fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).
CRITICAL (3):
- C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
no longer silently drop interest links
- C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
- C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
callers must go through /stage with the override-guard chain
HIGH (14/15):
- H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
interests/documents/reservations/reminders/invoices (migration 0070)
- H-02 login page reads ?redirect= param with same-origin guard
- H-03 CRM invite token moves to URL fragment so it never lands in
nginx access logs / Referer headers
- H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
- H-05 toggleAccount writes an audit row
- H-06 upsertSetting masks any value whose key ends with _encrypted
- H-07 archiveClient cascade fires per-interest audit rows
- H-08 createSalesTransporter applies SMTP_TIMEOUTS
- H-09 AppShell stable children — viewport flip across breakpoint no
longer destroys in-progress form drafts
- H-10 portal documents page swaps Unicode glyph status icons for
Lucide CheckCircle2/XCircle/Circle + aria-labels
- H-12 list components swap alert(...) for toast.warning(...)
- H-13 5 icon-only buttons gain aria-label
- H-14 parseBody treats empty bodies as {}
- H-15 admin layout renders a 403 panel instead of silent bounce
- H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet
MEDIUM (28+):
- M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
WHEREs across custom-fields, notes (all 6 entity types x update +
delete), client-contacts, yacht ownerClient lookup, webhook reads
- M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
- M-EM01 portal-auth emails thread through portId
- M-EM02 sendEmail accepts cc/bcc params
- M-EM04 notification_digest catalog key
- M-IN01 portal presigned download URLs use 4h TTL
- M-IN02 OpenAI client lazy-instantiated
- M-IN04 stale pdfme refs updated to pdf-lib AcroForm
- M-IN05 umami.testConnection returns tagged union
- M-L01 reservations tenure_type unified with berths
- M-L02 report-generators canonicalize stage values
- M-AU01 audit log placeholder copy fixed
- M-AU04 outcome_set / outcome_cleared distinct audit verbs
- M-NEW-2 activity feed entity name+type separator
- M-R01 portal allowlist narrowed + portal_session backstop in proxy
- M-SC02 companies archived partial index
- M-SC04 audit_logs.searchText documented as DB-managed
- M-S01 storage_s3_access_key_encrypted admin field
- M-U01 audit log empty state uses <EmptyState>
- M-U09 invoice delete dialog -> <AlertDialog>
- M-U10 toast.success on ClientForm + InterestForm create/edit
- M-U11 settings-form-card logo preview alt text
- M-U14 mobile topbar title on clients/yachts/interests/berths
- M-U15 Invoices in mobile More-sheet
LOW (6/8):
- L-AU01 severity defaults for security-relevant verbs
- L-AU02 +13 missing actions in admin audit filter
- L-AU03 +7 missing entity types in admin audit filter
- L-AU04 dead listAuditLogs stubbed
- L-D02 CLAUDE.md Owner-wins chain tightened
Bonus — Document detail polish (#67 partial, 3/6 deliverables):
- state-aware action button per signer
- watcher Add UI with display-name resolution
- cleanSignerName cleanup
Prior session work bundled in:
- Documenso v2 webhook + envelope-ID normalization + sequential signing
- SigningProgress UI redesign (avatars, per-signer state, timestamps)
- env->admin settings registry + RegistryDrivenForm + encrypted creds
- Embedded-signing card + Test connection + setup help
- Dev-mode EMAIL_REDIRECT_TO banner
- Pipeline rules admin page
- Sales email config card
- Audit log details Sheet
- EOI tab: Finalising badge, absolute timestamps, sequential indicator
- Notes pipeline_stage_at_creation (migration 0069)
- Documenso numeric ID dual-key webhook (migration 0068)
- Dimensions criterion copy (migration 0067)
Tests: 1374/1374 vitest pass. tsc clean. lint clean.
See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,57 +1,8 @@
|
||||
import { and, eq, desc, sql, gte, lte } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { auditLogs } from '@/lib/db/schema';
|
||||
|
||||
interface AuditListQuery {
|
||||
page: number;
|
||||
limit: number;
|
||||
entityType?: string;
|
||||
action?: string;
|
||||
userId?: string;
|
||||
entityId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export async function listAuditLogs(portId: string, query: AuditListQuery) {
|
||||
const conditions = [eq(auditLogs.portId, portId)];
|
||||
|
||||
if (query.entityType) conditions.push(eq(auditLogs.entityType, query.entityType));
|
||||
if (query.action) conditions.push(eq(auditLogs.action, query.action));
|
||||
if (query.userId) conditions.push(eq(auditLogs.userId, query.userId));
|
||||
if (query.entityId) conditions.push(eq(auditLogs.entityId, query.entityId));
|
||||
if (query.dateFrom) conditions.push(gte(auditLogs.createdAt, new Date(query.dateFrom)));
|
||||
if (query.dateTo) conditions.push(lte(auditLogs.createdAt, new Date(query.dateTo)));
|
||||
if (query.search) {
|
||||
conditions.push(
|
||||
sql`(${auditLogs.entityType} ILIKE ${'%' + query.search + '%'} OR ${auditLogs.action} ILIKE ${'%' + query.search + '%'})`,
|
||||
);
|
||||
}
|
||||
|
||||
const offset = (query.page - 1) * query.limit;
|
||||
|
||||
const [data, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(auditLogs)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(auditLogs.createdAt))
|
||||
.limit(query.limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(auditLogs)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
pagination: {
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
total: Number(countResult[0]?.count ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
// L-AU04: the legacy ILIKE-based `listAuditLogs` was superseded by the
|
||||
// FTS-backed `searchAuditLogs` (audit-search.service.ts) in 2026-05-08.
|
||||
// Nothing imports the old function anymore — keeping a stub file rather
|
||||
// than deleting outright in case the module path resolves elsewhere in
|
||||
// build tooling. Remove the file in a follow-up sweep.
|
||||
//
|
||||
// All audit reads should go through `searchAuditLogs(options)` instead.
|
||||
export {};
|
||||
|
||||
@@ -617,6 +617,22 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta)
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
// H-07: emit per-interest archive rows so an auditor searching for a
|
||||
// specific archived interest finds it directly — the client-level row's
|
||||
// `cascadedInterestIds` array doesn't participate in audit-log FTS.
|
||||
for (const interestId of archivedInterestIds) {
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'archive',
|
||||
entityType: 'interest',
|
||||
entityId: interestId,
|
||||
metadata: { cascadeSource: 'client_archive', clientId: id },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
emitToRoom(`port:${portId}`, 'client:archived', { clientId: id });
|
||||
for (const interestId of archivedInterestIds) {
|
||||
emitToRoom(`port:${portId}`, 'interest:archived', { interestId });
|
||||
@@ -737,7 +753,8 @@ export async function updateContact(
|
||||
const [updated] = await db
|
||||
.update(clientContacts)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(clientContacts.id, contactId))
|
||||
// M-MT03: pin the WHERE to (id, clientId) for defense-in-depth.
|
||||
.where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)))
|
||||
.returning();
|
||||
|
||||
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
|
||||
@@ -761,7 +778,10 @@ export async function removeContact(
|
||||
});
|
||||
if (!contact) throw new NotFoundError('Contact');
|
||||
|
||||
await db.delete(clientContacts).where(eq(clientContacts.id, contactId));
|
||||
// M-MT03: pin (id, clientId) for defense-in-depth.
|
||||
await db
|
||||
.delete(clientContacts)
|
||||
.where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)));
|
||||
|
||||
emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
|
||||
}
|
||||
|
||||
@@ -68,7 +68,12 @@ export async function createCrmInvite(args: {
|
||||
internalMessage: 'Failed to create CRM invite',
|
||||
});
|
||||
|
||||
const link = `${env.APP_URL}/set-password?token=${raw}`;
|
||||
// H-03: token moves to the URL fragment so it never lands in nginx/Caddy
|
||||
// access logs (or any HTTP-Referer-leaking middlebox). The fragment is
|
||||
// browser-only; the server only sees the path. set-password/page.tsx
|
||||
// reads either `#token=` or `?token=` (back-compat for outstanding
|
||||
// links). encodeURIComponent guards against `#`/`&` in the token.
|
||||
const link = `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`;
|
||||
const result = await crmInviteEmail({
|
||||
link,
|
||||
ttlHours: INVITE_TTL_HOURS,
|
||||
@@ -230,7 +235,12 @@ export async function resendCrmInvite(
|
||||
.set({ tokenHash: hash, expiresAt })
|
||||
.where(eq(crmUserInvites.id, inviteId));
|
||||
|
||||
const link = `${env.APP_URL}/set-password?token=${raw}`;
|
||||
// H-03: token moves to the URL fragment so it never lands in nginx/Caddy
|
||||
// access logs (or any HTTP-Referer-leaking middlebox). The fragment is
|
||||
// browser-only; the server only sees the path. set-password/page.tsx
|
||||
// reads either `#token=` or `?token=` (back-compat for outstanding
|
||||
// links). encodeURIComponent guards against `#`/`&` in the token.
|
||||
const link = `${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}`;
|
||||
const branding = await getBrandingShell(meta.portId);
|
||||
const result = await crmInviteEmail(
|
||||
{
|
||||
|
||||
@@ -141,7 +141,13 @@ export async function updateDefinition(
|
||||
...(data.isRequired !== undefined && { isRequired: data.isRequired }),
|
||||
...(data.sortOrder !== undefined && { sortOrder: data.sortOrder }),
|
||||
})
|
||||
.where(eq(customFieldDefinitions.id, fieldId))
|
||||
// M-MT01: defense-in-depth port_id filter on the UPDATE WHERE.
|
||||
// The findFirst above is the primary tenant check, but a concurrent
|
||||
// port-swap (or a future cache-jitter path that lets the existing
|
||||
// pointer outlive its read) would otherwise let the write land
|
||||
// against a sibling port's row with the same id. The entry-point
|
||||
// and the write share the same tuple identity now.
|
||||
.where(and(eq(customFieldDefinitions.id, fieldId), eq(customFieldDefinitions.portId, portId)))
|
||||
.returning();
|
||||
|
||||
const updated = updateRows[0];
|
||||
|
||||
@@ -13,15 +13,24 @@ interface DocumensoCreds {
|
||||
}
|
||||
|
||||
async function resolveCreds(portId?: string): Promise<DocumensoCreds> {
|
||||
// env.DOCUMENSO_API_URL / env.DOCUMENSO_API_KEY are now optional — the
|
||||
// canonical config lives in admin settings. Empty fallbacks let the call
|
||||
// proceed; if both env + admin are blank, the downstream fetch hits an
|
||||
// empty URL and errors with a clear "Documenso not configured" upstream
|
||||
// (vs. crashing at type-check or boot).
|
||||
if (!portId) {
|
||||
return {
|
||||
baseUrl: env.DOCUMENSO_API_URL,
|
||||
apiKey: env.DOCUMENSO_API_KEY,
|
||||
baseUrl: env.DOCUMENSO_API_URL ?? '',
|
||||
apiKey: env.DOCUMENSO_API_KEY ?? '',
|
||||
apiVersion: env.DOCUMENSO_API_VERSION,
|
||||
};
|
||||
}
|
||||
const cfg = await getPortDocumensoConfig(portId);
|
||||
return { baseUrl: cfg.apiUrl, apiKey: cfg.apiKey, apiVersion: cfg.apiVersion };
|
||||
return {
|
||||
baseUrl: cfg.apiUrl ?? '',
|
||||
apiKey: cfg.apiKey ?? '',
|
||||
apiVersion: cfg.apiVersion,
|
||||
};
|
||||
}
|
||||
|
||||
async function documensoFetchOnce(
|
||||
@@ -113,12 +122,38 @@ async function documensoFetch(
|
||||
});
|
||||
}
|
||||
|
||||
// Documenso 2.x renamed top-level `id` → `documentId` and recipient `id` →
|
||||
// `recipientId`; v1.13 still uses `id`. Normalize both shapes to the legacy
|
||||
// `id` form that this codebase consumes everywhere downstream.
|
||||
// Documenso 2.x has THREE potential ID fields on responses depending on the
|
||||
// endpoint:
|
||||
// - `envelopeId: string` — the public 'envelope_xxx' identifier. This is
|
||||
// what every downstream endpoint expects (/envelope/update,
|
||||
// /envelope/distribute, /envelope/{id}, DELETE etc).
|
||||
// - `documentId: number|string` — an alias on some responses.
|
||||
// - `id` — on /template/use this is the INTERNAL numeric
|
||||
// pk (e.g. 17). On other endpoints `id` is sometimes the envelope_xxx
|
||||
// string. On v1.13 `id` is the only field and represents the document.
|
||||
//
|
||||
// Resolution order: envelopeId (most reliable, v2-only) → documentId →
|
||||
// id. We coerce to string everywhere downstream. A previous version of
|
||||
// this normalizer used `documentId ?? id` which picked up the numeric
|
||||
// internal pk from /template/use, broke envelope/update + envelope/distribute
|
||||
// with "Invalid envelope ID", and silently failed every title-change +
|
||||
// distribute on freshly-created envelopes.
|
||||
function normalizeDocument(raw: unknown): DocumensoDocument {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
const id = String(r.documentId ?? r.id ?? '');
|
||||
const id = String(r.envelopeId ?? r.documentId ?? r.id ?? '');
|
||||
// Documenso v2 also exposes a numeric internal pk (`id`) alongside the
|
||||
// envelope_xxx string — webhooks ONLY carry the numeric id, so we
|
||||
// surface it separately so the webhook resolver can match by either.
|
||||
// For v1 responses `id` IS the (numeric) document id, so this is the
|
||||
// same value as `id` above. For v2 with envelopeId set, this captures
|
||||
// the internal pk that the webhook payload uses.
|
||||
const numericIdRaw = r.id;
|
||||
const numericId =
|
||||
typeof numericIdRaw === 'number'
|
||||
? String(numericIdRaw)
|
||||
: typeof numericIdRaw === 'string' && /^\d+$/.test(numericIdRaw)
|
||||
? numericIdRaw
|
||||
: null;
|
||||
const status = String(r.status ?? 'PENDING');
|
||||
// v1.32+ payloads carry a `Recipient` (capital R) array as a legacy
|
||||
// duplicate of `recipients` — fall through to it so we still resolve
|
||||
@@ -142,7 +177,7 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
|
||||
// see on subsequent webhook deliveries.
|
||||
token: typeof rec.token === 'string' ? rec.token : undefined,
|
||||
}));
|
||||
return { id, status, recipients };
|
||||
return { id, numericId, status, recipients };
|
||||
}
|
||||
|
||||
export interface DocumensoRecipient {
|
||||
@@ -154,6 +189,10 @@ export interface DocumensoRecipient {
|
||||
|
||||
export interface DocumensoDocument {
|
||||
id: string;
|
||||
/** Documenso v2 numeric internal pk. Populated alongside the
|
||||
* envelope_xxx `id` so callers can persist both — webhooks use this
|
||||
* one. Null when the response didn't include a numeric id. */
|
||||
numericId: string | null;
|
||||
status: string;
|
||||
recipients: Array<{
|
||||
id: string;
|
||||
@@ -353,16 +392,179 @@ export async function generateDocumentFromTemplate(
|
||||
portId?: string,
|
||||
): Promise<DocumensoDocument> {
|
||||
const safePayload = applyPayloadRedirect(payload);
|
||||
const { apiVersion } = await resolveCreds(portId);
|
||||
if (env.EMAIL_REDIRECT_TO) {
|
||||
logger.info(
|
||||
{ templateId },
|
||||
{ templateId, apiVersion },
|
||||
'Documenso template-generate payload redirected to EMAIL_REDIRECT_TO',
|
||||
);
|
||||
}
|
||||
|
||||
// v2 uses POST /api/v2/template/use with `prefillFields` keyed by field ID.
|
||||
// The payload builder emits `prefillFields` when a cached field name→ID map
|
||||
// exists for the port — see `buildDocumensoPayload` + `documenso-template-
|
||||
// sync.service.ts`. When no map is cached we still hit /template/use but
|
||||
// skip prefillFields (recipients-only); v2 instances ignore the legacy
|
||||
// `formValues` field, so emit it only on v1 paths.
|
||||
//
|
||||
// v1 (incl. Documenso 1.13.x) uses the legacy
|
||||
// /api/v1/templates/{id}/generate-document with `formValues` by name.
|
||||
if (apiVersion === 'v2') {
|
||||
const v2Payload = safePayload as Record<string, unknown>;
|
||||
|
||||
// Title PATCH must happen BEFORE distribution because Documenso v2
|
||||
// restricts `envelope/update` to DRAFT envelopes only. So the v2
|
||||
// flow is: 1) /template/use without distribute → DRAFT envelope, 2)
|
||||
// /envelope/update with the title, 3) /envelope/distribute → PENDING
|
||||
// envelope with signingUrls populated. Step 3 is REQUIRED because
|
||||
// v2 doesn't return signingUrls from /template/use — without it
|
||||
// `document_signers.signing_url` stays null and the manual
|
||||
// "Send invitation" button errors with "Signer has no Documenso URL".
|
||||
const created = await documensoFetch(
|
||||
`/api/v2/template/use`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...v2Payload, templateId }),
|
||||
},
|
||||
portId,
|
||||
).then(normalizeDocument);
|
||||
|
||||
const desiredTitle =
|
||||
typeof v2Payload.title === 'string' && v2Payload.title.length > 0 ? v2Payload.title : null;
|
||||
// `/template/use` silently drops the `meta` field on the request body —
|
||||
// signingOrder, subject, message, redirectUrl all inherit from the
|
||||
// template's stored defaults. To enforce the per-port `documenso_signing_
|
||||
// order` (PARALLEL vs SEQUENTIAL) and per-port subject/message, replay
|
||||
// the meta fields through `/envelope/update` while the envelope is still
|
||||
// DRAFT (update is rejected once distributed).
|
||||
const payloadMeta = (v2Payload.meta as Record<string, unknown> | undefined) ?? {};
|
||||
const updateMeta: Record<string, unknown> = {};
|
||||
for (const key of ['signingOrder', 'subject', 'message', 'redirectUrl', 'language'] as const) {
|
||||
const value = payloadMeta[key];
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
updateMeta[key] = value;
|
||||
}
|
||||
}
|
||||
const hasMetaPatch = Object.keys(updateMeta).length > 0;
|
||||
if (desiredTitle || hasMetaPatch) {
|
||||
try {
|
||||
const updateBody: Record<string, unknown> = { envelopeId: created.id };
|
||||
if (desiredTitle) updateBody.data = { title: desiredTitle };
|
||||
if (hasMetaPatch) updateBody.meta = updateMeta;
|
||||
const updateResponse = await documensoFetch(
|
||||
`/api/v2/envelope/update`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updateBody),
|
||||
},
|
||||
portId,
|
||||
);
|
||||
// Log the raw response so we can debug when Documenso's UI keeps
|
||||
// showing the template PDF filename despite our update succeeding.
|
||||
// The update endpoint returns `{success: true}` on a clean ack;
|
||||
// anything else hints that the title field wasn't accepted.
|
||||
logger.info(
|
||||
{ docId: created.id, desiredTitle, updateMeta, updateResponse },
|
||||
'Documenso envelope update — response',
|
||||
);
|
||||
// Belt-and-braces verify: re-read the envelope and confirm the
|
||||
// title persisted. Documenso v2's listing surface has been known
|
||||
// to render the underlying PDF filename rather than envelope.title
|
||||
// — surfacing the actual returned `title` here lets us tell
|
||||
// whether the API accepted our value (and the UI is the issue)
|
||||
// vs the update silently no-op'd.
|
||||
try {
|
||||
const verify = (await documensoFetch(
|
||||
`/api/v2/envelope/${created.id}`,
|
||||
{ method: 'GET' },
|
||||
portId,
|
||||
)) as Record<string, unknown>;
|
||||
logger.info(
|
||||
{
|
||||
docId: created.id,
|
||||
desiredTitle,
|
||||
actualTitle: verify?.title,
|
||||
titleMatches: verify?.title === desiredTitle,
|
||||
actualMeta: verify?.documentMeta ?? verify?.envelopeMeta ?? verify?.meta,
|
||||
},
|
||||
'Documenso envelope update — verification',
|
||||
);
|
||||
} catch {
|
||||
// GET verify is best-effort; don't fail generate on it.
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ docId: created.id, updateMeta, err: err instanceof Error ? err.message : err },
|
||||
'Documenso envelope update failed — created envelope keeps template default title/meta',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Distribute the envelope so per-recipient signing URLs are minted.
|
||||
// Without this, the recipients returned by /template/use have
|
||||
// `signingUrl: null` and our "Send invitation" button errors out
|
||||
// with "Signer has no Documenso URL yet."
|
||||
//
|
||||
// Documenso v2's distribute fires its own emails by default, but
|
||||
// our payload sets `meta.distributionMethod: 'NONE'` so it just
|
||||
// mints the URLs without emailing — our branded
|
||||
// `sendSigningInvitation` is the dispatcher.
|
||||
//
|
||||
// We replace `created` with the distribute response because that's
|
||||
// the call that actually returns recipients with `signingUrl`
|
||||
// populated; downstream code (the document_signers insert in
|
||||
// generateAndSignViaDocumensoTemplate) reads from this object.
|
||||
// CRITICAL: pass `meta.distributionMethod: 'NONE'` in the distribute
|
||||
// body. `/template/use` doesn't accept a `meta` field at all — our
|
||||
// payload's `meta.distributionMethod: 'NONE'` is silently dropped at
|
||||
// template-use time, so the envelope inherits the TEMPLATE's
|
||||
// distributionMethod (which defaults to EMAIL). Without overriding
|
||||
// it on the distribute call, Documenso fires its own emails the
|
||||
// moment distribute runs — which clashes with our branded
|
||||
// `sendSigningInvitation` flow and ignores the per-port
|
||||
// `eoi_send_mode: 'manual'` setting. The override here is the
|
||||
// authoritative one for v2 envelopes.
|
||||
try {
|
||||
const distributed = (await documensoFetch(
|
||||
`/api/v2/envelope/distribute`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
envelopeId: created.id,
|
||||
meta: { distributionMethod: 'NONE' },
|
||||
}),
|
||||
},
|
||||
portId,
|
||||
)) as Record<string, unknown>;
|
||||
const normalized = normalizeDocument({
|
||||
envelopeId: distributed.id ?? created.id,
|
||||
// Distribute doesn't return the numeric id, so we synthesize it
|
||||
// from the original /template/use response by passing the numeric
|
||||
// id as Documenso's `id` field — normalizeDocument picks it up
|
||||
// as numericId. Without this, the row would lose its numeric id
|
||||
// on distribute and webhooks couldn't resolve back to it.
|
||||
id: created.numericId,
|
||||
status: 'PENDING',
|
||||
recipients: distributed.recipients,
|
||||
});
|
||||
return normalized;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ docId: created.id, err: err instanceof Error ? err.message : err },
|
||||
'Documenso envelope distribute failed — signingUrl will be null. Send-invitation will fail until the envelope is distributed.',
|
||||
);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
return documensoFetch(
|
||||
`/api/v1/templates/${templateId}/generate-document`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(safePayload),
|
||||
},
|
||||
portId,
|
||||
@@ -378,6 +580,48 @@ export async function generateDocumentFromTemplate(
|
||||
* we're trying to hold comms). When the redirect is on we skip the API
|
||||
* call entirely and return a synthetic "still pending" response.
|
||||
*/
|
||||
/**
|
||||
* v2-only: distribute an envelope. Moves it from DRAFT → PENDING and
|
||||
* mints per-recipient signing URLs. Does NOT email recipients when the
|
||||
* envelope's `meta.distributionMethod` is `NONE` (our default — branded
|
||||
* emails are dispatched by `sendSigningInvitation`).
|
||||
*
|
||||
* Direct call bypassing `sendDocument`'s dev-mode short-circuit. The
|
||||
* self-heal path for envelopes created before the auto-distribute fix
|
||||
* shipped uses this so the URLs actually get minted in dev too —
|
||||
* `EMAIL_REDIRECT_TO` already rewrites recipient emails to a safe
|
||||
* address at envelope-creation time, so distribute can't accidentally
|
||||
* email a real client.
|
||||
*/
|
||||
export async function distributeEnvelopeV2(
|
||||
envelopeId: string,
|
||||
portId?: string,
|
||||
): Promise<DocumensoDocument> {
|
||||
// Architectural rule (Matt 2026-05-15): ALL outbound emails go through
|
||||
// our branded `sendSigningInvitation` path — Documenso never fires its
|
||||
// own emails for our envelopes. `meta.distributionMethod: 'NONE'`
|
||||
// here is the ONLY place where this contract is actually enforced
|
||||
// for v2 envelopes (the corresponding flag in /template/use is
|
||||
// silently dropped because that endpoint doesn't accept a meta field).
|
||||
const distributed = (await documensoFetch(
|
||||
`/api/v2/envelope/distribute`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
envelopeId,
|
||||
meta: { distributionMethod: 'NONE' },
|
||||
}),
|
||||
},
|
||||
portId,
|
||||
)) as Record<string, unknown>;
|
||||
return normalizeDocument({
|
||||
id: distributed.id ?? envelopeId,
|
||||
status: 'PENDING',
|
||||
recipients: distributed.recipients,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendDocument(docId: string, portId?: string): Promise<DocumensoDocument> {
|
||||
if (env.EMAIL_REDIRECT_TO) {
|
||||
logger.warn(
|
||||
@@ -395,11 +639,18 @@ export async function sendDocument(docId: string, portId?: string): Promise<Docu
|
||||
// v2: POST /api/v2/envelope/distribute with body { envelopeId }.
|
||||
// Returns the envelope with per-recipient signingUrl fields populated —
|
||||
// this is one of the genuine v2 wins (saves a separate GET round-trip).
|
||||
// `meta.distributionMethod: 'NONE'` is the authoritative way to suppress
|
||||
// Documenso's own emails for v2 envelopes — see distributeEnvelopeV2
|
||||
// for the full rationale. Branded sends are routed through
|
||||
// `sendSigningInvitation` separately.
|
||||
const distributed = (await documensoFetch(
|
||||
'/api/v2/envelope/distribute',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ envelopeId: docId }),
|
||||
body: JSON.stringify({
|
||||
envelopeId: docId,
|
||||
meta: { distributionMethod: 'NONE' },
|
||||
}),
|
||||
},
|
||||
portId,
|
||||
)) as Record<string, unknown>;
|
||||
@@ -435,6 +686,190 @@ export async function getDocument(docId: string, portId?: string): Promise<Docum
|
||||
return documensoFetch(path, undefined, portId).then(normalizeDocument);
|
||||
}
|
||||
|
||||
// ─── Template introspection ─────────────────────────────────────────────────
|
||||
|
||||
export interface DocumensoTemplateRecipient {
|
||||
id: number;
|
||||
role: string; // 'SIGNER' | 'APPROVER' | 'CC' | 'VIEWER'
|
||||
signingOrder: number;
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface DocumensoTemplateField {
|
||||
id: number;
|
||||
type: string;
|
||||
/**
|
||||
* The human label assigned in the template editor — for v2 templates this
|
||||
* comes from `field.fieldMeta.label`; for v1 templates it's available as
|
||||
* `field.fieldMeta.label` too (the shape was preserved). Used as the key
|
||||
* for the cached field-name → ID map that drives v2's `prefillFields`.
|
||||
*/
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface DocumensoTemplate {
|
||||
id: number;
|
||||
title: string;
|
||||
recipients: DocumensoTemplateRecipient[];
|
||||
fields: DocumensoTemplateField[];
|
||||
/**
|
||||
* v2 only. Each entry corresponds to one underlying PDF file on the
|
||||
* template — usually a single envelope item per template, but Documenso
|
||||
* 2.x supports stitching multiple PDFs together. Used by the sync flow
|
||||
* to download each PDF and inspect its native AcroForm fields.
|
||||
*/
|
||||
envelopeItems: Array<{ id: string }>;
|
||||
/**
|
||||
* v2 only. The template's stored meta — signing order, distribution
|
||||
* method, redirect URL. Surfaced in the sync report so the admin can
|
||||
* confirm what the template itself is configured to do at envelope
|
||||
* creation time. /template/use does NOT accept a signingOrder override,
|
||||
* so these values are what every generated envelope inherits.
|
||||
*/
|
||||
meta: {
|
||||
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
|
||||
distributionMethod: 'EMAIL' | 'NONE' | null;
|
||||
redirectUrl: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplate(raw: unknown): DocumensoTemplate {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
const id = Number(r.templateId ?? r.id ?? 0);
|
||||
const title = String(r.title ?? '');
|
||||
const recipientsRaw =
|
||||
(r.recipients as Array<Record<string, unknown>> | undefined) ??
|
||||
(r.Recipient as Array<Record<string, unknown>> | undefined) ??
|
||||
[];
|
||||
const recipients: DocumensoTemplateRecipient[] = recipientsRaw.map((rec) => ({
|
||||
id: Number(rec.recipientId ?? rec.id ?? 0),
|
||||
role: String(rec.role ?? 'SIGNER'),
|
||||
signingOrder: Number(rec.signingOrder ?? 0),
|
||||
name: typeof rec.name === 'string' ? rec.name : undefined,
|
||||
email: typeof rec.email === 'string' ? rec.email : undefined,
|
||||
}));
|
||||
const fieldsRaw = (r.fields as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const fields: DocumensoTemplateField[] = fieldsRaw.map((f) => {
|
||||
const fieldMeta = (f.fieldMeta as Record<string, unknown> | undefined) ?? {};
|
||||
return {
|
||||
id: Number(f.id ?? 0),
|
||||
type: String(f.type ?? ''),
|
||||
label: typeof fieldMeta.label === 'string' ? fieldMeta.label : undefined,
|
||||
};
|
||||
});
|
||||
const itemsRaw = (r.envelopeItems as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const envelopeItems = itemsRaw
|
||||
.map((it) => ({ id: typeof it.id === 'string' ? it.id : '' }))
|
||||
.filter((it) => it.id);
|
||||
|
||||
// templateMeta on v2 carries the signing order + distribution method +
|
||||
// post-sign redirect. v1 templates put the same data on the doc root, so
|
||||
// try both shapes.
|
||||
const metaRaw =
|
||||
(r.templateMeta as Record<string, unknown> | undefined) ?? (r as Record<string, unknown>);
|
||||
const signingOrderRaw = metaRaw.signingOrder;
|
||||
const distributionRaw = metaRaw.distributionMethod;
|
||||
const redirectRaw = metaRaw.redirectUrl;
|
||||
const meta: DocumensoTemplate['meta'] = {
|
||||
signingOrder:
|
||||
signingOrderRaw === 'PARALLEL' || signingOrderRaw === 'SEQUENTIAL' ? signingOrderRaw : null,
|
||||
distributionMethod:
|
||||
distributionRaw === 'EMAIL' || distributionRaw === 'NONE' ? distributionRaw : null,
|
||||
redirectUrl: typeof redirectRaw === 'string' && redirectRaw ? redirectRaw : null,
|
||||
};
|
||||
|
||||
return { id, title, recipients, fields, envelopeItems, meta };
|
||||
}
|
||||
|
||||
/**
|
||||
* v2-only: download the raw PDF bytes for one envelope-item (each template
|
||||
* is backed by 1+ envelope items, one per uploaded PDF). The sync flow
|
||||
* uses this to inspect the PDF's AcroForm field names, surfacing whether
|
||||
* the operator's fillable PDF matches the CRM's expected field-label set.
|
||||
*
|
||||
* v1 templates aren't supported here — the v1 download endpoint requires
|
||||
* a documentId, not a templateId, and v1 doesn't expose envelope items.
|
||||
*/
|
||||
export async function downloadEnvelopeItemPdf(
|
||||
envelopeItemId: string,
|
||||
portId?: string,
|
||||
version: 'signed' | 'original' = 'original',
|
||||
): Promise<Buffer> {
|
||||
const { baseUrl, apiKey } = await resolveCreds(portId);
|
||||
const res = await fetchWithTimeout(
|
||||
`${baseUrl}/api/v2/envelope/item/${envelopeItemId}/download?version=${version}`,
|
||||
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => '');
|
||||
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
||||
internalMessage: `download envelope item ${envelopeItemId} → ${res.status} ${errText}`,
|
||||
});
|
||||
}
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a Documenso template by ID. Used by the admin "Sync from Documenso"
|
||||
* flow to discover recipient slot IDs + template field IDs without forcing
|
||||
* the operator to type them in by hand.
|
||||
*
|
||||
* - v2 path: `GET /api/v2/template/{templateId}` (returns envelope-shape JSON)
|
||||
* - v1 path: `GET /api/v1/templates/{templateId}` (returns legacy doc shape;
|
||||
* recipient + field arrays are present but with `id` instead of
|
||||
* `recipientId`/`templateId`).
|
||||
*/
|
||||
export async function getTemplate(templateId: number, portId?: string): Promise<DocumensoTemplate> {
|
||||
const { apiVersion } = await resolveCreds(portId);
|
||||
const path =
|
||||
apiVersion === 'v2' ? `/api/v2/template/${templateId}` : `/api/v1/templates/${templateId}`;
|
||||
return documensoFetch(path, undefined, portId).then(normalizeTemplate);
|
||||
}
|
||||
|
||||
/**
|
||||
* v2-only: resolve a Documenso template by its envelope ID (the
|
||||
* `envelope_xxxxxxxx` string that appears in the Documenso UI URL). The
|
||||
* admin pastes that URL slug into the Sync input and we look up the
|
||||
* matching numeric template id via `GET /api/v2/template`. Returns null
|
||||
* when no template matches.
|
||||
*
|
||||
* Documenso 2.x's template editor URL is
|
||||
* `https://.../templates/envelope_xxxxxxxx`, but the numeric `id` is not
|
||||
* surfaced anywhere in the UI — so admins have no way to enter the
|
||||
* numeric id by hand. This resolver bridges the gap.
|
||||
*/
|
||||
export async function findTemplateIdByEnvelopeId(
|
||||
envelopeId: string,
|
||||
portId?: string,
|
||||
): Promise<number | null> {
|
||||
const { apiVersion } = await resolveCreds(portId);
|
||||
if (apiVersion !== 'v2') return null;
|
||||
// Paginate through templates (perPage maxes at ~100 on the upstream).
|
||||
// Most installs have <50 templates so the first page is usually enough.
|
||||
let page = 1;
|
||||
const perPage = 100;
|
||||
while (page < 20) {
|
||||
const res = (await documensoFetch(
|
||||
`/api/v2/template?page=${page}&perPage=${perPage}`,
|
||||
undefined,
|
||||
portId,
|
||||
)) as { data?: Array<Record<string, unknown>>; templates?: Array<Record<string, unknown>> };
|
||||
const rows = res.data ?? res.templates ?? [];
|
||||
if (!Array.isArray(rows) || rows.length === 0) return null;
|
||||
for (const row of rows) {
|
||||
const rowEnvelopeId = String(row.envelopeId ?? '');
|
||||
if (rowEnvelopeId === envelopeId) {
|
||||
const numericId = Number(row.id ?? 0);
|
||||
return Number.isInteger(numericId) && numericId > 0 ? numericId : null;
|
||||
}
|
||||
}
|
||||
if (rows.length < perPage) return null;
|
||||
page += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email a signing reminder to one recipient. Skipped entirely when
|
||||
* EMAIL_REDIRECT_TO is set - the recipient's stored email may still be
|
||||
@@ -482,12 +917,26 @@ export async function sendReminder(
|
||||
|
||||
export async function downloadSignedPdf(docId: string, portId?: string): Promise<Buffer> {
|
||||
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
|
||||
// v2: /api/v2/envelope/{id}/download (mirrors the v1 path under the
|
||||
// envelope namespace). v1: existing /documents/{id}/download.
|
||||
const path =
|
||||
apiVersion === 'v2'
|
||||
? `/api/v2/envelope/${docId}/download`
|
||||
: `/api/v1/documents/${docId}/download`;
|
||||
// v2 download is a two-step lookup: there's no /envelope/{id}/download path
|
||||
// (that 404s — see audit-2026-05-15). The canonical flow is:
|
||||
// 1. GET /envelope/{id} → read envelopeItems[0].id
|
||||
// 2. GET /envelope/item/{itemId}/download?version=signed
|
||||
// v1 keeps the direct /documents/{id}/download single-call path.
|
||||
if (apiVersion === 'v2') {
|
||||
const envelope = (await documensoFetch(
|
||||
`/api/v2/envelope/${docId}`,
|
||||
{ method: 'GET' },
|
||||
portId,
|
||||
)) as { envelopeItems?: Array<{ id?: string }> };
|
||||
const itemId = envelope.envelopeItems?.[0]?.id;
|
||||
if (!itemId) {
|
||||
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
||||
internalMessage: `v2 envelope ${docId} has no envelopeItems — cannot download signed PDF`,
|
||||
});
|
||||
}
|
||||
return downloadEnvelopeItemPdf(itemId, portId, 'signed');
|
||||
}
|
||||
const path = `/api/v1/documents/${docId}/download`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchWithTimeout(`${baseUrl}${path}`, {
|
||||
@@ -519,18 +968,25 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise
|
||||
return Buffer.from(arrayBuffer);
|
||||
}
|
||||
|
||||
/** Convenience health-check used by the admin "Test connection" button. */
|
||||
/** Convenience health-check used by the admin "Test connection" button.
|
||||
*
|
||||
* v2 cloud (Documenso 2.x) doesn't expose `/api/v1/health` — the old v1
|
||||
* path is gone. So we probe the appropriate cheap list endpoint per
|
||||
* version (`GET /api/v2/document` for v2, `GET /api/v1/health` for v1)
|
||||
* and treat a 401 as "creds rejected" and a 200 as "all good". A 404
|
||||
* means the URL points at something that isn't Documenso. */
|
||||
export async function checkDocumensoHealth(
|
||||
portId?: string,
|
||||
): Promise<{ ok: boolean; status?: number; error?: string; apiVersion?: DocumensoApiVersion }> {
|
||||
try {
|
||||
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
|
||||
// Both v1 and v2 expose /api/v1/health (v2 keeps the v1 path for
|
||||
// backward compat). If a v2 deployment ever moves this we'll add a
|
||||
// v2 branch — but as of Documenso 2.x there isn't a v2 health path.
|
||||
const res = await fetchWithTimeout(`${baseUrl}/api/v1/health`, {
|
||||
const path = apiVersion === 'v2' ? '/api/v2/document' : '/api/v1/health';
|
||||
const res = await fetchWithTimeout(`${baseUrl}${path}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
});
|
||||
// 2xx = full success; 401/403 = creds wrong but URL right (still a
|
||||
// partial-success signal — the admin should know it's an auth issue,
|
||||
// not a typoed URL). 404 = wrong URL.
|
||||
return { ok: res.ok, status: res.status, apiVersion };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
@@ -851,3 +1307,56 @@ export async function voidDocument(docId: string, portId?: string): Promise<void
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an envelope's metadata while it's still in DRAFT or PENDING — title,
|
||||
* subject, message, redirect URL, signing order, language. v2-only feature
|
||||
* (Documenso 1.13.x has no equivalent; admins on v1 need to void + recreate).
|
||||
*
|
||||
* Returns the normalized document shape so callers can persist the latest
|
||||
* title locally in the `documents` row.
|
||||
*/
|
||||
export async function updateEnvelope(
|
||||
docId: string,
|
||||
patch: {
|
||||
title?: string;
|
||||
meta?: {
|
||||
subject?: string;
|
||||
message?: string;
|
||||
redirectUrl?: string;
|
||||
language?: string;
|
||||
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
|
||||
timezone?: string;
|
||||
dateFormat?: string;
|
||||
};
|
||||
},
|
||||
portId?: string,
|
||||
): Promise<DocumensoDocument> {
|
||||
const { apiVersion } = await resolveCreds(portId);
|
||||
if (apiVersion !== 'v2') {
|
||||
throw new CodedError('DOCUMENSO_V1_NOT_SUPPORTED', {
|
||||
internalMessage:
|
||||
'updateEnvelope requires Documenso 2.x — the v1.13.x API has no envelope/update endpoint',
|
||||
});
|
||||
}
|
||||
// v2 update is POST /api/v2/envelope/update with the envelopeId in the
|
||||
// body — NOT a PATCH against a per-id path. The body splits document
|
||||
// properties (title, externalId, visibility, email) under `data` from
|
||||
// email/signing settings under `meta`. Restricted to DRAFT envelopes.
|
||||
const body: Record<string, unknown> = { envelopeId: docId };
|
||||
if (patch.title !== undefined) {
|
||||
body.data = { title: patch.title };
|
||||
}
|
||||
if (patch.meta) {
|
||||
body.meta = patch.meta;
|
||||
}
|
||||
return documensoFetch(
|
||||
`/api/v2/envelope/update`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
portId,
|
||||
).then(normalizeDocument);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { systemSettings } from '@/lib/db/schema/system';
|
||||
import { user, userProfiles } from '@/lib/db/schema/users';
|
||||
import type { EoiContext } from '@/lib/services/eoi-context';
|
||||
import { readSetting, SETTING_KEYS } from '@/lib/services/port-config';
|
||||
|
||||
export interface DocumensoTemplatePayload {
|
||||
title: string;
|
||||
@@ -20,6 +22,10 @@ export interface DocumensoTemplatePayload {
|
||||
*/
|
||||
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
|
||||
};
|
||||
/**
|
||||
* Legacy v1 path: form-field values keyed by field NAME. Documenso v1.13.x
|
||||
* accepts only this shape. v2 instances accept it via backward compat too.
|
||||
*/
|
||||
formValues: {
|
||||
Name: string;
|
||||
Email: string;
|
||||
@@ -41,13 +47,37 @@ export interface DocumensoTemplatePayload {
|
||||
Lease_10: boolean;
|
||||
Purchase: boolean;
|
||||
};
|
||||
/**
|
||||
* v2-native path: prefill values keyed by field ID. Generated by mapping
|
||||
* each `formValues` entry through the cached `documenso_eoi_field_map`
|
||||
* (name → ID) discovered via the admin's "Sync from Documenso" button.
|
||||
* v1 instances ignore this field; v2 instances accept either prefillFields
|
||||
* OR formValues but prefillFields-by-ID is the canonical modern path.
|
||||
*/
|
||||
prefillFields?: Array<{
|
||||
id: number;
|
||||
type: 'text' | 'number' | 'date' | 'checkbox' | 'dropdown';
|
||||
value: string;
|
||||
}>;
|
||||
recipients: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'SIGNER' | 'APPROVER';
|
||||
role: 'SIGNER' | 'APPROVER' | 'CC' | 'VIEWER';
|
||||
signingOrder: number;
|
||||
}>;
|
||||
/**
|
||||
* Extra recipients beyond the canonical client + developer + approver trio.
|
||||
* Used by the "send a copy to my manager" workflow: pass CC slots here and
|
||||
* they'll be appended to the recipients array at send time.
|
||||
*/
|
||||
extraRecipients?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'CC' | 'VIEWER';
|
||||
signingOrder?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DocumensoPayloadOptions {
|
||||
@@ -69,12 +99,28 @@ export interface DocumensoPayloadOptions {
|
||||
* Set via per-port `documenso_signing_order` system_settings key.
|
||||
*/
|
||||
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
|
||||
/**
|
||||
* Optional extra recipients beyond the canonical client+developer+approver
|
||||
* trio. Used by the "send a copy to my manager" workflow. CC = receives a
|
||||
* copy of the signed PDF; VIEWER = can view but not sign. Slot IDs must
|
||||
* exist on the Documenso template (CRM operator adds them in the template
|
||||
* editor first). v2-only on v2 instances; v1 ignores unknown roles.
|
||||
*/
|
||||
extraRecipients?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'CC' | 'VIEWER';
|
||||
signingOrder?: number;
|
||||
}>;
|
||||
/**
|
||||
* Which side of the yacht's stored dimensions (ft|m) flows into the EOI's
|
||||
* Length/Width/Draft formValues. Defaults to 'ft' when omitted for legacy
|
||||
* call sites; the EOI-generate drawer always supplies the rep's choice.
|
||||
*/
|
||||
dimensionUnit?: 'ft' | 'm';
|
||||
}
|
||||
|
||||
const DEFAULT_DEVELOPER_NAME = 'David Mizrahi';
|
||||
const DEFAULT_DEVELOPER_EMAIL = 'dm@portnimara.com';
|
||||
const DEFAULT_APPROVER_NAME = 'Abbie May';
|
||||
const DEFAULT_APPROVER_EMAIL = 'sales@portnimara.com';
|
||||
const DEFAULT_REDIRECT_URL = 'https://portnimara.com';
|
||||
|
||||
export interface EoiSignerConfig {
|
||||
@@ -82,9 +128,9 @@ export interface EoiSignerConfig {
|
||||
approver: { name: string; email: string };
|
||||
}
|
||||
|
||||
const DEFAULT_EOI_SIGNERS: EoiSignerConfig = {
|
||||
developer: { name: DEFAULT_DEVELOPER_NAME, email: DEFAULT_DEVELOPER_EMAIL },
|
||||
approver: { name: DEFAULT_APPROVER_NAME, email: DEFAULT_APPROVER_EMAIL },
|
||||
const EMPTY_SIGNERS: EoiSignerConfig = {
|
||||
developer: { name: '', email: '' },
|
||||
approver: { name: '', email: '' },
|
||||
};
|
||||
|
||||
function isSignerEntry(v: unknown): v is { name: string; email: string } {
|
||||
@@ -98,27 +144,89 @@ function isSignerEntry(v: unknown): v is { name: string; email: string } {
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the per-port `eoi_signers` setting, fall back to legacy hardcoded
|
||||
* defaults if missing or malformed. The fallback exists to keep older
|
||||
* ports working until an admin saves the setting; once saved, the DB row
|
||||
* always wins. */
|
||||
/** Look up `{name, email}` for a CRM user id by joining `userProfiles`
|
||||
* (display name) + `user` (auth email). Returns nulls on miss. */
|
||||
async function resolveCrmUser(
|
||||
userId: string | null,
|
||||
): Promise<{ name: string; email: string } | null> {
|
||||
if (!userId) return null;
|
||||
const [row] = await db
|
||||
.select({
|
||||
displayName: userProfiles.displayName,
|
||||
email: user.email,
|
||||
})
|
||||
.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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the developer + approver name/email for the EOI signing trio.
|
||||
*
|
||||
* Priority chain per slot (highest → lowest):
|
||||
* 1. Linked CRM user (`documenso_<role>_user_id`) — recommended path
|
||||
* because "the person on this slot" changes via a CRM admin re-link,
|
||||
* not a Documenso template edit. The display name comes from
|
||||
* `userProfiles.displayName`, the email from `user.email`.
|
||||
* 2. Free-text overrides (`documenso_<role>_name` +
|
||||
* `documenso_<role>_email`) — for ports where the signer isn't a
|
||||
* CRM-platform user (e.g. external counsel).
|
||||
* 3. Legacy `eoi_signers` JSON blob — kept for backward compat with
|
||||
* ports that haven't migrated to the registry-driven settings yet.
|
||||
* 4. Empty strings — let the Documenso template's stored values win.
|
||||
*
|
||||
* Either slot can resolve via a different tier than the other.
|
||||
*/
|
||||
export async function getPortEoiSigners(portId: string): Promise<EoiSignerConfig> {
|
||||
const row = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, 'eoi_signers'), eq(systemSettings.portId, portId)),
|
||||
});
|
||||
const value = row?.value as Record<string, unknown> | undefined;
|
||||
if (value && isSignerEntry(value.developer) && isSignerEntry(value.approver)) {
|
||||
return {
|
||||
developer: value.developer,
|
||||
approver: value.approver,
|
||||
};
|
||||
}
|
||||
return DEFAULT_EOI_SIGNERS;
|
||||
const [developerUserId, approverUserId, devName, devEmail, apprName, apprEmail, legacyRow] =
|
||||
await Promise.all([
|
||||
readSetting<string>(SETTING_KEYS.documensoDeveloperUserId, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoApproverUserId, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoDeveloperName, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoDeveloperEmail, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoApproverName, portId),
|
||||
readSetting<string>(SETTING_KEYS.documensoApproverEmail, portId),
|
||||
db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, 'eoi_signers'), eq(systemSettings.portId, portId)),
|
||||
}),
|
||||
]);
|
||||
|
||||
const legacyValue = legacyRow?.value as Record<string, unknown> | undefined;
|
||||
const legacyDev =
|
||||
legacyValue && isSignerEntry(legacyValue.developer) ? legacyValue.developer : null;
|
||||
const legacyApr =
|
||||
legacyValue && isSignerEntry(legacyValue.approver) ? legacyValue.approver : null;
|
||||
|
||||
const [developerFromUser, approverFromUser] = await Promise.all([
|
||||
resolveCrmUser(developerUserId ?? null),
|
||||
resolveCrmUser(approverUserId ?? null),
|
||||
]);
|
||||
|
||||
const developer =
|
||||
developerFromUser ??
|
||||
(devName && devEmail ? { name: devName, email: devEmail } : null) ??
|
||||
legacyDev ??
|
||||
EMPTY_SIGNERS.developer;
|
||||
|
||||
const approver =
|
||||
approverFromUser ??
|
||||
(apprName && apprEmail ? { name: apprName, email: apprEmail } : null) ??
|
||||
legacyApr ??
|
||||
EMPTY_SIGNERS.approver;
|
||||
|
||||
return { developer, approver };
|
||||
}
|
||||
|
||||
function formatAddress(address: EoiContext['client']['address']): string {
|
||||
if (!address) return '';
|
||||
return [address.street, address.city, address.country].filter(Boolean).join(', ');
|
||||
// Shortest comprehensive format so the line fits the EOI's Address field:
|
||||
// street, city, REGION (ISO-3166-2 suffix), postal, COUNTRY (alpha-2).
|
||||
return [address.street, address.city, address.subdivision, address.postalCode, address.countryIso]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function buildMessage(context: EoiContext): string {
|
||||
@@ -135,9 +243,74 @@ function buildMessage(context: EoiContext): string {
|
||||
export function buildDocumensoPayload(
|
||||
context: EoiContext,
|
||||
options: DocumensoPayloadOptions,
|
||||
/**
|
||||
* Cached field name → ID map from the per-port `documenso_eoi_field_map`
|
||||
* setting (populated by the admin "Sync from Documenso" button). When
|
||||
* provided, the payload also emits `prefillFields` keyed by ID — required
|
||||
* by v2's /template/use. v1 instances ignore this field; v2 instances
|
||||
* accept either prefillFields OR the legacy formValues shape.
|
||||
*/
|
||||
fieldMap?: Record<string, number> | null,
|
||||
): DocumensoTemplatePayload {
|
||||
// Honour the rep's unit choice from the EOI drawer's toggle. Defaults to
|
||||
// 'ft' for legacy call sites that don't pass `dimensionUnit`; new code
|
||||
// paths (generateAndSign + the drawer) always set it explicitly.
|
||||
// Append the unit suffix to every dimension value so the rendered EOI
|
||||
// reads "45 ft" / "13.7 m" rather than the bare number — the original
|
||||
// form field doesn't tell signers which unit they're looking at.
|
||||
const dimUnit: 'ft' | 'm' = options.dimensionUnit ?? 'ft';
|
||||
const yachtLength = dimUnit === 'ft' ? context.yacht?.lengthFt : context.yacht?.lengthM;
|
||||
const yachtWidth = dimUnit === 'ft' ? context.yacht?.widthFt : context.yacht?.widthM;
|
||||
const yachtDraft = dimUnit === 'ft' ? context.yacht?.draftFt : context.yacht?.draftM;
|
||||
const withUnit = (v: string | null | undefined): string =>
|
||||
v && String(v).trim() ? `${String(v).trim()} ${dimUnit}` : '';
|
||||
|
||||
const formValues = {
|
||||
Name: context.client.fullName,
|
||||
Email: context.client.primaryEmail ?? '',
|
||||
Address: formatAddress(context.client.address),
|
||||
// Yacht + berth are optional EOI fields; when not linked, render as
|
||||
// empty strings so the corresponding template inputs stay blank.
|
||||
'Yacht Name': context.yacht?.name ?? '',
|
||||
Length: withUnit(yachtLength),
|
||||
Width: withUnit(yachtWidth),
|
||||
Draft: withUnit(yachtDraft),
|
||||
// formatBerthRange(['A1']) === 'A1' — so single-berth EOIs render
|
||||
// identically to the legacy primary-only flow; multi-berth EOIs
|
||||
// now actually show the full range instead of just the primary
|
||||
// mooring.
|
||||
'Berth Number': context.eoiBerthRange || (context.berth?.mooringNumber ?? ''),
|
||||
Lease_10: false,
|
||||
Purchase: true,
|
||||
} as const;
|
||||
|
||||
// v2's prefillFields-by-ID emission. Map every formValue entry through the
|
||||
// cached field map; skip entries that aren't in the map (template doesn't
|
||||
// have that field, which is fine — Documenso silently drops unknown ones
|
||||
// in v1 too).
|
||||
const prefillFields = fieldMap
|
||||
? Object.entries(formValues)
|
||||
.map(([label, value]) => {
|
||||
const fieldId = fieldMap[label];
|
||||
if (fieldId == null) return null;
|
||||
const isBoolean = typeof value === 'boolean';
|
||||
return {
|
||||
id: fieldId,
|
||||
type: isBoolean ? ('checkbox' as const) : ('text' as const),
|
||||
value: String(value),
|
||||
};
|
||||
})
|
||||
.filter((x): x is { id: number; type: 'text' | 'checkbox'; value: string } => x !== null)
|
||||
: undefined;
|
||||
|
||||
// Title format: "<full name>-EOI-NDA[-<berth range>]". When the EOI is
|
||||
// tied to one or more berths, append the formatted range so the doc
|
||||
// identifies the deal at a glance in lists and Documenso dashboards.
|
||||
const berthSuffix = context.eoiBerthRange || context.berth?.mooringNumber || '';
|
||||
return {
|
||||
title: `${context.client.fullName}-EOI-NDA`,
|
||||
title: berthSuffix
|
||||
? `${context.client.fullName}-EOI-NDA-${berthSuffix}`
|
||||
: `${context.client.fullName}-EOI-NDA`,
|
||||
externalId: `loi-${options.interestId}`,
|
||||
meta: {
|
||||
message: buildMessage(context),
|
||||
@@ -146,24 +319,14 @@ export function buildDocumensoPayload(
|
||||
distributionMethod: 'NONE',
|
||||
...(options.signingOrder ? { signingOrder: options.signingOrder } : {}),
|
||||
},
|
||||
formValues: {
|
||||
Name: context.client.fullName,
|
||||
Email: context.client.primaryEmail ?? '',
|
||||
Address: formatAddress(context.client.address),
|
||||
// Yacht + berth are optional EOI fields; when not linked, render as
|
||||
// empty strings so the corresponding template inputs stay blank.
|
||||
'Yacht Name': context.yacht?.name ?? '',
|
||||
Length: context.yacht?.lengthFt ?? '',
|
||||
Width: context.yacht?.widthFt ?? '',
|
||||
Draft: context.yacht?.draftFt ?? '',
|
||||
// formatBerthRange(['A1']) === 'A1' — so single-berth EOIs render
|
||||
// identically to the legacy primary-only flow; multi-berth EOIs
|
||||
// now actually show the full range instead of just the primary
|
||||
// mooring.
|
||||
'Berth Number': context.eoiBerthRange || (context.berth?.mooringNumber ?? ''),
|
||||
Lease_10: false,
|
||||
Purchase: true,
|
||||
},
|
||||
formValues,
|
||||
...(prefillFields && prefillFields.length > 0 ? { prefillFields } : {}),
|
||||
// Per Documenso v2's /template/use schema, `email` and `name` accept "" as
|
||||
// a sentinel meaning "use the value baked into the template recipient".
|
||||
// So when an admin leaves the developer/approver name/email blank in our
|
||||
// admin settings, we pass "" rather than a hardcoded fallback — Documenso
|
||||
// then takes the email/name set on the template itself. A non-empty
|
||||
// admin value still wins (overrides the template at send time).
|
||||
recipients: [
|
||||
{
|
||||
id: options.clientRecipientId,
|
||||
@@ -174,18 +337,29 @@ export function buildDocumensoPayload(
|
||||
},
|
||||
{
|
||||
id: options.developerRecipientId,
|
||||
name: options.developerName ?? DEFAULT_DEVELOPER_NAME,
|
||||
email: options.developerEmail ?? DEFAULT_DEVELOPER_EMAIL,
|
||||
name: options.developerName ?? '',
|
||||
email: options.developerEmail ?? '',
|
||||
role: 'SIGNER',
|
||||
signingOrder: 2,
|
||||
},
|
||||
{
|
||||
id: options.approvalRecipientId,
|
||||
name: options.approverName ?? DEFAULT_APPROVER_NAME,
|
||||
email: options.approverEmail ?? DEFAULT_APPROVER_EMAIL,
|
||||
name: options.approverName ?? '',
|
||||
email: options.approverEmail ?? '',
|
||||
role: 'APPROVER',
|
||||
signingOrder: 3,
|
||||
},
|
||||
// Append CC / VIEWER slots after the canonical trio so their signing
|
||||
// order doesn't collide with 1/2/3. Documenso doesn't require
|
||||
// signingOrder uniqueness across non-signing roles but we still hand
|
||||
// out monotonic numbers (4, 5, …) for predictability.
|
||||
...(options.extraRecipients ?? []).map((extra, idx) => ({
|
||||
id: extra.id,
|
||||
name: extra.name,
|
||||
email: extra.email,
|
||||
role: extra.role,
|
||||
signingOrder: extra.signingOrder ?? 4 + idx,
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
307
src/lib/services/documenso-template-sync.service.ts
Normal file
307
src/lib/services/documenso-template-sync.service.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
downloadEnvelopeItemPdf,
|
||||
getTemplate,
|
||||
type DocumensoTemplate,
|
||||
} from '@/lib/services/documenso-client';
|
||||
import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
||||
import { writeSetting } from '@/lib/settings/resolver';
|
||||
import { getSetting as getSettingLegacy } from '@/lib/services/settings.service';
|
||||
import { inspectPdfAcroForm, type AcroFormField } from '@/lib/pdf/inspect-acroform';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { AuditMeta } from '@/lib/audit';
|
||||
|
||||
export interface TemplateFieldMap {
|
||||
[label: string]: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field labels the CRM expects to fill on every EOI. Kept in sync with the
|
||||
* keys of `formValues` inside `buildDocumensoPayload`. When a template's
|
||||
* field labels diverge from this set, the unmatched entries are surfaced in
|
||||
* the sync result so the admin can rename in the Documenso template editor.
|
||||
*/
|
||||
const CRM_EXPECTED_EOI_FIELD_LABELS = [
|
||||
'Name',
|
||||
'Email',
|
||||
'Address',
|
||||
'Yacht Name',
|
||||
'Length',
|
||||
'Width',
|
||||
'Draft',
|
||||
'Berth Number',
|
||||
'Lease_10',
|
||||
'Purchase',
|
||||
] as const;
|
||||
|
||||
export interface TemplateSyncResult {
|
||||
/** ISO timestamp of when this sync ran. Surfaced as "Last synced X ago"
|
||||
* so the admin can tell when the cached report is from. */
|
||||
syncedAt: string;
|
||||
templateId: number;
|
||||
title: string;
|
||||
/** Pre-filled into the matching documenso_*_recipient_id settings. */
|
||||
recipients: Array<{
|
||||
role: string;
|
||||
signingOrder: number;
|
||||
id: number;
|
||||
name?: string;
|
||||
email?: string;
|
||||
/** Which CRM setting key this row was written to (null = no match). */
|
||||
mappedSettingKey: string | null;
|
||||
}>;
|
||||
/** Stored at `documenso_eoi_field_map` for v2 prefillFields-by-ID. */
|
||||
fieldMap: TemplateFieldMap;
|
||||
fieldCount: number;
|
||||
/**
|
||||
* Template fields the CRM knows how to fill at send time. The admin doesn't
|
||||
* need to do anything for these — they'll flow through prefillFields on
|
||||
* the next EOI send.
|
||||
*/
|
||||
matchedFields: Array<{ label: string; fieldId: number }>;
|
||||
/**
|
||||
* Template fields discovered on Documenso side but with labels the CRM
|
||||
* doesn't recognize. These will NOT be filled. The admin should rename
|
||||
* them in the Documenso template editor to match a CRM expected label.
|
||||
*/
|
||||
unmatchedTemplateFields: Array<{ label: string; fieldId: number }>;
|
||||
/**
|
||||
* CRM-expected fields that don't exist on the template. The CRM will skip
|
||||
* them at send time. May be benign (e.g. you removed a field from the
|
||||
* template intentionally) or a typo (rename a template field to match).
|
||||
*/
|
||||
missingFromTemplate: string[];
|
||||
/**
|
||||
* The template's stored meta — what every envelope generated from this
|
||||
* template inherits at creation time. signingOrder is bound to the
|
||||
* template (v2's /template/use does NOT accept an override), so this
|
||||
* is the authoritative value the admin sees.
|
||||
*/
|
||||
templateMeta: {
|
||||
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
|
||||
distributionMethod: 'EMAIL' | 'NONE' | null;
|
||||
redirectUrl: string | null;
|
||||
};
|
||||
/**
|
||||
* v2 only. Each entry is one underlying PDF file behind the template,
|
||||
* with its AcroForm field roster and a diff against the CRM's expected
|
||||
* EOI field labels. The admin uses this to verify their fillable PDF
|
||||
* actually has the named fields the CRM will fill via `formValues`.
|
||||
*
|
||||
* Empty array on v1 or when the PDF download / parse fails — diagnostic
|
||||
* messages go to pino so the admin still sees the rest of the sync result.
|
||||
*/
|
||||
acroForm: Array<{
|
||||
envelopeItemId: string;
|
||||
fields: AcroFormField[];
|
||||
/** Names from the PDF that match a CRM-expected EOI label. */
|
||||
matchedFieldNames: string[];
|
||||
/**
|
||||
* CRM-expected labels missing from the PDF's AcroForm. These won't
|
||||
* be filled at send time on the AcroForm path. (Independent of the
|
||||
* Documenso-overlay-field diff above.)
|
||||
*/
|
||||
missingFieldNames: string[];
|
||||
/**
|
||||
* Names in the PDF AcroForm the CRM has no token for. Usually
|
||||
* harmless leftover fields from the original PDF authoring tool
|
||||
* (signature blocks, etc.).
|
||||
*/
|
||||
extraFieldNames: string[];
|
||||
/** Set when download or parse failed — string surfaces in the UI. */
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a template recipient to its CRM **recipient-id** setting key by role +
|
||||
* signing order. These are the Documenso-internal numeric IDs that bind a
|
||||
* /template/use call's recipients block to the template's slots.
|
||||
*
|
||||
* Distinct from `documenso_developer_user_id` / `documenso_approver_user_id`,
|
||||
* which are CRM user UUIDs for in-CRM notification routing (RBAC binding).
|
||||
*
|
||||
* Convention (matches buildDocumensoPayload's signing order):
|
||||
* signingOrder 1, role=SIGNER → client recipient slot
|
||||
* signingOrder 2, role=SIGNER → developer recipient slot
|
||||
* signingOrder 3, role=APPROVER → approver recipient slot
|
||||
*
|
||||
* Returns null if the role/order combination doesn't match any known slot —
|
||||
* useful future-proofing for templates that add CC / VIEWER recipients.
|
||||
*/
|
||||
function mapRecipientToSettingKey(role: string, signingOrder: number): string | null {
|
||||
const r = role.toUpperCase();
|
||||
if (r === 'SIGNER' && signingOrder === 1) return 'documenso_client_recipient_id';
|
||||
if (r === 'SIGNER' && signingOrder === 2) return 'documenso_developer_recipient_id';
|
||||
if (r === 'APPROVER' && signingOrder === 3) return 'documenso_approval_recipient_id';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the Documenso template, write the discovered recipient IDs into the
|
||||
* matching CRM settings, and cache the field name→ID map for v2 prefillFields.
|
||||
*
|
||||
* Throws when the template fetch fails (bad credentials, wrong template ID,
|
||||
* network) — caller surfaces the error to the admin via the form's mutation
|
||||
* onError + toastError.
|
||||
*/
|
||||
export async function syncDocumensoTemplate(
|
||||
templateId: number,
|
||||
portId: string,
|
||||
meta: AuditMeta,
|
||||
): Promise<TemplateSyncResult> {
|
||||
const template: DocumensoTemplate = await getTemplate(templateId, portId);
|
||||
|
||||
// Build the recipient-slot map and persist to system_settings.
|
||||
const recipientRows: TemplateSyncResult['recipients'] = [];
|
||||
for (const rec of template.recipients) {
|
||||
const settingKey = mapRecipientToSettingKey(rec.role, rec.signingOrder);
|
||||
recipientRows.push({
|
||||
role: rec.role,
|
||||
signingOrder: rec.signingOrder,
|
||||
id: rec.id,
|
||||
name: rec.name,
|
||||
email: rec.email,
|
||||
mappedSettingKey: settingKey,
|
||||
});
|
||||
if (settingKey) {
|
||||
await writeSetting(settingKey, rec.id, portId, meta);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the field-label → field-id map. Drives v2's prefillFields. The
|
||||
// field-map is itself a registry-unaware setting (the registry only knows
|
||||
// about scalar credentials/URLs, not arbitrary JSON blobs), so write
|
||||
// through the legacy upsertSetting path to avoid registry validation.
|
||||
const fieldMap: TemplateFieldMap = {};
|
||||
for (const f of template.fields) {
|
||||
if (f.label) fieldMap[f.label] = f.id;
|
||||
}
|
||||
await persistFieldMap(portId, fieldMap, meta);
|
||||
|
||||
// Persist the canonical template ID so a subsequent send doesn't depend on
|
||||
// the admin form remembering it after the sync click.
|
||||
await writeSetting('documenso_eoi_template_id', templateId, portId, meta);
|
||||
|
||||
// Diff the discovered field labels against what the CRM expects to fill.
|
||||
// Drives the matched / unmatched / missing lists the admin sees post-sync.
|
||||
const expected = new Set<string>(CRM_EXPECTED_EOI_FIELD_LABELS);
|
||||
const discovered = new Set<string>(Object.keys(fieldMap));
|
||||
const matchedFields: TemplateSyncResult['matchedFields'] = [];
|
||||
const unmatchedTemplateFields: TemplateSyncResult['unmatchedTemplateFields'] = [];
|
||||
for (const [label, fieldId] of Object.entries(fieldMap)) {
|
||||
if (expected.has(label)) matchedFields.push({ label, fieldId });
|
||||
else unmatchedTemplateFields.push({ label, fieldId });
|
||||
}
|
||||
const missingFromTemplate: string[] = CRM_EXPECTED_EOI_FIELD_LABELS.filter(
|
||||
(lbl) => !discovered.has(lbl),
|
||||
);
|
||||
|
||||
// v2 only: download each underlying PDF and inspect its AcroForm. v1
|
||||
// doesn't expose envelope items so the array stays empty there.
|
||||
const acroForm: TemplateSyncResult['acroForm'] = [];
|
||||
const cfg = await getPortDocumensoConfig(portId);
|
||||
if (cfg.apiVersion === 'v2' && template.envelopeItems.length > 0) {
|
||||
for (const item of template.envelopeItems) {
|
||||
try {
|
||||
const pdfBytes = await downloadEnvelopeItemPdf(item.id, portId);
|
||||
const fields = await inspectPdfAcroForm(pdfBytes);
|
||||
const pdfNames = new Set(fields.map((f) => f.name));
|
||||
const matchedFieldNames = CRM_EXPECTED_EOI_FIELD_LABELS.filter((lbl) => pdfNames.has(lbl));
|
||||
const missingFieldNames = CRM_EXPECTED_EOI_FIELD_LABELS.filter((lbl) => !pdfNames.has(lbl));
|
||||
const extraFieldNames = fields.map((f) => f.name).filter((n) => !expected.has(n));
|
||||
acroForm.push({
|
||||
envelopeItemId: item.id,
|
||||
fields,
|
||||
matchedFieldNames,
|
||||
missingFieldNames,
|
||||
extraFieldNames,
|
||||
});
|
||||
} catch (err) {
|
||||
// Surface the failure in the UI rather than failing the whole
|
||||
// sync — the recipient + field-map writes already succeeded
|
||||
// and an admin can still progress without the AcroForm diff.
|
||||
logger.warn(
|
||||
{ err, envelopeItemId: item.id, templateId },
|
||||
'AcroForm inspection failed during template sync',
|
||||
);
|
||||
acroForm.push({
|
||||
envelopeItemId: item.id,
|
||||
fields: [],
|
||||
matchedFieldNames: [],
|
||||
missingFieldNames: [...CRM_EXPECTED_EOI_FIELD_LABELS],
|
||||
extraFieldNames: [],
|
||||
error: err instanceof Error ? err.message : 'PDF inspection failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: TemplateSyncResult = {
|
||||
syncedAt: new Date().toISOString(),
|
||||
templateId: template.id || templateId,
|
||||
title: template.title,
|
||||
recipients: recipientRows,
|
||||
fieldMap,
|
||||
fieldCount: Object.keys(fieldMap).length,
|
||||
matchedFields,
|
||||
unmatchedTemplateFields,
|
||||
missingFromTemplate,
|
||||
templateMeta: template.meta,
|
||||
acroForm,
|
||||
};
|
||||
|
||||
// Cache the full report so the admin's status panel survives page reloads.
|
||||
// Free-form JSON, written via the legacy upsertSetting path (the registry
|
||||
// resolver only handles scalar credentials/URLs). Last sync wins.
|
||||
const { upsertSetting } = await import('@/lib/services/settings.service');
|
||||
await upsertSetting('documenso_eoi_template_sync_report', result, portId, meta);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the cached sync report written on the most recent successful sync.
|
||||
* Drives the post-reload status panel — returns null when no sync has
|
||||
* ever run for this port.
|
||||
*/
|
||||
export async function getEoiTemplateSyncReport(portId: string): Promise<TemplateSyncResult | null> {
|
||||
const row = await getSettingLegacy('documenso_eoi_template_sync_report', portId);
|
||||
const stored = row?.value as unknown;
|
||||
if (!stored || typeof stored !== 'object') return null;
|
||||
return stored as TemplateSyncResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the cached field-name → field-id map for this port. Used by
|
||||
* `buildDocumensoPayload` when emitting v2's `prefillFields` array.
|
||||
* Returns null when no sync has run yet — caller falls back to v1's
|
||||
* `formValues`-by-name shape.
|
||||
*/
|
||||
export async function getEoiFieldMap(portId: string): Promise<TemplateFieldMap | null> {
|
||||
// The field map is a free-form JSON blob that doesn't fit the registry's
|
||||
// scalar-credential model — read directly via the legacy settings.service
|
||||
// path so the registry-aware resolver doesn't reject the unknown key.
|
||||
const row = await getSettingLegacy('documenso_eoi_field_map', portId);
|
||||
const stored = row?.value;
|
||||
if (!stored || typeof stored !== 'object') return null;
|
||||
const out: TemplateFieldMap = {};
|
||||
for (const [k, v] of Object.entries(stored as Record<string, unknown>)) {
|
||||
if (typeof v === 'number') out[k] = v;
|
||||
else if (typeof v === 'string' && /^\d+$/.test(v)) out[k] = Number(v);
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the field map via the legacy settings.service path so we don't have
|
||||
* to add an arbitrary-JSON entry to the registry. The map is per-port and
|
||||
* gets blown away + rewritten on every sync click.
|
||||
*/
|
||||
async function persistFieldMap(
|
||||
portId: string,
|
||||
fieldMap: TemplateFieldMap,
|
||||
meta: AuditMeta,
|
||||
): Promise<void> {
|
||||
const { upsertSetting } = await import('@/lib/services/settings.service');
|
||||
await upsertSetting('documenso_eoi_field_map', fieldMap, portId, meta);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import pLimit from 'p-limit';
|
||||
import { sendEmail } from '@/lib/email';
|
||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||
import {
|
||||
signingCancelledEmail,
|
||||
signingCompletedEmail,
|
||||
signingInvitationEmail,
|
||||
signingReminderEmail,
|
||||
@@ -71,6 +72,19 @@ export interface SigningReminderArgs extends Omit<SigningInvitationArgs, 'signer
|
||||
invitedAgo: string;
|
||||
}
|
||||
|
||||
export interface SigningCancelledArgs {
|
||||
portId: string;
|
||||
portName: string;
|
||||
/** Recipients to notify of the cancellation. Caller decides who —
|
||||
* the rep typically picks a subset of the original signers via the
|
||||
* cancel-with-notify modal. Empty list = no emails fire (the
|
||||
* Regenerate flow path). */
|
||||
recipients: Array<{ name: string; email: string }>;
|
||||
documentLabel: DocumentLabel;
|
||||
/** Optional rep-authored explanation rendered as a callout. */
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface SigningCompletedArgs {
|
||||
portId: string;
|
||||
portName: string;
|
||||
@@ -277,3 +291,41 @@ export async function sendSigningCompleted(args: SigningCompletedArgs): Promise<
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify a subset of signers that an EOI / contract has been cancelled.
|
||||
* Called by the cancel-with-notify modal — empty `recipients` is a
|
||||
* no-op (the Regenerate path, where the rep wants to silently void).
|
||||
*/
|
||||
export async function sendSigningCancelled(args: SigningCancelledArgs): Promise<void> {
|
||||
if (args.recipients.length === 0) return;
|
||||
const branding = await getBrandingShell(args.portId);
|
||||
const sendLimit = pLimit(3);
|
||||
await Promise.all(
|
||||
args.recipients.map((recipient) =>
|
||||
sendLimit(async () => {
|
||||
const { subject, html, text } = await signingCancelledEmail(
|
||||
{
|
||||
recipientName: recipient.name,
|
||||
documentLabel: args.documentLabel,
|
||||
portName: args.portName,
|
||||
reason: args.reason ?? null,
|
||||
},
|
||||
{ branding },
|
||||
);
|
||||
try {
|
||||
await sendEmail(recipient.email, subject, html, undefined, text, args.portId);
|
||||
logger.info(
|
||||
{ portId: args.portId, recipient: recipient.email, documentLabel: args.documentLabel },
|
||||
'Signing-cancelled email sent',
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, portId: args.portId, recipient: recipient.email },
|
||||
'Signing-cancelled email send failed',
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { formatDate } from '@/lib/utils/format-date';
|
||||
import { documentTemplates, documents, files } from '@/lib/db/schema/documents';
|
||||
import { documentTemplates, documents, documentSigners, files } from '@/lib/db/schema/documents';
|
||||
import type { File as DbFile, Document as DbDocument } from '@/lib/db/schema/documents';
|
||||
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
@@ -483,13 +483,16 @@ async function generateEoiFromSourcePdf(
|
||||
portId: string,
|
||||
context: GenerateInput,
|
||||
meta: AuditMeta,
|
||||
options?: { dimensionUnit?: 'ft' | 'm' },
|
||||
): Promise<{ document: DbDocument; file: DbFile }> {
|
||||
if (!context.interestId) {
|
||||
throw new ValidationError('interestId is required for EOI template generation');
|
||||
}
|
||||
|
||||
const eoiContext = await buildEoiContext(context.interestId, portId);
|
||||
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext);
|
||||
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, {
|
||||
dimensionUnit: options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft',
|
||||
});
|
||||
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||
|
||||
@@ -569,9 +572,9 @@ async function generateEoiFromSourcePdf(
|
||||
/**
|
||||
* BR-142: EOI / NDA signing. Dual pathway:
|
||||
* - `inapp`: produce the PDF locally (EOI templates fill the same source
|
||||
* PDF as Documenso via pdf-lib; other template types fall back to the
|
||||
* HTML→pdfme path), upload to MinIO, then upload to Documenso and send
|
||||
* for signing.
|
||||
* PDF as Documenso via pdf-lib AcroForm; other template types fall
|
||||
* back to the @react-pdf/renderer path), upload to MinIO, then upload
|
||||
* to Documenso and send for signing.
|
||||
* - `documenso-template`: skip our PDF generation entirely; call Documenso's
|
||||
* template-generate endpoint with the shared EOI context. Documenso owns
|
||||
* the PDF. We still record a `documents` row for tracking.
|
||||
@@ -583,14 +586,15 @@ export async function generateAndSign(
|
||||
signers: GenerateAndSignInput['signers'],
|
||||
pathway: 'inapp' | 'documenso-template',
|
||||
meta: AuditMeta,
|
||||
options?: { dimensionUnit?: 'ft' | 'm' },
|
||||
) {
|
||||
if (pathway === 'documenso-template') {
|
||||
return generateAndSignViaDocumensoTemplate(portId, context, meta);
|
||||
return generateAndSignViaDocumensoTemplate(portId, context, meta, options);
|
||||
}
|
||||
if (!templateId) {
|
||||
throw new ValidationError('templateId is required for inapp pathway');
|
||||
}
|
||||
return generateAndSignViaInApp(templateId, portId, context, signers, meta);
|
||||
return generateAndSignViaInApp(templateId, portId, context, signers, meta, options);
|
||||
}
|
||||
|
||||
async function generateAndSignViaInApp(
|
||||
@@ -599,6 +603,7 @@ async function generateAndSignViaInApp(
|
||||
context: GenerateInput,
|
||||
signers: GenerateAndSignInput['signers'],
|
||||
meta: AuditMeta,
|
||||
options?: { dimensionUnit?: 'ft' | 'm' },
|
||||
) {
|
||||
const template = await getTemplateById(templateId, portId);
|
||||
|
||||
@@ -656,6 +661,7 @@ async function generateAndSignViaInApp(
|
||||
portId,
|
||||
context,
|
||||
meta,
|
||||
options,
|
||||
);
|
||||
|
||||
// Fetch PDF bytes from the active storage backend to send to Documenso.
|
||||
@@ -688,6 +694,7 @@ async function generateAndSignViaInApp(
|
||||
.update(documents)
|
||||
.set({
|
||||
documensoId: documensoDoc.id,
|
||||
documensoNumericId: documensoDoc.numericId,
|
||||
status: 'sent',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -721,6 +728,7 @@ async function generateAndSignViaDocumensoTemplate(
|
||||
portId: string,
|
||||
context: GenerateInput,
|
||||
meta: AuditMeta,
|
||||
options?: { dimensionUnit?: 'ft' | 'm' },
|
||||
) {
|
||||
if (!context.interestId) {
|
||||
throw new ValidationError('interestId is required for documenso-template pathway');
|
||||
@@ -734,21 +742,39 @@ async function generateAndSignViaDocumensoTemplate(
|
||||
// platform to one Documenso instance per CRM process.
|
||||
const docCfg = await getPortDocumensoConfig(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,
|
||||
// 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 } : {}),
|
||||
});
|
||||
// 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.
|
||||
// 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,
|
||||
{
|
||||
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 documensoDoc = await documensoGenerateFromTemplate(
|
||||
docCfg.eoiTemplateId,
|
||||
@@ -768,11 +794,73 @@ async function generateAndSignViaDocumensoTemplate(
|
||||
title: payload.title,
|
||||
status: 'sent',
|
||||
documensoId: documensoDoc.id,
|
||||
documensoNumericId: documensoDoc.numericId,
|
||||
isManualUpload: false,
|
||||
createdBy: meta.userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Persist the per-recipient signer rows from Documenso's create response.
|
||||
// Without these the EOI tab's "Signing progress" panel shows
|
||||
// "No signers loaded" forever (the webhook handler only updates existing
|
||||
// rows by token / email). Each row maps a Documenso recipient slot to
|
||||
// a CRM document-signer record.
|
||||
if (documensoDoc.recipients.length > 0) {
|
||||
await db.insert(documentSigners).values(
|
||||
documensoDoc.recipients.map((r) => {
|
||||
// Strip the `(was: <email>)` suffix that `applyRecipientRedirect`
|
||||
// bakes into recipient names when EMAIL_REDIRECT_TO is on. Without
|
||||
// this, every downstream surface (email greeting, signing-progress
|
||||
// card, document-detail page) leaks "Matt Ciaccio (was: matt@...)"
|
||||
// into reps' faces. Display-only cleanup; the original email is
|
||||
// still recoverable via the redirect helper.
|
||||
const cleanName = (r.name || r.email)
|
||||
.replace(/\s*\(was:[^)]*\)/i, '')
|
||||
.replace(/\s*\(placeholder\)/i, '')
|
||||
.replace(/\s*\(placeholder\b[^)]*\)/i, '')
|
||||
.trim();
|
||||
// signingOrder 1 with role SIGNER is always the CLIENT in our trio
|
||||
// (Client → Developer → Approver). Without this special-case the
|
||||
// role gets stored as 'signer' for the client too, and the email
|
||||
// template's `isClient` branch wrongly tells the client "you're
|
||||
// the next signer; the client has already signed."
|
||||
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 deliberately left null at create time. The
|
||||
// send-invitation route stamps it once the branded invite goes
|
||||
// out. Pre-stamping would mis-label the signer card as
|
||||
// "Invited just now" in manual send mode.
|
||||
invitedAt: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Stamp the interest's EOI milestone so the Overview tab flips the
|
||||
// "Generate EOI" prompt to the "EOI sent / awaiting signatures" state
|
||||
// immediately. Cache-invalidation on the client picks the new shape up
|
||||
// via the document-templates POST's onSuccess.
|
||||
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,3 +882,20 @@ async function generateAndSignViaDocumensoTemplate(
|
||||
|
||||
return { document: documentRecord!, file: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Documenso recipient roles arrive as ALL-CAPS strings ('SIGNER' | 'APPROVER'
|
||||
* | 'CC' | 'VIEWER'); the CRM's `document_signers.signer_role` column uses
|
||||
* the lowercase domain vocabulary ('client' | 'developer' | 'approver' |
|
||||
* 'cc' | 'viewer' | 'other'). Map them so the UI's progress panel renders
|
||||
* the right label per row. SIGNER → developer is a safe default because
|
||||
* the client slot is identified positionally elsewhere (signingOrder=1
|
||||
* always).
|
||||
*/
|
||||
function normalizeSignerRole(documensoRole: string): string {
|
||||
const r = documensoRole.toUpperCase();
|
||||
if (r === 'APPROVER') return 'approver';
|
||||
if (r === 'CC') return 'cc';
|
||||
if (r === 'VIEWER') return 'viewer';
|
||||
return 'signer';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, gte, inArray, isNull, lt, lte, ne, sql, exists } from 'drizzle-orm';
|
||||
import { and, desc, eq, gte, inArray, isNull, lt, lte, ne, or, sql, exists } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
@@ -26,7 +26,7 @@ import { env } from '@/lib/env';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { evaluateRule } from '@/lib/services/berth-rules-engine';
|
||||
import { PIPELINE_STAGES } from '@/lib/constants';
|
||||
import { advanceStageIfBehind } from '@/lib/services/interests.service';
|
||||
import { advanceStageIfBehind, advanceStageIfBehindGated } from '@/lib/services/interests.service';
|
||||
import {
|
||||
createDocument as documensoCreate,
|
||||
sendDocument as documensoSend,
|
||||
@@ -184,7 +184,14 @@ export async function listDocuments(
|
||||
if (interestId) filters.push(eq(documents.interestId, interestId));
|
||||
if (clientId) filters.push(eq(documents.clientId, clientId));
|
||||
if (documentType) filters.push(eq(documents.documentType, documentType));
|
||||
if (status) filters.push(eq(documents.status, status));
|
||||
if (status) {
|
||||
filters.push(eq(documents.status, status));
|
||||
} else {
|
||||
// Hide soft-deleted rows by default from the standard list endpoint.
|
||||
// Callers that explicitly want the deleted bucket pass `status='deleted'`
|
||||
// (e.g. the "Deleted" filter chip on the EOI history list).
|
||||
filters.push(ne(documents.status, 'deleted'));
|
||||
}
|
||||
if (query.folderId !== undefined) {
|
||||
if (query.folderId === null) {
|
||||
filters.push(isNull(documents.folderId));
|
||||
@@ -603,14 +610,58 @@ export async function updateDocument(
|
||||
|
||||
// ─── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Soft-deletes a document by setting `status='deleted'`. The row stays so
|
||||
* audit-log/event history references it; the document is hidden from
|
||||
* primary lists via the `status != 'deleted'` filter at the read layer.
|
||||
*
|
||||
* If the document is wired to a Documenso envelope, the call also voids
|
||||
* the envelope upstream (Documenso DELETE = void, not hard-erase; the
|
||||
* envelope moves to VOIDED status in Documenso's UI so it stops
|
||||
* accepting signatures and outstanding signing URLs invalidate).
|
||||
*
|
||||
* Refuses to delete a document in the middle of signing (`sent` /
|
||||
* `partially_signed`) — reps must cancel first, then delete the
|
||||
* cancelled record.
|
||||
*/
|
||||
export async function deleteDocument(id: string, portId: string, meta: AuditMeta) {
|
||||
const existing = await getDocumentById(id, portId);
|
||||
|
||||
if (['sent', 'partially_signed'].includes(existing.status)) {
|
||||
throw new ConflictError('Cannot delete a document that is currently in signing process');
|
||||
throw new ConflictError(
|
||||
'Cannot delete a document while signing is in progress — cancel it first, then delete the cancelled record.',
|
||||
);
|
||||
}
|
||||
if (existing.status === 'deleted') {
|
||||
// Idempotent: a second DELETE is a no-op rather than a 409.
|
||||
return;
|
||||
}
|
||||
|
||||
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.portId, portId)));
|
||||
// Best-effort upstream void. A transient Documenso failure shouldn't
|
||||
// block the CRM-side delete — the document_events row + audit log
|
||||
// capture what happened, and `voidDocument` treats 404 (already gone)
|
||||
// as success so a Documenso UI re-delete remains safe.
|
||||
if (existing.documensoId) {
|
||||
try {
|
||||
await documensoVoid(existing.documensoId, portId);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, documentId: id, documensoId: existing.documensoId },
|
||||
'Documenso void failed during delete; soft-deleting CRM-side anyway',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'deleted', updatedAt: new Date() })
|
||||
.where(and(eq(documents.id, id), eq(documents.portId, portId)));
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
documentId: id,
|
||||
eventType: 'deleted',
|
||||
eventData: { initiatedBy: meta.userId },
|
||||
});
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
@@ -619,6 +670,7 @@ export async function deleteDocument(id: string, portId: string, meta: AuditMeta
|
||||
entityType: 'document',
|
||||
entityId: id,
|
||||
oldValue: { title: existing.title, status: existing.status },
|
||||
newValue: { status: 'deleted' },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
@@ -788,7 +840,14 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
||||
// Advance pipeline stage to eoi (no-op if already further along).
|
||||
// Doc sub-status is set by the webhook receiver when Documenso confirms;
|
||||
// we stamp eoiDocStatus optimistically here so the UI shows "sent".
|
||||
void advanceStageIfBehind(interest.id, portId, 'eoi', meta, 'EOI sent for signing');
|
||||
void advanceStageIfBehindGated(
|
||||
interest.id,
|
||||
portId,
|
||||
'eoi',
|
||||
meta,
|
||||
'EOI sent for signing',
|
||||
'eoi_sent',
|
||||
);
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ eoiDocStatus: 'sent', updatedAt: new Date() })
|
||||
@@ -998,9 +1057,19 @@ async function resolveWebhookDocument(
|
||||
documensoId: string,
|
||||
portId: string | undefined,
|
||||
): Promise<typeof documents.$inferSelect | null> {
|
||||
// Documenso v1 sends `payload.id` = the same numeric id we stored in
|
||||
// `documents.documenso_id`. Documenso v2 sends `payload.id` = the
|
||||
// internal numeric pk, while `documents.documenso_id` holds the public
|
||||
// `envelope_xxx` string and the numeric pk lives in
|
||||
// `documents.documenso_numeric_id`. Match either column so both
|
||||
// versions resolve.
|
||||
const idMatch = or(
|
||||
eq(documents.documensoId, documensoId),
|
||||
eq(documents.documensoNumericId, documensoId),
|
||||
);
|
||||
if (portId) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: and(eq(documents.documensoId, documensoId), eq(documents.portId, portId)),
|
||||
where: and(idMatch, eq(documents.portId, portId)),
|
||||
});
|
||||
if (!doc) {
|
||||
logger.warn({ documensoId, portId }, 'Document not found for webhook (port-scoped)');
|
||||
@@ -1009,7 +1078,7 @@ async function resolveWebhookDocument(
|
||||
return doc;
|
||||
}
|
||||
const matches = await db.query.documents.findMany({
|
||||
where: eq(documents.documensoId, documensoId),
|
||||
where: idMatch,
|
||||
});
|
||||
if (matches.length === 0) {
|
||||
logger.warn({ documensoId }, 'Document not found for webhook');
|
||||
@@ -1052,9 +1121,24 @@ export async function handleRecipientSigned(eventData: {
|
||||
eq(documentSigners.signerEmail, eventData.recipientEmail),
|
||||
);
|
||||
|
||||
// Read prior status so we know whether this delivery is the first
|
||||
// signing transition. Documenso v2 retries deliver the same
|
||||
// DOCUMENT_RECIPIENT_COMPLETED multiple times with slightly different
|
||||
// rawBody hashes — without this gate the cascade fires on every
|
||||
// delivery, the "your turn" email goes out twice, and downstream side
|
||||
// effects (rule engine, audit, notifications) duplicate.
|
||||
const [priorSigner] = await db.select().from(documentSigners).where(signerWhere);
|
||||
const wasAlreadySigned = priorSigner?.status === 'signed';
|
||||
|
||||
const [signer] = await db
|
||||
.update(documentSigners)
|
||||
.set({ status: 'signed', signedAt: new Date() })
|
||||
.set({
|
||||
status: 'signed',
|
||||
// Preserve the original signedAt timestamp on duplicate webhook
|
||||
// deliveries — overwriting it makes every signer card show the
|
||||
// most-recent webhook timestamp instead of the actual sign time.
|
||||
...(wasAlreadySigned ? {} : { signedAt: new Date() }),
|
||||
})
|
||||
.where(signerWhere)
|
||||
.returning();
|
||||
|
||||
@@ -1083,13 +1167,21 @@ export async function handleRecipientSigned(eventData: {
|
||||
.where(and(eq(documents.id, doc.id), eq(documents.portId, doc.portId)));
|
||||
}
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
documentId: doc.id,
|
||||
eventType: 'signed',
|
||||
signerId: signer?.id ?? null,
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { recipientEmail: eventData.recipientEmail },
|
||||
});
|
||||
// Idempotent insert: Documenso v2 retries the same logical event with
|
||||
// varying rawBody hashes, so the (documentId, hash:signed:email) unique
|
||||
// index would otherwise throw on duplicate deliveries and short-circuit
|
||||
// the cascade below. `onConflictDoNothing` treats the duplicate as the
|
||||
// no-op it is.
|
||||
await db
|
||||
.insert(documentEvents)
|
||||
.values({
|
||||
documentId: doc.id,
|
||||
eventType: 'signed',
|
||||
signerId: signer?.id ?? null,
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { recipientEmail: eventData.recipientEmail },
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
emitToRoom(`port:${doc.portId}`, 'document:signer:signed', {
|
||||
documentId: doc.id,
|
||||
@@ -1098,9 +1190,10 @@ export async function handleRecipientSigned(eventData: {
|
||||
|
||||
// Phase 2 cascade: now that this signer is done, fire the branded
|
||||
// "your turn" invitation to the next pending signer in signing order.
|
||||
// The webhook may fire multiple times per document (one per recipient
|
||||
// sign event); the `invitedAt` guard prevents duplicate invites.
|
||||
if (signer) {
|
||||
// Skip the cascade entirely on duplicate deliveries — only fire on
|
||||
// the first pending→signed transition. The `invitedAt` guard inside
|
||||
// sendCascadingInviteForNextSigner is a second safety net.
|
||||
if (signer && !wasAlreadySigned) {
|
||||
await sendCascadingInviteForNextSigner(doc).catch((err) => {
|
||||
// Cascading-invite failure is non-fatal — the webhook itself
|
||||
// succeeded. The rep can manually click "Send invitation" if the
|
||||
@@ -1289,7 +1382,13 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
let putStoragePath: string | null = null;
|
||||
|
||||
try {
|
||||
const signedPdfBuffer = await downloadSignedPdf(eventData.documentId);
|
||||
// Download by the stored Documenso ID (envelope_xxx on v2, numeric on
|
||||
// v1) rather than `eventData.documentId` — webhooks deliver the v2
|
||||
// numeric internal pk, but the download endpoint expects the public
|
||||
// envelope_xxx string. Falls back to the webhook's value when the
|
||||
// stored ID is somehow missing (e.g. legacy pre-#69 rows).
|
||||
const downloadId = doc.documensoId ?? eventData.documentId;
|
||||
const signedPdfBuffer = await downloadSignedPdf(downloadId, doc.portId);
|
||||
|
||||
// Guard: a 0-byte response from Documenso would otherwise persist a
|
||||
// permanent corrupt signedFileId pointing at a blob with no content.
|
||||
@@ -1503,13 +1602,18 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
void evaluateRule('eoi_signed', doc.interestId, doc.portId, systemMeta);
|
||||
}
|
||||
|
||||
// Stage stays at 'eoi' — sub-status flips to signed.
|
||||
void advanceStageIfBehind(
|
||||
// EOI signed = formal commitment to proceed → advance to 'reservation'
|
||||
// (the next milestone). Conventional CRM behaviour: stage reflects the
|
||||
// deal's CURRENT pursuit phase, not the most recently signed document.
|
||||
// Per-port admins can override the rule via the `eoi_signed` entry in
|
||||
// the stage_advance_rules setting (auto / suggest / off).
|
||||
void advanceStageIfBehindGated(
|
||||
doc.interestId,
|
||||
doc.portId,
|
||||
'eoi',
|
||||
'reservation',
|
||||
systemMeta,
|
||||
'EOI signed via Documenso',
|
||||
'eoi_signed',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1533,12 +1637,13 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(interests.id, doc.interestId));
|
||||
void advanceStageIfBehind(
|
||||
void advanceStageIfBehindGated(
|
||||
doc.interestId,
|
||||
doc.portId,
|
||||
'reservation',
|
||||
systemMeta,
|
||||
'Reservation agreement signed',
|
||||
'reservation_signed',
|
||||
);
|
||||
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
|
||||
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
|
||||
@@ -1563,12 +1668,13 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(interests.id, doc.interestId));
|
||||
void advanceStageIfBehind(
|
||||
void advanceStageIfBehindGated(
|
||||
doc.interestId,
|
||||
doc.portId,
|
||||
'contract',
|
||||
systemMeta,
|
||||
'Contract signed via Documenso',
|
||||
'contract_signed',
|
||||
);
|
||||
void import('@/lib/services/berth-rules-engine').then(({ evaluateRule }) =>
|
||||
evaluateRule('contract_signed', doc.interestId!, doc.portId, systemMeta),
|
||||
@@ -1717,13 +1823,16 @@ export async function handleDocumentOpened(eventData: {
|
||||
.where(eq(documentSigners.id, signer.id));
|
||||
}
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
documentId: doc.id,
|
||||
eventType: 'viewed',
|
||||
signerId: signer?.id ?? null,
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { recipientEmail: eventData.recipientEmail },
|
||||
});
|
||||
await db
|
||||
.insert(documentEvents)
|
||||
.values({
|
||||
documentId: doc.id,
|
||||
eventType: 'viewed',
|
||||
signerId: signer?.id ?? null,
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { recipientEmail: eventData.recipientEmail },
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
emitToRoom(`port:${doc.portId}`, 'document:signer:opened', {
|
||||
documentId: doc.id,
|
||||
@@ -1767,13 +1876,16 @@ export async function handleDocumentRejected(eventData: {
|
||||
.where(and(eq(interests.id, doc.interestId), eq(interests.portId, doc.portId)));
|
||||
}
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
documentId: doc.id,
|
||||
eventType: 'rejected',
|
||||
signerId,
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { recipientEmail: eventData.recipientEmail ?? null },
|
||||
});
|
||||
await db
|
||||
.insert(documentEvents)
|
||||
.values({
|
||||
documentId: doc.id,
|
||||
eventType: 'rejected',
|
||||
signerId,
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { recipientEmail: eventData.recipientEmail ?? null },
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
emitToRoom(`port:${doc.portId}`, 'document:rejected', {
|
||||
documentId: doc.id,
|
||||
@@ -1801,12 +1913,15 @@ export async function handleDocumentCancelled(eventData: {
|
||||
.where(and(eq(interests.id, doc.interestId), eq(interests.portId, doc.portId)));
|
||||
}
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
documentId: doc.id,
|
||||
eventType: 'cancelled',
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { documensoId: eventData.documentId },
|
||||
});
|
||||
await db
|
||||
.insert(documentEvents)
|
||||
.values({
|
||||
documentId: doc.id,
|
||||
eventType: 'cancelled',
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { documensoId: eventData.documentId },
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
emitToRoom(`port:${doc.portId}`, 'document:cancelled', { documentId: doc.id });
|
||||
}
|
||||
@@ -1865,10 +1980,20 @@ export async function getDocumentDetail(id: string, portId: string): Promise<Doc
|
||||
* The actual Documenso void call lands in PR2 (`documenso-client.voidDocument`);
|
||||
* this skeleton updates DB state only.
|
||||
*/
|
||||
export interface CancelDocumentOptions {
|
||||
/** Rep-authored reason inlined into notification emails + audit log. */
|
||||
reason?: string | null;
|
||||
/** Document_signers ids the rep wants to email about the cancellation.
|
||||
* Empty list = silent void (Regenerate flow). Each id is validated to
|
||||
* belong to this document before any email fires. */
|
||||
notifyRecipients?: string[];
|
||||
}
|
||||
|
||||
export async function cancelDocument(
|
||||
documentId: string,
|
||||
portId: string,
|
||||
meta: AuditMeta,
|
||||
options: CancelDocumentOptions = {},
|
||||
): Promise<typeof documents.$inferSelect> {
|
||||
const existing = await getDocumentById(documentId, portId);
|
||||
|
||||
@@ -1900,7 +2025,11 @@ export async function cancelDocument(
|
||||
await db.insert(documentEvents).values({
|
||||
documentId,
|
||||
eventType: 'cancelled',
|
||||
eventData: { initiatedBy: meta.userId },
|
||||
eventData: {
|
||||
initiatedBy: meta.userId,
|
||||
reason: options.reason ?? null,
|
||||
notifyCount: options.notifyRecipients?.length ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
void createAuditLog({
|
||||
@@ -1910,13 +2039,58 @@ export async function cancelDocument(
|
||||
entityType: 'document',
|
||||
entityId: documentId,
|
||||
oldValue: { status: existing.status },
|
||||
newValue: { status: 'cancelled' },
|
||||
newValue: { status: 'cancelled', reason: options.reason ?? null },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'document:cancelled', { documentId });
|
||||
|
||||
// Notify selected signers (rep-picked subset via the cancel-with-notify
|
||||
// modal). Pull the matching signer rows so we can render the recipient's
|
||||
// canonical name; skip silently when the rep passed no ids (Regenerate
|
||||
// flow). Failure to send is logged + non-fatal — the cancellation has
|
||||
// already committed locally.
|
||||
const notifyIds = options.notifyRecipients ?? [];
|
||||
if (notifyIds.length > 0) {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: documentSigners.id,
|
||||
signerName: documentSigners.signerName,
|
||||
signerEmail: documentSigners.signerEmail,
|
||||
})
|
||||
.from(documentSigners)
|
||||
.where(
|
||||
and(eq(documentSigners.documentId, documentId), inArray(documentSigners.id, notifyIds)),
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
const portRow = await db.query.ports.findFirst({
|
||||
where: eq(ports.id, portId),
|
||||
columns: { name: true },
|
||||
});
|
||||
const { sendSigningCancelled } =
|
||||
await import('@/lib/services/document-signing-emails.service');
|
||||
await sendSigningCancelled({
|
||||
portId,
|
||||
portName: portRow?.name ?? 'Port Nimara',
|
||||
recipients: rows.map((r) => ({ name: r.signerName, email: r.signerEmail })),
|
||||
documentLabel:
|
||||
(DOC_TYPE_LABEL[existing.documentType] as
|
||||
| 'Expression of Interest'
|
||||
| 'Sales Contract'
|
||||
| 'Reservation Agreement') ?? 'Expression of Interest',
|
||||
reason: options.reason ?? null,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, documentId, notifyIds },
|
||||
'cancel-with-notify email fan-out failed; cancellation already committed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ export async function toggleAccount(
|
||||
accountId: string,
|
||||
userId: string,
|
||||
data: ToggleAccountInput,
|
||||
audit?: AuditMeta,
|
||||
): Promise<AccountWithoutCredentials> {
|
||||
const existing = await db.query.emailAccounts.findFirst({
|
||||
where: eq(emailAccounts.id, accountId),
|
||||
@@ -112,6 +113,25 @@ export async function toggleAccount(
|
||||
internalMessage: 'Failed to update email account',
|
||||
});
|
||||
|
||||
// H-05: enable/disable used to land silently between connect/disconnect.
|
||||
// Audit-trail this so an admin can see the toggle history (silently
|
||||
// disabling an account suppresses bounce detection or reroutes replies —
|
||||
// compliance-relevant change).
|
||||
if (audit) {
|
||||
void createAuditLog({
|
||||
userId: audit.userId,
|
||||
portId: audit.portId,
|
||||
action: 'update',
|
||||
entityType: 'email_account',
|
||||
entityId: accountId,
|
||||
oldValue: { isActive: existing.isActive },
|
||||
newValue: { isActive: updated.isActive },
|
||||
metadata: { emailAddress: existing.emailAddress },
|
||||
ipAddress: audit.ipAddress,
|
||||
userAgent: audit.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
return stripCredentials(updated);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,19 @@ export type EoiContext = {
|
||||
nationality: string | null;
|
||||
primaryEmail: string | null;
|
||||
primaryPhone: string | null;
|
||||
address: { street: string; city: string; country: string } | null;
|
||||
address: {
|
||||
street: string;
|
||||
city: string;
|
||||
/** ISO-3166-2 subdivision code stripped of its country prefix
|
||||
* (e.g. 'NY' from 'US-NY'). Empty string when not set. */
|
||||
subdivision: string;
|
||||
postalCode: string;
|
||||
/** Localised country name — for the deprecated UI preview line only. */
|
||||
country: string;
|
||||
/** ISO-3166-1 alpha-2 country code — what the EOI Address field renders
|
||||
* (e.g. 'US'), not the long name. */
|
||||
countryIso: string;
|
||||
} | null;
|
||||
};
|
||||
/** Optional. The EOI's Section 3 yacht block is left blank when null. */
|
||||
yacht: {
|
||||
@@ -33,6 +45,11 @@ export type EoiContext = {
|
||||
lengthM: string | null;
|
||||
widthM: string | null;
|
||||
draftM: string | null;
|
||||
/** Which unit the rep originally typed in. Drives the EOI drawer's
|
||||
* default unit toggle so a metric-entered yacht doesn't render as ft. */
|
||||
lengthUnit: 'ft' | 'm';
|
||||
widthUnit: 'ft' | 'm';
|
||||
draftUnit: 'ft' | 'm';
|
||||
hullNumber: string | null;
|
||||
flag: string | null;
|
||||
yearBuilt: number | null;
|
||||
@@ -181,17 +198,28 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
.select({
|
||||
streetAddress: clientAddresses.streetAddress,
|
||||
city: clientAddresses.city,
|
||||
subdivisionIso: clientAddresses.subdivisionIso,
|
||||
postalCode: clientAddresses.postalCode,
|
||||
countryIso: clientAddresses.countryIso,
|
||||
})
|
||||
.from(clientAddresses)
|
||||
.where(and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)))
|
||||
.limit(1);
|
||||
|
||||
// The EOI's Address field renders the subdivision as the ISO-3166-2 SUFFIX
|
||||
// (e.g. 'NY' from 'US-NY') and the country as the alpha-2 code ('US') so
|
||||
// the line is short enough to fit. The localised country name is kept
|
||||
// alongside for legacy callers and the in-app preview.
|
||||
const clientAddress = primaryAddress
|
||||
? {
|
||||
street: primaryAddress.streetAddress ?? '',
|
||||
city: primaryAddress.city ?? '',
|
||||
subdivision: primaryAddress.subdivisionIso
|
||||
? (primaryAddress.subdivisionIso.split('-').pop() ?? '')
|
||||
: '',
|
||||
postalCode: primaryAddress.postalCode ?? '',
|
||||
country: primaryAddress.countryIso ? getCountryName(primaryAddress.countryIso, 'en') : '',
|
||||
countryIso: primaryAddress.countryIso ?? '',
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -294,6 +322,9 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
lengthM: yacht.lengthM,
|
||||
widthM: yacht.widthM,
|
||||
draftM: yacht.draftM,
|
||||
lengthUnit: (yacht.lengthUnit ?? 'ft') as 'ft' | 'm',
|
||||
widthUnit: (yacht.widthUnit ?? 'ft') as 'ft' | 'm',
|
||||
draftUnit: (yacht.draftUnit ?? 'ft') as 'ft' | 'm',
|
||||
hullNumber: yacht.hullNumber,
|
||||
flag: yacht.flag,
|
||||
yearBuilt: yacht.yearBuilt,
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function getPrimaryBerth(interestId: string): Promise<PrimaryBerthR
|
||||
mooringNumber: berths.mooringNumber,
|
||||
})
|
||||
.from(interestBerths)
|
||||
.innerJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||
.where(eq(interestBerths.interestId, interestId))
|
||||
.orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt));
|
||||
const first = rows[0];
|
||||
@@ -84,7 +84,7 @@ export async function getPrimaryBerthsForInterests(
|
||||
mooringNumber: berths.mooringNumber,
|
||||
})
|
||||
.from(interestBerths)
|
||||
.innerJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||
.where(inArray(interestBerths.interestId, interestIds))
|
||||
.orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt));
|
||||
|
||||
@@ -101,11 +101,13 @@ export async function getPrimaryBerthsForInterests(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Berth metadata surfaced alongside each junction row by {@link listBerthsForInterest}. */
|
||||
/** Berth metadata surfaced alongside each junction row by {@link listBerthsForInterest}.
|
||||
* All berth-derived fields are nullable so an orphaned junction row (berth
|
||||
* hard-deleted out from under the link) still renders rather than vanishing. */
|
||||
export interface InterestBerthWithDetails extends InterestBerth {
|
||||
mooringNumber: string | null;
|
||||
area: string | null;
|
||||
status: string;
|
||||
status: string | null;
|
||||
lengthFt: string | null;
|
||||
widthFt: string | null;
|
||||
draftFt: string | null;
|
||||
@@ -137,7 +139,7 @@ export async function listBerthsForInterest(
|
||||
draftFt: berths.draftFt,
|
||||
})
|
||||
.from(interestBerths)
|
||||
.innerJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||
.where(eq(interestBerths.interestId, interestId))
|
||||
.orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt));
|
||||
}
|
||||
|
||||
@@ -479,14 +479,18 @@ export async function getInterestById(id: string, portId: string) {
|
||||
// wa.me) so the header can render Email / Call / WhatsApp buttons without
|
||||
// a second fetch, and the Documents tab can show the EOI prereq checklist.
|
||||
const [emailContact] = await db
|
||||
.select({ value: clientContacts.value })
|
||||
.select({ id: clientContacts.id, value: clientContacts.value })
|
||||
.from(clientContacts)
|
||||
.where(and(eq(clientContacts.clientId, interest.clientId), eq(clientContacts.channel, 'email')))
|
||||
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
const [phoneContact] = await db
|
||||
.select({ value: clientContacts.value, valueE164: clientContacts.valueE164 })
|
||||
.select({
|
||||
id: clientContacts.id,
|
||||
value: clientContacts.value,
|
||||
valueE164: clientContacts.valueE164,
|
||||
})
|
||||
.from(clientContacts)
|
||||
.where(
|
||||
and(
|
||||
@@ -528,14 +532,18 @@ export async function getInterestById(id: string, portId: string) {
|
||||
// Most-recent note preview for the Overview tab (the "do you have anything
|
||||
// outstanding on this lead?" peek). Returns the latest note's truncated
|
||||
// content + author/timestamp so the UI can render a one-line teaser.
|
||||
// Left-joins userProfiles so the teaser can show the author's display name
|
||||
// instead of leaking the raw user-id UUID.
|
||||
const [recentNote] = await db
|
||||
.select({
|
||||
id: interestNotes.id,
|
||||
content: interestNotes.content,
|
||||
authorId: interestNotes.authorId,
|
||||
authorName: userProfiles.displayName,
|
||||
createdAt: interestNotes.createdAt,
|
||||
})
|
||||
.from(interestNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
|
||||
.where(eq(interestNotes.interestId, id))
|
||||
.orderBy(desc(interestNotes.createdAt))
|
||||
.limit(1);
|
||||
@@ -580,7 +588,11 @@ export async function getInterestById(id: string, portId: string) {
|
||||
...interest,
|
||||
clientName: clientRow?.fullName ?? null,
|
||||
clientPrimaryEmail: emailContact?.value ?? null,
|
||||
/** Contact-row id for the primary email — surfaces so the interest UI
|
||||
* can inline-edit through PATCH /api/v1/clients/[id]/contacts/[contactId]. */
|
||||
clientPrimaryEmailContactId: emailContact?.id ?? null,
|
||||
clientPrimaryPhone: phoneContact?.value ?? null,
|
||||
clientPrimaryPhoneContactId: phoneContact?.id ?? null,
|
||||
clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null,
|
||||
clientHasAddress: !!addressRow,
|
||||
berthId,
|
||||
@@ -1010,6 +1022,68 @@ export async function advanceStageIfBehind(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gated variant: reads the per-port `stage_advance_rules` setting for the
|
||||
* given trigger and either:
|
||||
* - 'auto' → calls advanceStageIfBehind (same behaviour as the base helper)
|
||||
* - 'suggest' → emits an in-CRM notification with an Approve link so the
|
||||
* rep can advance with one click (no auto-move)
|
||||
* - 'off' → no-op (audit log of the event still fires upstream)
|
||||
*
|
||||
* Use this from every lifecycle event handler that wants admin-controlled
|
||||
* cadence — the bare `advanceStageIfBehind` stays available for paths
|
||||
* where the move is unconditional (manual rep action, completion of a
|
||||
* doc the admin can't disable).
|
||||
*/
|
||||
export async function advanceStageIfBehindGated(
|
||||
interestId: string,
|
||||
portId: string,
|
||||
target: PipelineStage,
|
||||
meta: AuditMeta,
|
||||
reason: string | undefined,
|
||||
trigger:
|
||||
| 'eoi_sent'
|
||||
| 'eoi_signed'
|
||||
| 'reservation_signed'
|
||||
| 'deposit_received'
|
||||
| 'contract_signed',
|
||||
): Promise<boolean> {
|
||||
const { getStageAdvanceMode } = await import('@/lib/services/port-config');
|
||||
const mode = await getStageAdvanceMode(portId, trigger);
|
||||
if (mode === 'off') return false;
|
||||
if (mode === 'auto') return advanceStageIfBehind(interestId, portId, target, meta, reason);
|
||||
// 'suggest' — notify the rep with an Approve link, no auto-move. The
|
||||
// rep can click the notification to fire the same advance manually.
|
||||
const existing = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
columns: { pipelineStage: true, assignedTo: true },
|
||||
});
|
||||
if (!existing) return false;
|
||||
const currentIdx = PIPELINE_STAGES.indexOf(existing.pipelineStage as PipelineStage);
|
||||
const targetIdx = PIPELINE_STAGES.indexOf(target);
|
||||
if (currentIdx === -1 || targetIdx === -1 || currentIdx >= targetIdx) return false;
|
||||
if (existing.assignedTo) {
|
||||
void import('@/lib/services/notifications.service').then(({ createNotification }) =>
|
||||
createNotification({
|
||||
portId,
|
||||
userId: existing.assignedTo!,
|
||||
type: 'stage_advance_suggested',
|
||||
title: `Advance to ${target}?`,
|
||||
description:
|
||||
reason ??
|
||||
`${trigger} fired — suggested advance from ${existing.pipelineStage} to ${target}.`,
|
||||
link: `/interests/${interestId}`,
|
||||
entityType: 'interest',
|
||||
entityId: interestId,
|
||||
dedupeKey: `interest:${interestId}:advance-suggest:${trigger}`,
|
||||
}).catch(() => {
|
||||
// Notification failure shouldn't block the parent webhook handler.
|
||||
}),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Set Outcome (Won / Lost) ────────────────────────────────────────────────
|
||||
//
|
||||
// Records a terminal outcome for the interest. The `outcome` column is the
|
||||
@@ -1047,12 +1121,13 @@ export async function setInterestOutcome(
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
// M-AU04: distinct verb so the audit filter / FTS surface it directly.
|
||||
action: 'outcome_set',
|
||||
entityType: 'interest',
|
||||
entityId: id,
|
||||
oldValue: { outcome: oldOutcome, pipelineStage: stageAtOutcome },
|
||||
newValue: { outcome: data.outcome, pipelineStage: stageAtOutcome, reason: data.reason },
|
||||
metadata: { type: 'outcome_set', stageAtOutcome },
|
||||
metadata: { stageAtOutcome },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
@@ -1115,12 +1190,12 @@ export async function clearInterestOutcome(
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
// M-AU04: distinct verb so the audit filter / FTS surface it directly.
|
||||
action: 'outcome_cleared',
|
||||
entityType: 'interest',
|
||||
entityId: id,
|
||||
oldValue: { outcome: existing.outcome, pipelineStage: existing.pipelineStage },
|
||||
newValue: { outcome: null, pipelineStage: reopenStage },
|
||||
metadata: { type: 'outcome_cleared' },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
@@ -680,13 +680,14 @@ export async function recordPayment(
|
||||
// tracking for the deposit stage; this block stays wired for legacy
|
||||
// invoices but new flows record payments via that pathway instead.
|
||||
if (updated.kind === 'deposit' && updated.interestId) {
|
||||
const { advanceStageIfBehind } = await import('@/lib/services/interests.service');
|
||||
void advanceStageIfBehind(
|
||||
const { advanceStageIfBehindGated } = await import('@/lib/services/interests.service');
|
||||
void advanceStageIfBehindGated(
|
||||
updated.interestId,
|
||||
portId,
|
||||
'deposit_paid',
|
||||
meta,
|
||||
`Deposit invoice ${existing.invoiceNumber} paid`,
|
||||
'deposit_received',
|
||||
);
|
||||
|
||||
// Deposit-paid also fires the berth-rule for `deposit_received` so admins
|
||||
|
||||
@@ -105,6 +105,10 @@ export interface AggregatedClientNote {
|
||||
/** Human label for the source (interest's berth mooring, yacht
|
||||
* name, or "Client" for client-level). */
|
||||
sourceLabel: string;
|
||||
/** Pipeline stage the linked interest was at when this note was
|
||||
* authored. Only populated for interest notes (the only entity with
|
||||
* a pipeline). Null for pre-2026-05-15 rows + non-interest sources. */
|
||||
pipelineStageAtCreation?: string | null;
|
||||
}
|
||||
|
||||
export async function listForClientAggregated(
|
||||
@@ -175,6 +179,7 @@ export async function listForClientAggregated(
|
||||
authorId: interestNotes.authorId,
|
||||
authorName: userProfiles.displayName,
|
||||
sourceId: interestNotes.interestId,
|
||||
pipelineStageAtCreation: interestNotes.pipelineStageAtCreation,
|
||||
})
|
||||
.from(interestNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
|
||||
@@ -278,7 +283,11 @@ export async function listForYachtAggregated(
|
||||
? await db
|
||||
.select({ id: clients.id, name: clients.fullName })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, ownerClientId))
|
||||
// M-MT04: defense-in-depth port_id filter — without it a stale
|
||||
// ownerClientId persisted by a prior cross-port migration could
|
||||
// surface the wrong tenant's client name. Belt-and-braces given
|
||||
// yacht ownership is polymorphic via a non-FK pair.
|
||||
.where(and(eq(clients.id, ownerClientId), eq(clients.portId, portId)))
|
||||
.limit(1)
|
||||
: [];
|
||||
|
||||
@@ -340,6 +349,7 @@ export async function listForYachtAggregated(
|
||||
authorId: interestNotes.authorId,
|
||||
authorName: userProfiles.displayName,
|
||||
sourceId: interestNotes.interestId,
|
||||
pipelineStageAtCreation: interestNotes.pipelineStageAtCreation,
|
||||
})
|
||||
.from(interestNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
|
||||
@@ -456,6 +466,7 @@ export async function listForCompanyAggregated(
|
||||
authorId: interestNotes.authorId,
|
||||
authorName: userProfiles.displayName,
|
||||
sourceId: interestNotes.interestId,
|
||||
pipelineStageAtCreation: interestNotes.pipelineStageAtCreation,
|
||||
})
|
||||
.from(interestNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
|
||||
@@ -590,6 +601,7 @@ export async function listForEntity(portId: string, entityType: EntityType, enti
|
||||
createdAt: interestNotes.createdAt,
|
||||
updatedAt: interestNotes.updatedAt,
|
||||
authorName: userProfiles.displayName,
|
||||
pipelineStageAtCreation: interestNotes.pipelineStageAtCreation,
|
||||
})
|
||||
.from(interestNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
|
||||
@@ -748,9 +760,22 @@ export async function create(
|
||||
return { ...note, authorName };
|
||||
}
|
||||
if (entityType === 'interests') {
|
||||
// Snapshot the linked interest's current pipeline_stage so the note
|
||||
// carries the stage it was authored in. Powers the stage chip on the
|
||||
// NotesList timeline. Missing-interest is treated as a no-stamp
|
||||
// create rather than a hard failure.
|
||||
const interestRow = await db.query.interests.findFirst({
|
||||
where: eq(interests.id, entityId),
|
||||
columns: { pipelineStage: true },
|
||||
});
|
||||
const [note] = await db
|
||||
.insert(interestNotes)
|
||||
.values({ interestId: entityId, authorId, content: data.content })
|
||||
.values({
|
||||
interestId: entityId,
|
||||
authorId,
|
||||
content: data.content,
|
||||
pipelineStageAtCreation: interestRow?.pipelineStage ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!note)
|
||||
@@ -846,7 +871,10 @@ export async function update(
|
||||
const [updated] = await db
|
||||
.update(yachtNotes)
|
||||
.set({ content: data.content, updatedAt: new Date() })
|
||||
.where(eq(yachtNotes.id, noteId))
|
||||
// M-MT02: defense-in-depth — pin the UPDATE to the (id, parent) pair
|
||||
// so a swapped noteId can't land on a sibling yacht's note even if
|
||||
// the existing read above already validated ownership.
|
||||
.where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId)))
|
||||
.returning();
|
||||
if (!updated) throw new NotFoundError('Note');
|
||||
const profile = await db
|
||||
@@ -869,7 +897,8 @@ export async function update(
|
||||
const [updated] = await db
|
||||
.update(companyNotes)
|
||||
.set({ content: data.content, updatedAt: new Date() })
|
||||
.where(eq(companyNotes.id, noteId))
|
||||
// M-MT02: pin (id, parent) for defense-in-depth.
|
||||
.where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId)))
|
||||
.returning();
|
||||
if (!updated) throw new NotFoundError('Note');
|
||||
const profile = await db
|
||||
@@ -897,7 +926,8 @@ export async function update(
|
||||
const [updated] = await db
|
||||
.update(clientNotes)
|
||||
.set({ content: data.content, updatedAt: new Date() })
|
||||
.where(eq(clientNotes.id, noteId))
|
||||
// M-MT02: pin (id, parent) for defense-in-depth.
|
||||
.where(and(eq(clientNotes.id, noteId), eq(clientNotes.clientId, entityId)))
|
||||
.returning();
|
||||
|
||||
if (!updated) throw new NotFoundError('Note');
|
||||
@@ -928,7 +958,13 @@ export async function update(
|
||||
const [updated] = await db
|
||||
.update(residentialClientNotes)
|
||||
.set({ content: data.content, updatedAt: new Date() })
|
||||
.where(eq(residentialClientNotes.id, noteId))
|
||||
// M-MT02: pin (id, parent) for defense-in-depth.
|
||||
.where(
|
||||
and(
|
||||
eq(residentialClientNotes.id, noteId),
|
||||
eq(residentialClientNotes.residentialClientId, entityId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!updated) throw new NotFoundError('Note');
|
||||
const profile = await db
|
||||
@@ -956,7 +992,13 @@ export async function update(
|
||||
const [updated] = await db
|
||||
.update(residentialInterestNotes)
|
||||
.set({ content: data.content, updatedAt: new Date() })
|
||||
.where(eq(residentialInterestNotes.id, noteId))
|
||||
// M-MT02: pin (id, parent) for defense-in-depth.
|
||||
.where(
|
||||
and(
|
||||
eq(residentialInterestNotes.id, noteId),
|
||||
eq(residentialInterestNotes.residentialInterestId, entityId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!updated) throw new NotFoundError('Note');
|
||||
const profile = await db
|
||||
@@ -982,7 +1024,8 @@ export async function update(
|
||||
const [updated] = await db
|
||||
.update(interestNotes)
|
||||
.set({ content: data.content, updatedAt: new Date() })
|
||||
.where(eq(interestNotes.id, noteId))
|
||||
// M-MT02: pin (id, parent) for defense-in-depth.
|
||||
.where(and(eq(interestNotes.id, noteId), eq(interestNotes.interestId, entityId)))
|
||||
.returning();
|
||||
|
||||
if (!updated) throw new NotFoundError('Note');
|
||||
@@ -1015,7 +1058,10 @@ export async function deleteNote(
|
||||
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
await db.delete(yachtNotes).where(eq(yachtNotes.id, noteId));
|
||||
// M-MT02: pin (id, parent) on the delete WHERE for defense-in-depth.
|
||||
await db
|
||||
.delete(yachtNotes)
|
||||
.where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId)));
|
||||
return existing;
|
||||
}
|
||||
if (entityType === 'companies') {
|
||||
@@ -1028,7 +1074,10 @@ export async function deleteNote(
|
||||
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
await db.delete(companyNotes).where(eq(companyNotes.id, noteId));
|
||||
// M-MT02: pin (id, parent).
|
||||
await db
|
||||
.delete(companyNotes)
|
||||
.where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId)));
|
||||
return existing;
|
||||
}
|
||||
if (entityType === 'clients') {
|
||||
@@ -1043,7 +1092,10 @@ export async function deleteNote(
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
|
||||
await db.delete(clientNotes).where(eq(clientNotes.id, noteId));
|
||||
// M-MT02: pin (id, parent).
|
||||
await db
|
||||
.delete(clientNotes)
|
||||
.where(and(eq(clientNotes.id, noteId), eq(clientNotes.clientId, entityId)));
|
||||
return existing;
|
||||
}
|
||||
if (entityType === 'residential_clients') {
|
||||
@@ -1061,7 +1113,15 @@ export async function deleteNote(
|
||||
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
await db.delete(residentialClientNotes).where(eq(residentialClientNotes.id, noteId));
|
||||
// M-MT02: pin (id, parent).
|
||||
await db
|
||||
.delete(residentialClientNotes)
|
||||
.where(
|
||||
and(
|
||||
eq(residentialClientNotes.id, noteId),
|
||||
eq(residentialClientNotes.residentialClientId, entityId),
|
||||
),
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
if (entityType === 'residential_interests') {
|
||||
@@ -1079,7 +1139,15 @@ export async function deleteNote(
|
||||
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
await db.delete(residentialInterestNotes).where(eq(residentialInterestNotes.id, noteId));
|
||||
// M-MT02: pin (id, parent).
|
||||
await db
|
||||
.delete(residentialInterestNotes)
|
||||
.where(
|
||||
and(
|
||||
eq(residentialInterestNotes.id, noteId),
|
||||
eq(residentialInterestNotes.residentialInterestId, entityId),
|
||||
),
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
// Default: interests
|
||||
@@ -1095,7 +1163,10 @@ export async function deleteNote(
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
|
||||
await db.delete(interestNotes).where(eq(interestNotes.id, noteId));
|
||||
// M-MT02: pin (id, parent).
|
||||
await db
|
||||
.delete(interestNotes)
|
||||
.where(and(eq(interestNotes.id, noteId), eq(interestNotes.interestId, entityId)));
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,15 +155,11 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
|
||||
{ branding },
|
||||
);
|
||||
|
||||
// The per-port subject override key for the digest is the
|
||||
// existing 'crm_invite' / 'portal_*' family — digest is its own
|
||||
// thing; for now we ship the default subject from the template.
|
||||
// M-EM04: dedicated catalog key — admins can override the
|
||||
// digest subject from /admin/email without an unsafe cast and
|
||||
// the digest's setting key now namespaces cleanly.
|
||||
const subject = await resolveSubject({
|
||||
// No dedicated catalog key yet for the digest; keep a stable
|
||||
// pseudo-key in case admins want to override later. Falls
|
||||
// through to the template's default subject if no override.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
key: 'crm_invite' as any,
|
||||
key: 'notification_digest',
|
||||
portId: port.id,
|
||||
fallback: result.subject,
|
||||
tokens: { portName: port.name },
|
||||
|
||||
@@ -130,13 +130,14 @@ export async function createPayment(portId: string, data: CreatePaymentInput, me
|
||||
const { total } = await getDepositTotalForInterest(data.interestId, portId);
|
||||
const expected = interest.depositExpectedAmount ? Number(interest.depositExpectedAmount) : null;
|
||||
if (expected !== null && Number.isFinite(expected) && Number(total) >= expected) {
|
||||
const { advanceStageIfBehind } = await import('@/lib/services/interests.service');
|
||||
void advanceStageIfBehind(
|
||||
const { advanceStageIfBehindGated } = await import('@/lib/services/interests.service');
|
||||
void advanceStageIfBehindGated(
|
||||
data.interestId,
|
||||
portId,
|
||||
'deposit_paid',
|
||||
meta,
|
||||
`Deposit total (${total} ${data.currency}) reached expected amount`,
|
||||
'deposit_received',
|
||||
);
|
||||
|
||||
// Stamp dateDepositReceived if not already set so the timeline shows
|
||||
|
||||
@@ -105,14 +105,103 @@ export const SETTING_KEYS = {
|
||||
|
||||
// Berths
|
||||
berthsDefaultCurrency: 'berths_default_currency',
|
||||
|
||||
// Pipeline auto-advance — per-trigger mode (auto | suggest | off).
|
||||
// Stored as a single JSON blob keyed by trigger name so the admin UI
|
||||
// edits/saves the full map atomically. Defaults applied in
|
||||
// `getStageAdvanceMode` — aggressive defaults match the conventional
|
||||
// CRM behaviour (EOI signed → reservation auto-advances).
|
||||
stageAdvanceRules: 'stage_advance_rules',
|
||||
} as const;
|
||||
|
||||
// ─── Stage auto-advance ──────────────────────────────────────────────────────
|
||||
|
||||
export type StageAdvanceMode = 'auto' | 'suggest' | 'off';
|
||||
|
||||
/**
|
||||
* Stage transitions that callers can gate through the admin's
|
||||
* `stage_advance_rules` setting. Keys are the trigger names already
|
||||
* used by the rules engine (`berth-rules-engine.ts`) — keeping them in
|
||||
* sync lets a single admin toggle drive both side-effects (berth status)
|
||||
* and stage moves.
|
||||
*/
|
||||
export type StageAdvanceTrigger =
|
||||
| 'eoi_sent'
|
||||
| 'eoi_signed'
|
||||
| 'reservation_signed'
|
||||
| 'deposit_received'
|
||||
| 'contract_signed';
|
||||
|
||||
const STAGE_ADVANCE_DEFAULTS: Record<StageAdvanceTrigger, StageAdvanceMode> = {
|
||||
// Sending the EOI is the moment the deal formally enters the document-
|
||||
// signing pursuit phase — auto-advance so the kanban tracks reality
|
||||
// without a rep having to click.
|
||||
eoi_sent: 'auto',
|
||||
// EOI signed = formal commitment to proceed → advance to reservation.
|
||||
eoi_signed: 'auto',
|
||||
// Reservation signed = the deal is now under contract pursuit.
|
||||
reservation_signed: 'auto',
|
||||
// Deposit received = monies in, deal is committed; the next milestone
|
||||
// is contract sign-off.
|
||||
deposit_received: 'auto',
|
||||
contract_signed: 'auto',
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the auto-advance mode for a single trigger on a port.
|
||||
* Reads `stage_advance_rules` (a JSON object keyed by trigger name) and
|
||||
* falls back to the platform default when the port hasn't overridden.
|
||||
*/
|
||||
export async function getStageAdvanceMode(
|
||||
portId: string,
|
||||
trigger: StageAdvanceTrigger,
|
||||
): Promise<StageAdvanceMode> {
|
||||
const raw = await readSetting<Record<string, StageAdvanceMode>>(
|
||||
SETTING_KEYS.stageAdvanceRules,
|
||||
portId,
|
||||
);
|
||||
const mode = raw?.[trigger];
|
||||
if (mode === 'auto' || mode === 'suggest' || mode === 'off') return mode;
|
||||
return STAGE_ADVANCE_DEFAULTS[trigger];
|
||||
}
|
||||
|
||||
export function getStageAdvanceDefaults(): Record<StageAdvanceTrigger, StageAdvanceMode> {
|
||||
return { ...STAGE_ADVANCE_DEFAULTS };
|
||||
}
|
||||
|
||||
// ─── Helper ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function readSetting<T>(key: string, portId: string): Promise<T | null> {
|
||||
/**
|
||||
* Resolves a port-scoped setting through the registry-aware resolver, which
|
||||
* applies the port → global → env → registry-default chain and decrypts
|
||||
* encrypted entries transparently. The legacy `{ value: <T> }` wrapper from
|
||||
* `settings.service.upsertSetting` is unwrapped inside the resolver, so this
|
||||
* helper still returns the bare `T | null` shape its callers expect.
|
||||
*
|
||||
* If the key isn't registered yet (legacy bespoke settings not in the
|
||||
* registry), we fall back to the older `settings.service.getSetting()` path
|
||||
* for backward compatibility.
|
||||
*/
|
||||
export async function readSetting<T>(key: string, portId: string): Promise<T | null> {
|
||||
const { registryFor } = await import('@/lib/settings/registry');
|
||||
if (registryFor(key)) {
|
||||
const { getSetting: getSettingFromRegistry } = await import('@/lib/settings/resolver');
|
||||
return (await getSettingFromRegistry<T>(key, portId)) ?? null;
|
||||
}
|
||||
const setting = await getSetting(key, portId);
|
||||
if (!setting) return null;
|
||||
return setting.value as T;
|
||||
// Legacy upsertSetting wrote the value as the JSONB column directly (not
|
||||
// wrapped). Newer paths wrap it as `{ value }`. Tolerate both.
|
||||
const raw = setting.value as unknown;
|
||||
if (
|
||||
raw &&
|
||||
typeof raw === 'object' &&
|
||||
'value' in (raw as Record<string, unknown>) &&
|
||||
Object.keys(raw as object).length === 1
|
||||
) {
|
||||
return (raw as { value: T }).value;
|
||||
}
|
||||
return raw as T;
|
||||
}
|
||||
|
||||
// ─── Email ──────────────────────────────────────────────────────────────────
|
||||
@@ -171,8 +260,8 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
|
||||
fromName: fromName ?? envFromName,
|
||||
fromAddress: fromAddress ?? envFromAddress,
|
||||
replyTo: replyTo ?? null,
|
||||
smtpHost: smtpHost ?? env.SMTP_HOST,
|
||||
smtpPort: smtpPort ?? env.SMTP_PORT,
|
||||
smtpHost: smtpHost ?? env.SMTP_HOST ?? '',
|
||||
smtpPort: smtpPort ?? env.SMTP_PORT ?? 587,
|
||||
smtpUser: smtpUser ?? env.SMTP_USER ?? null,
|
||||
smtpPass: smtpPass ?? env.SMTP_PASS ?? null,
|
||||
allowPersonalAccountSends: allowPersonalAccountSends ?? false,
|
||||
@@ -302,13 +391,18 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
||||
]);
|
||||
|
||||
return {
|
||||
apiUrl: apiUrl ?? env.DOCUMENSO_API_URL,
|
||||
apiKey: apiKey ?? env.DOCUMENSO_API_KEY,
|
||||
// Env values are now optional (admin is canonical). Empty / zero
|
||||
// defaults let consumers proceed and fail at the actual API call with
|
||||
// a clearer "not configured" error rather than crashing at type-check.
|
||||
apiUrl: apiUrl ?? env.DOCUMENSO_API_URL ?? '',
|
||||
apiKey: apiKey ?? env.DOCUMENSO_API_KEY ?? '',
|
||||
apiVersion: apiVersion ?? env.DOCUMENSO_API_VERSION,
|
||||
eoiTemplateId: toIntOrNull(eoiTemplateId) ?? env.DOCUMENSO_TEMPLATE_ID_EOI,
|
||||
clientRecipientId: toIntOrNull(clientRecipientId) ?? env.DOCUMENSO_CLIENT_RECIPIENT_ID,
|
||||
developerRecipientId: toIntOrNull(developerRecipientId) ?? env.DOCUMENSO_DEVELOPER_RECIPIENT_ID,
|
||||
approvalRecipientId: toIntOrNull(approvalRecipientId) ?? env.DOCUMENSO_APPROVAL_RECIPIENT_ID,
|
||||
eoiTemplateId: toIntOrNull(eoiTemplateId) ?? env.DOCUMENSO_TEMPLATE_ID_EOI ?? 0,
|
||||
clientRecipientId: toIntOrNull(clientRecipientId) ?? env.DOCUMENSO_CLIENT_RECIPIENT_ID ?? 0,
|
||||
developerRecipientId:
|
||||
toIntOrNull(developerRecipientId) ?? env.DOCUMENSO_DEVELOPER_RECIPIENT_ID ?? 0,
|
||||
approvalRecipientId:
|
||||
toIntOrNull(approvalRecipientId) ?? env.DOCUMENSO_APPROVAL_RECIPIENT_ID ?? 0,
|
||||
defaultPathway: defaultPathway ?? 'documenso-template',
|
||||
developerName: developerName ?? '',
|
||||
developerEmail: developerEmail ?? '',
|
||||
@@ -344,17 +438,40 @@ export interface DocumensoSecretEntry {
|
||||
export async function listDocumensoWebhookSecrets(): Promise<DocumensoSecretEntry[]> {
|
||||
const { db } = await import('@/lib/db');
|
||||
const { systemSettings } = await import('@/lib/db/schema/system');
|
||||
const { eq, isNotNull } = await import('drizzle-orm');
|
||||
const { decrypt } = await import('@/lib/utils/encryption');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
const rows = await db
|
||||
.select({ portId: systemSettings.portId, value: systemSettings.value })
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, SETTING_KEYS.documensoWebhookSecret));
|
||||
void isNotNull; // imported for future filters
|
||||
|
||||
const out: DocumensoSecretEntry[] = [];
|
||||
for (const row of rows) {
|
||||
if (typeof row.value !== 'string' || !row.value || !row.portId) continue;
|
||||
out.push({ portId: row.portId, secret: row.value });
|
||||
if (!row.portId) continue;
|
||||
let secret: string | null = null;
|
||||
if (typeof row.value === 'string') {
|
||||
// Legacy plaintext rows.
|
||||
secret = row.value;
|
||||
} else if (
|
||||
typeof row.value === 'object' &&
|
||||
row.value !== null &&
|
||||
'iv' in row.value &&
|
||||
'tag' in row.value &&
|
||||
'data' in row.value
|
||||
) {
|
||||
// Encrypted envelope written by the registry-aware resolver.
|
||||
try {
|
||||
secret = decrypt(JSON.stringify(row.value));
|
||||
} catch {
|
||||
// Decryption failure (corrupt envelope, key mismatch) — skip the
|
||||
// entry so a stale row doesn't crash the entire receiver loop.
|
||||
secret = null;
|
||||
}
|
||||
}
|
||||
if (!secret) continue;
|
||||
out.push({ portId: row.portId, secret });
|
||||
}
|
||||
|
||||
// Append the global env secret as a fallback ONLY when it's a real,
|
||||
// non-empty value. An empty env secret would otherwise match an empty
|
||||
// X-Documenso-Secret header (verifyDocumensoSecret guards this too,
|
||||
|
||||
@@ -161,7 +161,10 @@ async function issueActivationToken(
|
||||
);
|
||||
|
||||
try {
|
||||
await sendEmail(email, subject, html, undefined, text);
|
||||
// M-EM01: pass portId so per-port SMTP is used. Without it the call
|
||||
// falls back to the global SMTP transport and the from-address won't
|
||||
// carry the port's branding.
|
||||
await sendEmail(email, subject, html, undefined, text, portId);
|
||||
} catch (err) {
|
||||
logger.error({ err, email }, 'Failed to send portal activation email');
|
||||
// Re-throw - the admin should know if their invite mail bounced.
|
||||
@@ -425,7 +428,8 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
||||
);
|
||||
|
||||
try {
|
||||
await sendEmail(user.email, subject, html, undefined, text);
|
||||
// M-EM01: pass portId so per-port SMTP + from-address are used.
|
||||
await sendEmail(user.email, subject, html, undefined, text, user.portId);
|
||||
} catch (err) {
|
||||
logger.error({ err, email: user.email }, 'Failed to send password-reset email');
|
||||
// Don't propagate - the public route returns 200 either way.
|
||||
|
||||
@@ -348,7 +348,11 @@ export async function getDocumentDownloadUrl(
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
return presignDownloadUrl(file.storagePath);
|
||||
// M-IN01: 4-hour TTL for portal links so clients clicking on a saved
|
||||
// email don't see "link expired" after 15 minutes. Storage backend's
|
||||
// default is 900s — that's appropriate for in-CRM previews but
|
||||
// hostile for end-clients who may revisit a doc the next morning.
|
||||
return presignDownloadUrl(file.storagePath, 4 * 3600);
|
||||
}
|
||||
|
||||
// ─── Yachts (direct + via company) ────────────────────────────────────────────
|
||||
|
||||
@@ -214,6 +214,21 @@ export interface QualificationRow {
|
||||
confirmedAt: Date | null;
|
||||
confirmedBy: string | null;
|
||||
notes: string | null;
|
||||
/**
|
||||
* True when the criterion is automatically satisfied by data already on
|
||||
* the interest (or a linked record). Surfaced to the UI so the checklist
|
||||
* can render the criterion as ticked-by-the-system without needing an
|
||||
* explicit `interest_qualifications` row. When both `autoSatisfied` and
|
||||
* the explicit `confirmed` are true, either signal alone counts.
|
||||
*
|
||||
* Auto-satisfaction rules:
|
||||
* - `dimensions` → ticked when EITHER (a) the linked yacht has all
|
||||
* three dims (length/width/draft) OR (b) the interest itself has
|
||||
* desired-berth dims set. The "no yacht needed" case is the second
|
||||
* branch — a client buying a berth doesn't have to own a vessel,
|
||||
* they just have to know the berth size they want.
|
||||
*/
|
||||
autoSatisfied: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,12 +241,38 @@ export async function listInterestQualifications(
|
||||
interestId: string,
|
||||
portId: string,
|
||||
): Promise<QualificationRow[]> {
|
||||
// Pull the interest row with the fields needed to derive auto-satisfaction —
|
||||
// desired-berth dims (length/width/draft) plus a linked yacht if any. Cost
|
||||
// is one extra column-select vs the previous columns:{id:true} probe, so
|
||||
// negligible.
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
columns: { id: true },
|
||||
columns: {
|
||||
id: true,
|
||||
yachtId: true,
|
||||
desiredLengthFt: true,
|
||||
desiredWidthFt: true,
|
||||
desiredDraftFt: true,
|
||||
},
|
||||
});
|
||||
if (!interest) throw new NotFoundError('Interest');
|
||||
|
||||
// Pull the linked yacht's dims when one is attached. Used by the
|
||||
// `dimensions` criterion's auto-satisfaction rule (yacht-side branch).
|
||||
let yachtDims: {
|
||||
lengthFt: string | null;
|
||||
widthFt: string | null;
|
||||
draftFt: string | null;
|
||||
} | null = null;
|
||||
if (interest.yachtId) {
|
||||
const { yachts } = await import('@/lib/db/schema/yachts');
|
||||
const yacht = await db.query.yachts.findFirst({
|
||||
where: eq(yachts.id, interest.yachtId),
|
||||
columns: { lengthFt: true, widthFt: true, draftFt: true },
|
||||
});
|
||||
if (yacht) yachtDims = yacht;
|
||||
}
|
||||
|
||||
const criteria = await db
|
||||
.select()
|
||||
.from(qualificationCriteria)
|
||||
@@ -246,20 +287,59 @@ export async function listInterestQualifications(
|
||||
|
||||
return criteria.map((c) => {
|
||||
const s = stateByKey.get(c.key);
|
||||
const autoSatisfied = computeAutoSatisfied(c.key, {
|
||||
yachtDims,
|
||||
desiredDims: {
|
||||
lengthFt: interest.desiredLengthFt ?? null,
|
||||
widthFt: interest.desiredWidthFt ?? null,
|
||||
draftFt: interest.desiredDraftFt ?? null,
|
||||
},
|
||||
});
|
||||
const explicit = s?.confirmed ?? false;
|
||||
return {
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
enabled: c.enabled,
|
||||
displayOrder: c.displayOrder,
|
||||
confirmed: s?.confirmed ?? false,
|
||||
// Surface ticked state when either signal is true. Explicit confirmation
|
||||
// still gets its confirmedAt/By stamps; auto-satisfied state leaves
|
||||
// those null so the rep can see "this was system-derived, not an
|
||||
// explicit sign-off".
|
||||
confirmed: explicit || autoSatisfied,
|
||||
confirmedAt: s?.confirmedAt ?? null,
|
||||
confirmedBy: s?.confirmedBy ?? null,
|
||||
notes: s?.notes ?? null,
|
||||
autoSatisfied,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-criterion derivation rules. Each key knows how to read the interest
|
||||
* (and any linked records) and decide whether the criterion is satisfied
|
||||
* without an explicit rep tick. Add new rules by branching on `key`.
|
||||
*/
|
||||
function computeAutoSatisfied(
|
||||
key: string,
|
||||
ctx: {
|
||||
yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null } | null;
|
||||
desiredDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null };
|
||||
},
|
||||
): boolean {
|
||||
if (key === 'dimensions') {
|
||||
const hasYachtDims =
|
||||
!!ctx.yachtDims &&
|
||||
!!ctx.yachtDims.lengthFt &&
|
||||
!!ctx.yachtDims.widthFt &&
|
||||
!!ctx.yachtDims.draftFt;
|
||||
const hasDesiredDims =
|
||||
!!ctx.desiredDims.lengthFt && !!ctx.desiredDims.widthFt && !!ctx.desiredDims.draftFt;
|
||||
return hasYachtDims || hasDesiredDims;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a single criterion's confirmed-state for an interest. Stamping the
|
||||
* server-side fields (confirmedBy / confirmedAt) makes the row a proper
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import OpenAI from 'openai';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
const openai = new OpenAI(); // uses OPENAI_API_KEY from env
|
||||
// M-IN02: lazy-instantiate so a missing/invalid OPENAI_API_KEY doesn't
|
||||
// fail boot — the receipt-scan path is opt-in and only some ports
|
||||
// will have OCR configured. Cached after first construction so we
|
||||
// don't pay the cost on every scan.
|
||||
let openaiClient: OpenAI | null = null;
|
||||
function getOpenAI(): OpenAI {
|
||||
if (!openaiClient) {
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
throw new Error(
|
||||
'OPENAI_API_KEY is not configured — receipt OCR is unavailable. Set the key in /admin/ai or .env.',
|
||||
);
|
||||
}
|
||||
openaiClient = new OpenAI({ apiKey: env.OPENAI_API_KEY });
|
||||
}
|
||||
return openaiClient;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
establishment: string | null;
|
||||
@@ -15,7 +31,7 @@ interface ScanResult {
|
||||
export async function scanReceipt(imageBuffer: Buffer, mimeType: string): Promise<ScanResult> {
|
||||
try {
|
||||
const base64 = imageBuffer.toString('base64');
|
||||
const response = await openai.chat.completions.create({
|
||||
const response = await getOpenAI().chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
|
||||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { auditLogs, systemSettings } from '@/lib/db/schema/system';
|
||||
import { STAGE_WEIGHTS } from '@/lib/constants';
|
||||
import { STAGE_WEIGHTS, canonicalizeStage } from '@/lib/constants';
|
||||
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -75,9 +75,14 @@ export async function fetchPipelineData(
|
||||
.where(activeInterestsWhere(portId))
|
||||
.groupBy(interests.pipelineStage);
|
||||
|
||||
// M-L02: legacy 9-stage values (deposit_10pct, contract_sent…) may
|
||||
// still be present on historical rows. canonicalizeStage maps them
|
||||
// back to the modern 7-stage keys so the rollup doesn't carry phantom
|
||||
// buckets through to the PDF.
|
||||
const stageCountMap: Record<string, number> = {};
|
||||
for (const row of stageCounts) {
|
||||
stageCountMap[row.stage] = row.count;
|
||||
const key = canonicalizeStage(row.stage);
|
||||
stageCountMap[key] = (stageCountMap[key] ?? 0) + row.count;
|
||||
}
|
||||
|
||||
// Top 10 interests by berth price (via primary-berth junction join, plan §3.4).
|
||||
@@ -103,7 +108,9 @@ export async function fetchPipelineData(
|
||||
topInterests: topInterestsRows.map((r) => ({
|
||||
id: r.id,
|
||||
clientId: r.clientId,
|
||||
pipelineStage: r.pipelineStage,
|
||||
// M-L02: canonicalize for the same reason — the PDF stage label
|
||||
// should always resolve from the modern 7-stage set.
|
||||
pipelineStage: canonicalizeStage(r.pipelineStage),
|
||||
berthPrice: r.berthPrice ? String(r.berthPrice) : null,
|
||||
})),
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -133,9 +140,13 @@ export async function fetchRevenueData(
|
||||
.where(activeInterestsWhere(portId))
|
||||
.groupBy(interests.pipelineStage);
|
||||
|
||||
// M-L02: canonicalize so legacy 9-stage rows fold into the modern bucket.
|
||||
const stageRevenueMap: Record<string, string> = {};
|
||||
for (const row of stageRevenue) {
|
||||
stageRevenueMap[row.stage] = row.revenue ? String(row.revenue) : '0';
|
||||
const key = canonicalizeStage(row.stage);
|
||||
const prior = parseFloat(stageRevenueMap[key] ?? '0');
|
||||
const next = row.revenue ? parseFloat(String(row.revenue)) : 0;
|
||||
stageRevenueMap[key] = String(prior + next);
|
||||
}
|
||||
|
||||
// Total revenue from WON interests only. Reporting audit caught the
|
||||
@@ -188,7 +199,10 @@ export async function fetchRevenueData(
|
||||
let totalForecast = 0;
|
||||
for (const row of forecastRows) {
|
||||
if (!row.revenue) continue;
|
||||
const weight = pipelineWeights[row.stage] ?? 0;
|
||||
// M-L02: canonicalize so legacy keys hit pipelineWeights via their
|
||||
// modern equivalent (otherwise the lookup falls through to 0 and the
|
||||
// forecast silently undershoots).
|
||||
const weight = pipelineWeights[canonicalizeStage(row.stage)] ?? 0;
|
||||
totalForecast += parseFloat(String(row.revenue)) * weight;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import nodemailer, { type Transporter } from 'nodemailer';
|
||||
import { env } from '@/lib/env';
|
||||
import { ConflictError } from '@/lib/errors';
|
||||
import { decrypt, encrypt } from '@/lib/utils/encryption';
|
||||
import { SMTP_TIMEOUTS } from '@/lib/email';
|
||||
import { getSetting, upsertSetting } from '@/lib/services/settings.service';
|
||||
import type { AuditMeta } from '@/lib/audit';
|
||||
|
||||
@@ -332,6 +333,11 @@ export async function createSalesTransporter(portId: string): Promise<{
|
||||
host: cfg.smtpHost,
|
||||
port: cfg.smtpPort,
|
||||
secure: cfg.smtpSecure,
|
||||
// H-08: cap connection / greeting / socket timeouts so a hung SMTP
|
||||
// relay can't stall the BullMQ email queue indefinitely (default
|
||||
// would let a single flaky upstream block every worker slot for
|
||||
// ~10 min before the retry policy gives up).
|
||||
...SMTP_TIMEOUTS,
|
||||
...(cfg.smtpUser && cfg.smtpPass ? { auth: { user: cfg.smtpUser, pass: cfg.smtpPass } } : {}),
|
||||
});
|
||||
return { transporter, fromAddress: cfg.fromAddress, authedUser: cfg.smtpUser };
|
||||
|
||||
@@ -468,7 +468,7 @@ async function searchClients(
|
||||
fullName: r.full_name,
|
||||
matchedContact: r.matched_value ?? null,
|
||||
matchedContactChannel: r.matched_channel ?? null,
|
||||
archivedAt: r.archived_at ? r.archived_at.toISOString() : null,
|
||||
archivedAt: r.archived_at ? new Date(r.archived_at).toISOString() : null,
|
||||
matchedOn,
|
||||
};
|
||||
});
|
||||
@@ -1032,7 +1032,10 @@ async function searchReminders(
|
||||
const rows = await db.execute<{
|
||||
id: string;
|
||||
title: string;
|
||||
due_at: Date;
|
||||
// postgres.js returns timestamptz as a string for raw db.execute calls
|
||||
// (no schema-aware decoding like the query builder gets), so type as
|
||||
// `string | Date` and normalize at the boundary.
|
||||
due_at: string | Date;
|
||||
priority: string;
|
||||
status: string;
|
||||
}>(sql`
|
||||
@@ -1051,7 +1054,7 @@ async function searchReminders(
|
||||
return Array.from(rows).map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
dueAt: r.due_at.toISOString(),
|
||||
dueAt: new Date(r.due_at).toISOString(),
|
||||
priority: r.priority,
|
||||
status: r.status,
|
||||
}));
|
||||
|
||||
@@ -63,14 +63,27 @@ export async function upsertSetting(key: string, value: unknown, portId: string,
|
||||
},
|
||||
});
|
||||
|
||||
// H-06: keys ending with `_encrypted` carry AES-GCM ciphertext that's only
|
||||
// useful with EMAIL_CREDENTIAL_KEY. Recording the ciphertext verbatim in
|
||||
// audit_logs.new_value would turn the audit log (readable by any admin
|
||||
// with `admin.view_audit_log`) into a credential bundle — if the
|
||||
// encryption key is ever rotated/leaked the history exfils every
|
||||
// configured password. Mask the value but keep the audit trail itself
|
||||
// (who toggled what, when).
|
||||
const isEncryptedKey = key.endsWith('_encrypted');
|
||||
const auditOldValue = existing
|
||||
? { value: isEncryptedKey ? '[redacted]' : existing.value }
|
||||
: undefined;
|
||||
const auditNewValue = { value: isEncryptedKey ? '[redacted]' : value };
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: existing ? 'update' : 'create',
|
||||
entityType: 'setting',
|
||||
entityId: key,
|
||||
oldValue: existing ? { value: existing.value } : undefined,
|
||||
newValue: { value },
|
||||
oldValue: auditOldValue,
|
||||
newValue: auditNewValue,
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
@@ -100,7 +113,7 @@ export async function deleteSetting(key: string, portId: string, meta: AuditMeta
|
||||
action: 'delete',
|
||||
entityType: 'setting',
|
||||
entityId: key,
|
||||
oldValue: { value: existing.value },
|
||||
oldValue: { value: key.endsWith('_encrypted') ? '[redacted]' : existing.value },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
@@ -287,19 +287,30 @@ export async function getActiveVisitors(portId: string): Promise<UmamiActiveVisi
|
||||
/**
|
||||
* Verify the connection by hitting `/api/websites/:id/active` - the cheapest
|
||||
* authenticated endpoint that proves both auth + websiteId are good.
|
||||
* Throws on any failure with a descriptive message; resolves on success.
|
||||
*
|
||||
* M-IN05: returns a tagged union `{ ok: true | false }` instead of throwing,
|
||||
* matching the shape of `checkDocumensoHealth` / sales-email health probes.
|
||||
* Routes that just want to surface a green/red "Test connection" pill no
|
||||
* longer have to wrap the call in try/catch with hand-crafted error
|
||||
* extraction.
|
||||
*/
|
||||
export async function testConnection(portId: string): Promise<{ ok: true; visitors: number }> {
|
||||
export async function testConnection(
|
||||
portId: string,
|
||||
): Promise<{ ok: true; visitors: number } | { ok: false; error: string }> {
|
||||
const config = await loadUmamiConfig(portId);
|
||||
if (!config) {
|
||||
throw new CodedError('UMAMI_NOT_CONFIGURED', {
|
||||
internalMessage: 'Umami is not configured for this port.',
|
||||
});
|
||||
return { ok: false, error: 'Umami is not configured for this port.' };
|
||||
}
|
||||
try {
|
||||
const result = await umamiFetch<UmamiActiveVisitors>(
|
||||
config,
|
||||
`/api/websites/${config.websiteId}/active`,
|
||||
{},
|
||||
);
|
||||
return { ok: true, visitors: result.visitors };
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Umami request failed';
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
const result = await umamiFetch<UmamiActiveVisitors>(
|
||||
config,
|
||||
`/api/websites/${config.websiteId}/active`,
|
||||
{},
|
||||
);
|
||||
return { ok: true, visitors: result.visitors };
|
||||
}
|
||||
|
||||
@@ -101,11 +101,15 @@ export async function listWebhooks(portId: string) {
|
||||
// ─── Get Single ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getWebhook(portId: string, webhookId: string) {
|
||||
// M-MT05: portId in the WHERE so the row never leaves the DB if it
|
||||
// belongs to a different tenant — the prior JS-side .portId !== portId
|
||||
// check fired AFTER the row was already loaded, which a future timing-
|
||||
// or audit-side-channel could exploit.
|
||||
const webhook = await db.query.webhooks.findFirst({
|
||||
where: eq(webhooks.id, webhookId),
|
||||
where: and(eq(webhooks.id, webhookId), eq(webhooks.portId, portId)),
|
||||
});
|
||||
|
||||
if (!webhook || webhook.portId !== portId) {
|
||||
if (!webhook) {
|
||||
throw new NotFoundError('Webhook');
|
||||
}
|
||||
|
||||
@@ -130,11 +134,12 @@ export async function updateWebhook(
|
||||
data: UpdateWebhookInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
// M-MT05: portId in WHERE — same reasoning as getWebhook.
|
||||
const existing = await db.query.webhooks.findFirst({
|
||||
where: eq(webhooks.id, webhookId),
|
||||
where: and(eq(webhooks.id, webhookId), eq(webhooks.portId, portId)),
|
||||
});
|
||||
|
||||
if (!existing || existing.portId !== portId) {
|
||||
if (!existing) {
|
||||
throw new NotFoundError('Webhook');
|
||||
}
|
||||
|
||||
@@ -167,11 +172,12 @@ export async function updateWebhook(
|
||||
// ─── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteWebhook(portId: string, webhookId: string, meta: AuditMeta) {
|
||||
// M-MT05: portId in WHERE — same reasoning as getWebhook.
|
||||
const existing = await db.query.webhooks.findFirst({
|
||||
where: eq(webhooks.id, webhookId),
|
||||
where: and(eq(webhooks.id, webhookId), eq(webhooks.portId, portId)),
|
||||
});
|
||||
|
||||
if (!existing || existing.portId !== portId) {
|
||||
if (!existing) {
|
||||
throw new NotFoundError('Webhook');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user