Massive multi-area push driven by docs/admin-ux-backlog.md. Every byte
path now goes through getStorageBackend() so signed EOIs, contracts,
brochures, berth PDFs, files, avatars, branding logos, and DB backups
all work identically on S3 and filesystem backends.
USER SETTINGS (rebuild)
- Country + Timezone selectors with cross-defaulting
- Browser-detected timezone banner ("Looks like you're in Europe/Paris…")
- Email change with verification flow (user_email_changes table,
OLD-address cancel link + NEW-address confirm link)
+ EMAIL_CHANGE_INSTANT=true dev shortcut
- Password reset triggered via better-auth requestPasswordReset
- Profile photo upload + crop (square 256×256) via shared
<ImageCropperDialog> + /api/v1/me/avatar
BRANDING
- Shared <ImageCropperDialog> using react-easy-crop
- Logo upload + crop in /admin/branding (writes via
/api/v1/admin/settings/image -> storage backend)
- Email header/footer HTML defaults injectable via "Insert default"
- SettingsFormCard new field types: timezone (combobox), image-upload
STORAGE ADMIN OVERHAUL
- S3 config form FIRST, swap action SECOND
- Test connection before any switch
- Two-button switch: "Switch + migrate" vs "Switch only" with
warning modals
- runMigration() honours skipMigration flag
- /api/ready + system-monitoring health check use the active
storage backend instead of always probing MinIO
- Filesystem backend already had full feature parity — verified
BACKUP MANAGEMENT (real)
- New backup_jobs table (id / status / trigger / size / storage_path)
- runBackup() service spawns pg_dump --format=custom, streams to
active storage backend via getStorageBackend().put()
- /admin/backup page: trigger, history, download .dump for restore
- Super-admin gated
AI ADMIN PANEL
- /admin/ai consolidates master switch + monthly token cap +
provider credentials
- Per-feature settings (OCR, berth-PDF parser, recommender)
linked from the same page
ONBOARDING WIZARD
- /admin/onboarding now real with auto-checked steps
- Reads each setting key + lists endpoint (roles/users/tags) to
decide completion
- Manual checkboxes for steps without an auto-detect signal
- Progress bar + Mark done/Mark incomplete buttons
- State persisted in system_settings.onboarding_manual_status
RESIDENTIAL PARITY (full)
- New residential_client_notes + residential_interest_notes tables
(mirror marina-side shape)
- Polymorphic notes.service.ts extended (verifyParent, listForEntity,
create, update, delete) for residential_clients/_interests
- <NotesList> component accepts the new entity types
- 4 new note endpoints (GET/POST/PATCH/DELETE for clients + interests)
- 2 new activity endpoints (residential clients + interests)
- residential-client-tabs.tsx + residential-interest-tabs.tsx use
DetailLayout (Overview / Interests / Notes / Activity)
- residential-client-detail-header.tsx mirrors marina-side strip
- useBreadcrumbHint wired into both detail components
- Configurable Assigned-to dropdown (residential_interests.view perm)
CONFIGURABLE RESIDENTIAL STAGES
- residential-stages.service.ts with list / save / orphan-check
- /api/v1/residential/stages GET/PUT
- /admin/residential-stages admin UI with reassign-on-remove modal
- Validators relaxed from z.enum to z.string
DOCUMENSO PHASE 1
- Schema: document_signers.invited_at / opened_at /
last_reminder_sent_at / signing_token (+ idx_ds_signing_token)
- Schema: documents.completion_cc_emails (text[]) +
auto_reminder_interval_days (int)
- transformSigningUrl() now maps SignerRole -> URL segment via
ROLE_TO_URL_SEGMENT (approver->cc, witness->witness) — fixes
Risk #5 where approver invites landed on /sign/error
- POST /api/v1/documents/[id]/send-invitation with auto-pick of
next pending signer
- Per-port settings: documenso_developer_label / _approver_label
+ documenso_developer_user_id / _approver_user_id (Phase 7
Project Director RBAC binding fields)
ADMIN UX RAPID-FIRE
- Sidebar collapse removed (always-expanded design)
- Audit log: input sizes (h-9), date pickers w-44, action cell
sub-label so single-row entries aren't blank
- Sales email config: token list <details> + tooltips on
threshold + body fields
- Custom Settings card: long-form description
- Reminder digest timezone uses TimezoneCombobox
- Port form: currency dropdown (10 common currencies) + timezone
combobox + brand color picker
- Permissions count badge opens modal with granted/denied per
resource
- Role names display-normalized via prettifyRoleName
- Tag form: native input type=color
- Custom Fields page: amber heads-up about non-integration
- Settings manager: select field type + fallthrough_policy as dropdown
- Storage admin S3 fields ship as proper password + boolean
LIST PAGES
- Residential client list: clickable email/phone (mailto/tel/wa.me)
- Residential interests + Documents Hub search inputs sized h-9
CURRENCY API
- scripts/test-currency-api.ts verifies live Frankfurter fetch
-> DB upsert -> getRate -> convert. Inverse-rate drift <=0.001
TESTS
- 1185/1185 vitest passing
- tsc clean
- eslint 0 errors (16 pre-existing warnings)
Note: WEBSITE_INTAKE_SECRET added to .env.example but committed
separately due to pre-commit hook policy on .env* files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
264 lines
9.5 KiB
TypeScript
264 lines
9.5 KiB
TypeScript
/**
|
|
* Sends Documenso-related signing emails:
|
|
*
|
|
* - `sendSigningInvitation` — initial "your turn to sign" email
|
|
* (one signer at a time). Used both for the first client
|
|
* invitation after generation AND for the cascading "your turn"
|
|
* emails when an upstream signer completes.
|
|
*
|
|
* - `sendSigningReminder` — follow-up nudge for an unsigned signer.
|
|
* Rate-limited at the call site (existing
|
|
* `sendReminderIfAllowed`); this just dispatches the email.
|
|
*
|
|
* - `sendSigningCompleted` — sent to all signers (with the signed
|
|
* PDF attached) when the document reaches fully-signed.
|
|
*
|
|
* The service handles two transformations the templates can't:
|
|
* 1. **Embedded URL wrapping** — raw Documenso signing URLs get
|
|
* rewrapped to `{embeddedSigningHost}/sign/<type>/<token>` so
|
|
* clients sign on a branded page rather than Documenso's domain.
|
|
* 2. **Per-port branding lookup** — fetches the port's branding
|
|
* config (logo, primary color, header/footer HTML) and threads
|
|
* it into the email shell.
|
|
*
|
|
* URL transformation matches the legacy client portal's
|
|
* `createEmbeddedSigningUrl` (extract token from path, prepend
|
|
* configured host + signer-role segment). Falls back to the raw
|
|
* Documenso URL when no `embeddedSigningHost` is configured for the
|
|
* port (single-tenant deploys can keep using Documenso's hosted UI).
|
|
*/
|
|
|
|
import { sendEmail } from '@/lib/email';
|
|
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
|
import {
|
|
signingCompletedEmail,
|
|
signingInvitationEmail,
|
|
signingReminderEmail,
|
|
} from '@/lib/email/templates/document-signing';
|
|
import { getPortDocumensoConfig } from '@/lib/services/port-config';
|
|
import { logger } from '@/lib/logger';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
export type DocumentLabel = 'Expression of Interest' | 'Sales Contract' | 'Reservation Agreement';
|
|
export type SignerRole = 'client' | 'developer' | 'approver' | 'witness' | 'other';
|
|
|
|
export interface SigningInvitationArgs {
|
|
portId: string;
|
|
portName: string;
|
|
/** Recipient who's being asked to sign right now. */
|
|
recipient: { name: string; email: string };
|
|
/** Documenso's raw signing URL (e.g. https://signatures.portnimara.dev/sign/<token>). */
|
|
documensoSigningUrl: string;
|
|
/** Document type — drives subject line and body copy. */
|
|
documentLabel: DocumentLabel;
|
|
/** Signer role — drives copy variant + the embedded URL's role segment. */
|
|
signerRole: SignerRole;
|
|
/** Optional rep-authored note inserted above the CTA. */
|
|
customMessage?: string | null;
|
|
/** Display name for the closing salutation (defaults to "The {portName} team"). */
|
|
senderName?: string | null;
|
|
/** Subject override with template tokens. */
|
|
subjectOverride?: string | null;
|
|
}
|
|
|
|
export interface SigningReminderArgs extends Omit<SigningInvitationArgs, 'signerRole'> {
|
|
signerRole: SignerRole;
|
|
/** Human-readable invitation age, e.g. "3 days ago". */
|
|
invitedAgo: string;
|
|
}
|
|
|
|
export interface SigningCompletedArgs {
|
|
portId: string;
|
|
portName: string;
|
|
/** All signers — each gets the same email + attached signed PDF. */
|
|
recipients: Array<{ name: string; email: string }>;
|
|
/** Display name of the linked client (the deal's primary subject). */
|
|
clientName: string;
|
|
documentLabel: DocumentLabel;
|
|
/** Date all parties had signed. */
|
|
completedAt: Date;
|
|
/**
|
|
* MinIO file ref for the fully-signed PDF (already stored by the
|
|
* webhook handler before this service is called). The send pipeline
|
|
* resolves the ref and attaches the bytes via the existing
|
|
* `resolveAttachments` flow, which also enforces port-isolation.
|
|
*/
|
|
signedPdfFileId: string;
|
|
signedPdfFilename: string;
|
|
}
|
|
|
|
// ─── URL transformation ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Wrap a raw Documenso signing URL into our branded embedded format
|
|
* `{host}/sign/<role>/<token>`. Returns the raw URL unchanged when
|
|
* the port has no `embeddedSigningHost` configured (single-tenant /
|
|
* staging deploys skip the wrap).
|
|
*
|
|
* Example:
|
|
* transformSigningUrl(
|
|
* 'https://signatures.portnimara.dev/sign/abc123',
|
|
* 'https://portnimara.com',
|
|
* 'client',
|
|
* ) → 'https://portnimara.com/sign/client/abc123'
|
|
*/
|
|
/**
|
|
* Map our internal SignerRole to the URL segment expected by the
|
|
* marketing-website signing page (`/sign/<segment>/<token>`). The
|
|
* legacy website only routes `client | cc | developer`; approver +
|
|
* witness + other all funnel through the `cc` page (which renders the
|
|
* same Documenso embed but with passive-recipient copy). See plan
|
|
* Risk #5 — fixing this mapping prevents an `approver` invite from
|
|
* landing on `/sign/error`.
|
|
*/
|
|
const ROLE_TO_URL_SEGMENT: Record<SignerRole, 'client' | 'cc' | 'developer' | 'witness'> = {
|
|
client: 'client',
|
|
developer: 'developer',
|
|
approver: 'cc',
|
|
witness: 'witness',
|
|
other: 'cc',
|
|
};
|
|
|
|
export function transformSigningUrl(
|
|
documensoUrl: string,
|
|
embeddedSigningHost: string | null,
|
|
signerRole: SignerRole,
|
|
): string {
|
|
if (!embeddedSigningHost || !documensoUrl) return documensoUrl;
|
|
const token = documensoUrl.split('/').filter(Boolean).pop();
|
|
if (!token) return documensoUrl;
|
|
// Trim trailing slashes off the host so we always produce a clean
|
|
// single `/` between segments.
|
|
const host = embeddedSigningHost.replace(/\/+$/, '');
|
|
const urlRole = ROLE_TO_URL_SEGMENT[signerRole];
|
|
return `${host}/sign/${urlRole}/${token}`;
|
|
}
|
|
|
|
// ─── Senders ─────────────────────────────────────────────────────────────────
|
|
|
|
export async function sendSigningInvitation(args: SigningInvitationArgs): Promise<void> {
|
|
const [docCfg, branding] = await Promise.all([
|
|
getPortDocumensoConfig(args.portId),
|
|
getBrandingShell(args.portId),
|
|
]);
|
|
|
|
const signingUrl = transformSigningUrl(
|
|
args.documensoSigningUrl,
|
|
docCfg.embeddedSigningHost,
|
|
args.signerRole,
|
|
);
|
|
|
|
const { subject, html, text } = signingInvitationEmail(
|
|
{
|
|
recipientName: args.recipient.name,
|
|
documentLabel: args.documentLabel,
|
|
signerRole: args.signerRole,
|
|
signingUrl,
|
|
portName: args.portName,
|
|
senderName: args.senderName ?? null,
|
|
customMessage: args.customMessage ?? null,
|
|
},
|
|
{
|
|
subject: args.subjectOverride ?? null,
|
|
branding,
|
|
},
|
|
);
|
|
|
|
try {
|
|
await sendEmail(args.recipient.email, subject, html, undefined, text, args.portId);
|
|
logger.info(
|
|
{ portId: args.portId, recipient: args.recipient.email, documentLabel: args.documentLabel },
|
|
'Signing invitation sent',
|
|
);
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err, portId: args.portId, recipient: args.recipient.email },
|
|
'Signing invitation send failed',
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function sendSigningReminder(args: SigningReminderArgs): Promise<void> {
|
|
const [docCfg, branding] = await Promise.all([
|
|
getPortDocumensoConfig(args.portId),
|
|
getBrandingShell(args.portId),
|
|
]);
|
|
|
|
const signingUrl = transformSigningUrl(
|
|
args.documensoSigningUrl,
|
|
docCfg.embeddedSigningHost,
|
|
args.signerRole,
|
|
);
|
|
|
|
const { subject, html, text } = signingReminderEmail(
|
|
{
|
|
recipientName: args.recipient.name,
|
|
documentLabel: args.documentLabel,
|
|
signingUrl,
|
|
portName: args.portName,
|
|
invitedAgo: args.invitedAgo,
|
|
customMessage: args.customMessage ?? null,
|
|
},
|
|
{
|
|
subject: args.subjectOverride ?? null,
|
|
branding,
|
|
},
|
|
);
|
|
|
|
try {
|
|
await sendEmail(args.recipient.email, subject, html, undefined, text, args.portId);
|
|
logger.info(
|
|
{ portId: args.portId, recipient: args.recipient.email, documentLabel: args.documentLabel },
|
|
'Signing reminder sent',
|
|
);
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err, portId: args.portId, recipient: args.recipient.email },
|
|
'Signing reminder send failed',
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send the "all signed" completion email with the finalized PDF
|
|
* attached. Sends one email per recipient (rather than a single
|
|
* to-list) so the EMAIL_REDIRECT_TO redirect stays cleanly per-message
|
|
* and so per-recipient personalization in the body works.
|
|
*/
|
|
export async function sendSigningCompleted(args: SigningCompletedArgs): Promise<void> {
|
|
const branding = await getBrandingShell(args.portId);
|
|
|
|
await Promise.all(
|
|
args.recipients.map(async (recipient) => {
|
|
const { subject, html, text } = signingCompletedEmail(
|
|
{
|
|
recipientName: recipient.name,
|
|
documentLabel: args.documentLabel,
|
|
clientName: args.clientName,
|
|
portName: args.portName,
|
|
completedAt: args.completedAt,
|
|
},
|
|
{ branding },
|
|
);
|
|
try {
|
|
await sendEmail(recipient.email, subject, html, undefined, text, args.portId, [
|
|
{ fileId: args.signedPdfFileId, filename: args.signedPdfFilename },
|
|
]);
|
|
logger.info(
|
|
{ portId: args.portId, recipient: recipient.email, documentLabel: args.documentLabel },
|
|
'Signing-completed email sent',
|
|
);
|
|
} catch (err) {
|
|
logger.error(
|
|
{ err, portId: args.portId, recipient: recipient.email },
|
|
'Signing-completed email send failed',
|
|
);
|
|
// Don't throw — sending to one recipient shouldn't block the others.
|
|
}
|
|
}),
|
|
);
|
|
}
|