Files
pn-new-crm/src/lib/services/documenso-payload.ts

166 lines
5.4 KiB
TypeScript
Raw Normal View History

feat(sales): admin-configurable EOI signers + richer timeline events 1. Per-port EOI signer config - New `eoi_signers` system_settings key (JSON: { developer, approver }, each `{ name, email }`). Settings UI exposes it under Admin → Settings. - getPortEoiSigners(portId) reads the setting with a typed validator; falls back to the legacy David Mizrahi / Abbie May defaults if the row is missing or malformed (so older ports keep working until an admin saves a value). - Both EOI generation pathways now read from the helper instead of hardcoded constants: * documenso-template path (generateAndSignViaDocumensoTemplate) * in-app PDF-fill path (generateAndSignViaInApp) 2. Timeline upgrades The interest detail Activity tab now distinguishes the new automation events that arrived with sessions 1+2: - Stage auto-advances (userId='system') get a small "Auto" pill and carry their reason into the description (e.g. "Stage advanced to EOI Signed (auto-advanced — EOI signed via Documenso)"). - outcome_set events show "Marked as Won" / "Marked as Lost — went to another marina" with optional reason; trophy/X icons. - outcome_cleared events show "Reopened to {stage}" with a refresh icon. - Document events humanized: "Document 'X' fully signed" instead of "Document X: completed". - Stage labels run through stageLabel() so the timeline shows the human label, not the enum key. - Timestamps switched to relative-time with full-date tooltip. - "by system" is rendered plainly (no longer the literal user-id). tsc clean. vitest 832/832 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:19:55 +02:00
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import type { EoiContext } from '@/lib/services/eoi-context';
export interface DocumensoTemplatePayload {
title: string;
externalId: string;
meta: {
message: string;
subject: string;
redirectUrl: string;
distributionMethod: 'NONE' | 'EMAIL';
};
formValues: {
Name: string;
Email: string;
Address: string;
'Yacht Name': string;
Length: string;
Width: string;
Draft: string;
'Berth Number': string;
Lease_10: boolean;
Purchase: boolean;
};
recipients: Array<{
id: number;
name: string;
email: string;
role: 'SIGNER' | 'APPROVER';
signingOrder: number;
}>;
}
export interface DocumensoPayloadOptions {
/** `interestId` used to build `externalId` and Documenso referencing. */
interestId: string;
/** Documenso recipient IDs — come from env vars. */
clientRecipientId: number;
developerRecipientId: number;
approvalRecipientId: number;
/** Hardcoded developer + approver names/emails (legacy). */
developerName?: string;
developerEmail?: string;
approverName?: string;
approverEmail?: string;
/** Redirect URL after signing. Defaults to the app URL. */
redirectUrl?: string;
}
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';
feat(sales): admin-configurable EOI signers + richer timeline events 1. Per-port EOI signer config - New `eoi_signers` system_settings key (JSON: { developer, approver }, each `{ name, email }`). Settings UI exposes it under Admin → Settings. - getPortEoiSigners(portId) reads the setting with a typed validator; falls back to the legacy David Mizrahi / Abbie May defaults if the row is missing or malformed (so older ports keep working until an admin saves a value). - Both EOI generation pathways now read from the helper instead of hardcoded constants: * documenso-template path (generateAndSignViaDocumensoTemplate) * in-app PDF-fill path (generateAndSignViaInApp) 2. Timeline upgrades The interest detail Activity tab now distinguishes the new automation events that arrived with sessions 1+2: - Stage auto-advances (userId='system') get a small "Auto" pill and carry their reason into the description (e.g. "Stage advanced to EOI Signed (auto-advanced — EOI signed via Documenso)"). - outcome_set events show "Marked as Won" / "Marked as Lost — went to another marina" with optional reason; trophy/X icons. - outcome_cleared events show "Reopened to {stage}" with a refresh icon. - Document events humanized: "Document 'X' fully signed" instead of "Document X: completed". - Stage labels run through stageLabel() so the timeline shows the human label, not the enum key. - Timestamps switched to relative-time with full-date tooltip. - "by system" is rendered plainly (no longer the literal user-id). tsc clean. vitest 832/832 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:19:55 +02:00
export interface EoiSignerConfig {
developer: { name: string; email: string };
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 },
};
function isSignerEntry(v: unknown): v is { name: string; email: string } {
return (
!!v &&
typeof v === 'object' &&
typeof (v as Record<string, unknown>).name === 'string' &&
typeof (v as Record<string, unknown>).email === 'string' &&
!!(v as Record<string, string>).name &&
!!(v as Record<string, string>).email
);
}
/** 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. */
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;
}
function formatAddress(address: EoiContext['client']['address']): string {
if (!address) return '';
return [address.street, address.city, address.country].filter(Boolean).join(', ');
}
function buildMessage(context: EoiContext): string {
const greeting = `Dear ${context.client.fullName},`;
const body = `Thank you for your interest in a berth at ${context.port.name}. Please click the link above to sign your LOI.`;
const onBehalf =
context.owner.type === 'company' && context.company
? `\n\nOn behalf of ${context.company.legalName ?? context.company.name} (representing the yacht's owner).`
: '';
const footer = `\n\nBest Regards,\n${context.port.name} Team`;
return `${greeting}\n\n${body}${onBehalf}${footer}`;
}
export function buildDocumensoPayload(
context: EoiContext,
options: DocumensoPayloadOptions,
): DocumensoTemplatePayload {
return {
title: `${context.client.fullName}-EOI-NDA`,
externalId: `loi-${options.interestId}`,
meta: {
message: buildMessage(context),
subject: 'Your LOI is ready to be signed',
redirectUrl: options.redirectUrl ?? DEFAULT_REDIRECT_URL,
distributionMethod: 'NONE',
},
formValues: {
Name: context.client.fullName,
Email: context.client.primaryEmail ?? '',
Address: formatAddress(context.client.address),
feat(eoi): align prerequisites with EOI document structure Match the gate to the actual EOI's structure (Section 2 vs Section 3) so the rep can generate the document the moment they have what they need — and not before. Required (Section 2 — top paragraph): - Client name - Client primary email - Client primary address Optional (Section 3 — left blank when absent): - Linked yacht (name, dimensions) - Linked berth (mooring number) Previously the dialog blocked generation unless yacht AND berth were both linked, which was overzealous — early-stage EOIs are routinely sent before a specific berth is pinned down. - eoi-context.ts: yacht and berth are now nullable in the returned context. The hard ValidationError is now driven by the EOI's Section 2 fields (name/email/address) rather than yacht/berth presence. The owner block falls back to the interest's client when no yacht is linked, so signing parties remain resolvable. - documenso-payload.ts + fill-eoi-form.ts: Section 3 form values render as empty strings when yacht or berth are absent, so the rendered PDF leaves those template inputs blank. - document-templates.ts: yacht.* and berth.* tokens fall back to empty strings; the legacy-fallback catch handler also recognises the new "missing required client details" error. - interests.service.ts: getInterestById now also returns `clientPrimaryEmail` and `clientHasAddress` so the Documents tab can compute the EOI prerequisites checklist client-side without an extra fetch. - eoi-generate-dialog.tsx: prereqs split into two groups visually — Required (with red ✗ when missing) and Optional (with grey – when absent). The Generate button only requires the Required block to pass. A small amber banner surfaces when Required is incomplete so the rep knows where to add the missing data. Tests: 835/835 pass. Replaces the obsolete "throws on missing yacht/ berth" tests with parity coverage for the new behaviour ("builds a valid context when yacht/berth missing", "throws when client email/ address missing"). Adds a payload test for the empty-Section-3 case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 03:11:14 +02:00
// 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 ?? '',
'Berth Number': context.berth?.mooringNumber ?? '',
Lease_10: false,
Purchase: true,
},
recipients: [
{
id: options.clientRecipientId,
name: context.client.fullName,
email: context.client.primaryEmail ?? '',
role: 'SIGNER',
signingOrder: 1,
},
{
id: options.developerRecipientId,
name: options.developerName ?? DEFAULT_DEVELOPER_NAME,
email: options.developerEmail ?? DEFAULT_DEVELOPER_EMAIL,
role: 'SIGNER',
signingOrder: 2,
},
{
id: options.approvalRecipientId,
name: options.approverName ?? DEFAULT_APPROVER_NAME,
email: options.approverEmail ?? DEFAULT_APPROVER_EMAIL,
role: 'APPROVER',
signingOrder: 3,
},
],
};
}