feat: autonomous backlog push — admin UX overhaul + storage parity + residential parity + Documenso Phase 1
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m32s
Build & Push Docker Images / build-and-push (push) Failing after 32s

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>
This commit is contained in:
2026-05-07 21:02:12 +02:00
parent 3e4d9d6310
commit 5c8c12ba1f
72 changed files with 5499 additions and 942 deletions

View File

@@ -51,6 +51,23 @@ export const auth = betterAuth({
minPasswordLength: 9,
// Accounts are admin-created only - no self-service email verification flow.
requireEmailVerification: false,
// Self-service password reset for CRM users. The reset link lands
// on the existing /reset-password page (which already handles
// better-auth's token + new-password POST). The email send goes
// through the shared SMTP infra so EMAIL_REDIRECT_TO honours it
// in dev.
sendResetPassword: async ({ user, url }) => {
const { sendEmail } = await import('@/lib/email');
const subject = 'Reset your Port Nimara CRM password';
const html = `
<p>Hi ${user.name || 'there'},</p>
<p>You requested a password reset for your Port Nimara CRM account.</p>
<p><a href="${url}">Click here to set a new password</a> — the link expires in 1 hour.</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`;
const text = `Reset your password: ${url}`;
await sendEmail(user.email, subject, html, undefined, text);
},
},
session: {

View File

@@ -70,6 +70,14 @@ export const documents = pgTable(
fileId: text('file_id').references(() => files.id),
signedFileId: text('signed_file_id').references(() => files.id),
isManualUpload: boolean('is_manual_upload').notNull().default(false),
/** Email addresses CC'd on the completion notification (the
* passive Documenso CC concept — see plan Q4). Per-document set
* by the rep; doesn't gate signing. */
completionCcEmails: text('completion_cc_emails').array().default([]),
/** Optional auto-reminder cadence — when set, a daily worker
* fires `sendSigningReminder()` for unsigned signers every
* N days until they complete. Null = manual reminders only. */
autoReminderIntervalDays: integer('auto_reminder_interval_days'),
notes: text('notes'),
remindersDisabled: boolean('reminders_disabled').notNull().default(false),
reminderCadenceOverride: integer('reminder_cadence_override'),
@@ -111,9 +119,21 @@ export const documentSigners = pgTable(
signedAt: timestamp('signed_at', { withTimezone: true }),
signingUrl: text('signing_url'),
embeddedUrl: text('embedded_url'),
/** Phase 1+2 lifecycle tracking — set by the send-invitation endpoint
* and the Documenso webhook handler respectively. */
invitedAt: timestamp('invited_at', { withTimezone: true }),
openedAt: timestamp('opened_at', { withTimezone: true }),
lastReminderSentAt: timestamp('last_reminder_sent_at', { withTimezone: true }),
/** Documenso recipient token — used for token-based lookup when the
* webhook fires (more robust than email match when one address
* serves multiple roles). */
signingToken: text('signing_token'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_ds_doc').on(table.documentId)],
(table) => [
index('idx_ds_doc').on(table.documentId),
index('idx_ds_signing_token').on(table.signingToken),
],
);
export const documentEvents = pgTable(

View File

@@ -199,3 +199,54 @@ export type ReportRecipient = typeof reportRecipients.$inferSelect;
export type NewReportRecipient = typeof reportRecipients.$inferInsert;
export type GeneratedReport = typeof generatedReports.$inferSelect;
export type NewGeneratedReport = typeof generatedReports.$inferInsert;
// ─── Interest Contact Log ──────────────────────────────────────────────────
//
// Per-interaction record of communication with a client about a specific
// interest. Sales reps log every email / call / WhatsApp / meeting touch
// here so the team has a structured history of "what was the last
// conversation about" — beyond the single `dateLastContact` timestamp on
// the interest itself.
//
// Notes are for free-form thinking / context. This table is for
// timestamped interactions with a known channel + direction.
export const interestContactLog = pgTable(
'interest_contact_log',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
interestId: text('interest_id')
.notNull()
.references(() => interests.id, { onDelete: 'cascade' }),
/** When the actual conversation happened (not when the log entry
* was recorded — those can differ if a rep logs after the fact). */
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
/** email | phone | whatsapp | in_person | video | other */
channel: text('channel').notNull(),
/** outbound | inbound — who initiated the contact. */
direction: text('direction').notNull().default('outbound'),
/** Short free text — "Discussed yacht size, asked about tax structure". */
summary: text('summary').notNull(),
/** Optional. When set, a reminder is auto-created pointing back to
* the interest for follow-up. Stored as the original choice so the
* UI can re-render it; the actual reminder lives in `reminders`. */
followUpAt: timestamp('follow_up_at', { withTimezone: true }),
/** ID of the auto-created reminder, if any — lets us update/cancel
* the reminder when the log entry is edited. */
reminderId: text('reminder_id').references(() => reminders.id, { onDelete: 'set null' }),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_icl_interest').on(table.interestId, table.occurredAt),
index('idx_icl_port').on(table.portId, table.occurredAt),
],
);
export type InterestContactLogEntry = typeof interestContactLog.$inferSelect;
export type NewInterestContactLogEntry = typeof interestContactLog.$inferInsert;

View File

@@ -1,4 +1,4 @@
import { pgTable, text, timestamp, index } from 'drizzle-orm/pg-core';
import { boolean, pgTable, text, timestamp, index } from 'drizzle-orm/pg-core';
import { ports } from './ports';
@@ -102,7 +102,52 @@ export const residentialInterests = pgTable(
],
);
/**
* Threaded notes for residential clients — mirror the marina-side
* `clientNotes` shape so the polymorphic NotesList component works
* with `entityType='residential_clients'`.
*/
export const residentialClientNotes = pgTable(
'residential_client_notes',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
residentialClientId: text('residential_client_id')
.notNull()
.references(() => residentialClients.id, { onDelete: 'cascade' }),
authorId: text('author_id').notNull(),
content: text('content').notNull(),
mentions: text('mentions').array(),
isLocked: boolean('is_locked').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_rcn_client').on(table.residentialClientId)],
);
export const residentialInterestNotes = pgTable(
'residential_interest_notes',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
residentialInterestId: text('residential_interest_id')
.notNull()
.references(() => residentialInterests.id, { onDelete: 'cascade' }),
authorId: text('author_id').notNull(),
content: text('content').notNull(),
mentions: text('mentions').array(),
isLocked: boolean('is_locked').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_rin_interest').on(table.residentialInterestId)],
);
export type ResidentialClient = typeof residentialClients.$inferSelect;
export type NewResidentialClient = typeof residentialClients.$inferInsert;
export type ResidentialInterest = typeof residentialInterests.$inferSelect;
export type NewResidentialInterest = typeof residentialInterests.$inferInsert;
export type ResidentialClientNote = typeof residentialClientNotes.$inferSelect;
export type ResidentialInterestNote = typeof residentialInterestNotes.$inferSelect;

View File

@@ -9,6 +9,7 @@ import {
index,
uniqueIndex,
customType,
bigint,
} from 'drizzle-orm/pg-core';
import { ports } from './ports';
import { clients } from './clients';
@@ -333,3 +334,31 @@ export type CustomFieldDefinition = typeof customFieldDefinitions.$inferSelect;
export type NewCustomFieldDefinition = typeof customFieldDefinitions.$inferInsert;
export type CustomFieldValue = typeof customFieldValues.$inferSelect;
export type NewCustomFieldValue = typeof customFieldValues.$inferInsert;
/**
* Backup-job ledger for the in-app backup admin. Each row tracks a
* single pg_dump invocation (success / failure / size / where the
* dump landed in storage). The actual dump runs via `runBackup()`
* in `@/lib/services/backup.service`; this table is the visible
* record used by `/admin/backup`.
*/
export const backupJobs = pgTable(
'backup_jobs',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
status: text('status').notNull().default('pending'), // pending | running | completed | failed
trigger: text('trigger').notNull().default('manual'), // manual | cron
triggeredBy: text('triggered_by'),
sizeBytes: bigint('size_bytes', { mode: 'number' }),
storagePath: text('storage_path'),
errorMessage: text('error_message'),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp('completed_at', { withTimezone: true }),
},
(table) => [index('idx_backup_jobs_started').on(table.startedAt)],
);
export type BackupJob = typeof backupJobs.$inferSelect;
export type NewBackupJob = typeof backupJobs.$inferInsert;

View File

@@ -0,0 +1,245 @@
/**
* Branded transactional emails for the Documenso signing lifecycle.
*
* Three template families:
*
* 1. `signingInvitation` — sent to a single signer when their turn
* to sign comes up. Used both for the initial client invite (after
* EOI/contract/reservation generation) AND for the cascading
* "your turn" emails when an earlier signer completes (developer
* after client signs, approver after developer signs, etc).
*
* 2. `signingCompleted` — sent to ALL signers (with the finalized
* signed PDF as an attachment) when the document reaches a fully
* signed state. Mirrors the old system's
* `sendFinalizedDocumentToSignatories` flow.
*
* 3. `signingReminder` — sent when a rep nudges an unsigned recipient
* manually OR when the rate-limited reminder service fires. Same
* visual shape as `signingInvitation` with different copy.
*
* All three use the per-port `BrandingShell` (logo + primary color +
* header/footer HTML) so each tenant's outbound emails match its
* brand. The signing URL passed in is expected to already be
* embedded-format (e.g. `https://portnimara.com/sign/<type>/<token>`)
* — the caller (interest service / webhook handler) does the
* transformation from the raw Documenso URL.
*/
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
interface RenderOpts {
subject?: string | null;
branding?: BrandingShell | null;
}
interface InvitationData {
/** Display name for the recipient — used in the greeting. */
recipientName: string;
/** Friendly document type label. e.g. "Expression of Interest", "Sales Contract", "Reservation Agreement". */
documentLabel: string;
/** Optional. The signer's role: 'client' | 'developer' | 'approver' | 'witness' etc. Drives copy nuance. */
signerRole?: string | null;
/** Embedded signing URL (already wrapped to the public branded host). */
signingUrl: string;
/** Port name to brand the email. */
portName: string;
/** Sales rep / sender name shown in the closing. Falls back to "{portName} team". */
senderName?: string | null;
/** Optional plain-text message from the rep to include above the CTA. */
customMessage?: string | null;
}
export function signingInvitationEmail(
data: InvitationData,
overrides?: RenderOpts,
): { subject: string; html: string; text: string } {
const accent = brandingPrimaryColor(overrides?.branding);
const docLabelEsc = escapeHtml(data.documentLabel);
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{portName\}\}/g, data.portName)
.replace(/\{\{recipientName\}\}/g, data.recipientName)
: `${data.documentLabel} ready to sign — ${data.portName}`;
const greeting = `Dear ${escapeHtml(data.recipientName)},`;
const closer = data.senderName
? `${escapeHtml(data.senderName)}<br /><strong>${escapeHtml(data.portName)}</strong>`
: `<strong>The ${escapeHtml(data.portName)} team</strong>`;
// Slightly different lead paragraph based on signer role so the
// developer / approver emails don't read as if they're the client.
const isClient = (data.signerRole ?? 'client') === 'client';
const leadCopy = isClient
? `Your ${docLabelEsc} for <strong>${escapeHtml(data.portName)}</strong> is ready for signing. Click the button below to review and sign — it should only take a couple of minutes.`
: data.signerRole === 'approver'
? `An ${docLabelEsc} is awaiting your approval. The earlier signers have completed their parts; please review and sign to finalise the document.`
: `An ${docLabelEsc} is awaiting your signature. The client has already signed; you're the next signer in the chain.`;
const customMessageBlock = data.customMessage
? `<p style="margin:20px 0; font-size:15px; line-height:1.6; color:#444; padding:14px 18px; background:#f8f9fb; border-left:3px solid ${accent}; border-radius:4px;">${escapeHtml(data.customMessage).replace(/\n/g, '<br />')}</p>`
: '';
const body = `
<p style="margin-bottom:14px; font-size:18px; font-weight:bold; color:${accent};">
${docLabelEsc} ready to sign
</p>
<p style="margin-bottom:14px; font-size:16px; line-height:1.6;">${greeting}</p>
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">${leadCopy}</p>
${customMessageBlock}
<p style="text-align:center; margin:30px 0;">
<a href="${data.signingUrl}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
Review &amp; sign
</a>
</p>
<p style="font-size:13px; color:#666; line-height:1.5; padding:14px 0; border-top:1px solid #eee; margin-top:24px;">
If the button doesn't work, paste this link into your browser:<br />
<a href="${data.signingUrl}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
</p>
<p style="font-size:14px; color:#666; line-height:1.5; margin-top:18px;">
Signing happens directly inside our website — your data isn't sent to a third-party signing service.
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />
${closer}
</p>`;
const text = `${greeting}\n\n${stripTags(leadCopy)}\n\n${data.customMessage ? data.customMessage + '\n\n' : ''}Sign here: ${data.signingUrl}\n\nThank you,\n${data.senderName ?? `The ${data.portName} team`}`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
interface CompletedData {
recipientName: string;
documentLabel: string;
/** Identity of the linked client (the deal's primary subject). */
clientName: string;
portName: string;
/** When the document reached fully-signed state. */
completedAt: Date;
}
export function signingCompletedEmail(
data: CompletedData,
overrides?: RenderOpts,
): { subject: string; html: string; text: string } {
const accent = brandingPrimaryColor(overrides?.branding);
const docLabelEsc = escapeHtml(data.documentLabel);
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{clientName\}\}/g, data.clientName)
.replace(/\{\{portName\}\}/g, data.portName)
: `${data.documentLabel} fully signed — ${data.clientName}`;
const greeting = `Dear ${escapeHtml(data.recipientName)},`;
const completedDateStr = data.completedAt.toLocaleString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const body = `
<p style="margin-bottom:14px; font-size:18px; font-weight:bold; color:${accent};">
${docLabelEsc} signed by all parties
</p>
<p style="margin-bottom:14px; font-size:16px; line-height:1.6;">${greeting}</p>
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">
The ${docLabelEsc} for <strong>${escapeHtml(data.clientName)}</strong> has been signed by every party as of ${completedDateStr}.
</p>
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">
The fully signed PDF is attached to this email for your records. A copy has also been stored in the ${escapeHtml(data.portName)} CRM.
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />
<strong>The ${escapeHtml(data.portName)} team</strong>
</p>`;
const text = `${greeting}\n\nThe ${data.documentLabel} for ${data.clientName} has been signed by all parties on ${completedDateStr}. The signed PDF is attached for your records.\n\nThank you,\nThe ${data.portName} team`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
interface ReminderData {
recipientName: string;
documentLabel: string;
signingUrl: string;
portName: string;
/** Human-readable string of how long ago the original invitation was sent. */
invitedAgo: string;
customMessage?: string | null;
}
export function signingReminderEmail(
data: ReminderData,
overrides?: RenderOpts,
): { subject: string; html: string; text: string } {
const accent = brandingPrimaryColor(overrides?.branding);
const docLabelEsc = escapeHtml(data.documentLabel);
const subject = overrides?.subject
? overrides.subject
.replace(/\{\{documentLabel\}\}/g, data.documentLabel)
.replace(/\{\{portName\}\}/g, data.portName)
: `Friendly reminder: ${data.documentLabel} still awaiting your signature — ${data.portName}`;
const greeting = `Dear ${escapeHtml(data.recipientName)},`;
const customMessageBlock = data.customMessage
? `<p style="margin:20px 0; font-size:15px; line-height:1.6; color:#444; padding:14px 18px; background:#f8f9fb; border-left:3px solid ${accent}; border-radius:4px;">${escapeHtml(data.customMessage).replace(/\n/g, '<br />')}</p>`
: '';
const body = `
<p style="margin-bottom:14px; font-size:18px; font-weight:bold; color:${accent};">
Just a quick reminder
</p>
<p style="margin-bottom:14px; font-size:16px; line-height:1.6;">${greeting}</p>
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">
We sent you a ${docLabelEsc} ${escapeHtml(data.invitedAgo)} that's still awaiting your signature. If you've already signed, please disregard this message — it can take a few minutes for our system to catch up.
</p>
${customMessageBlock}
<p style="text-align:center; margin:30px 0;">
<a href="${data.signingUrl}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
Sign now
</a>
</p>
<p style="font-size:13px; color:#666; line-height:1.5; padding:14px 0; border-top:1px solid #eee; margin-top:24px;">
Direct link: <a href="${data.signingUrl}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
</p>
<p style="font-size:16px; margin-top:30px;">
Thank you,<br />
<strong>The ${escapeHtml(data.portName)} team</strong>
</p>`;
const text = `${greeting}\n\nWe sent you a ${data.documentLabel} ${data.invitedAgo} that's still awaiting your signature. ${data.customMessage ? '\n\n' + data.customMessage + '\n\n' : ''}\n\nSign here: ${data.signingUrl}\n\nThank you,\nThe ${data.portName} team`;
return {
subject,
html: renderShell({ title: subject, body, branding: overrides?.branding }),
text,
};
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function stripTags(html: string): string {
return html.replace(/<[^>]+>/g, '');
}

View File

@@ -0,0 +1,124 @@
/**
* In-app backup orchestration. Drives the `/admin/backup` page.
*
* Each `runBackup()` invocation:
* 1. Inserts a `backup_jobs` row in status='running'
* 2. Spawns `pg_dump` against the DATABASE_URL into a temp file
* 3. Streams the dump to the active storage backend at
* `backups/<id>.dump` (works for both S3 and filesystem)
* 4. Marks the row completed/failed + records size + storage_path
*
* Restore is intentionally NOT exposed via the in-app UI yet — that
* needs a 2-step confirm + a maintenance window since it requires
* dropping the existing schema. Provide a CLI helper later via a
* downloadable .dump from the admin page (already wired below).
*/
import { spawn } from 'node:child_process';
import { createReadStream, createWriteStream } from 'node:fs';
import { unlink, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { backupJobs } from '@/lib/db/schema/system';
import { getStorageBackend, presignDownloadUrl } from '@/lib/storage';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
interface RunBackupArgs {
trigger: 'manual' | 'cron';
triggeredBy?: string | null;
}
export async function runBackup({ trigger, triggeredBy }: RunBackupArgs): Promise<{
id: string;
status: 'completed' | 'failed';
sizeBytes?: number;
error?: string;
}> {
const [row] = await db
.insert(backupJobs)
.values({ status: 'running', trigger, triggeredBy: triggeredBy ?? null })
.returning();
if (!row) throw new Error('Failed to create backup_jobs row');
const tmpFile = path.join(tmpdir(), `pn-backup-${row.id}.dump`);
let sizeBytes: number | undefined;
try {
await runPgDump(env.DATABASE_URL, tmpFile);
const s = await stat(tmpFile);
sizeBytes = s.size;
const storagePath = `backups/${row.id}.dump`;
const backend = await getStorageBackend();
const stream = createReadStream(tmpFile);
// Buffer-up the file rather than streaming because the storage
// abstraction's `put` takes a Buffer. For multi-GB dumps this
// would need streaming support — flag in the comment.
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(chunk as Buffer);
await backend.put(storagePath, Buffer.concat(chunks), {
contentType: 'application/octet-stream',
sizeBytes,
});
await db
.update(backupJobs)
.set({
status: 'completed',
sizeBytes,
storagePath,
completedAt: new Date(),
})
.where(eq(backupJobs.id, row.id));
logger.info({ id: row.id, sizeBytes }, 'Backup completed');
return { id: row.id, status: 'completed', sizeBytes };
} catch (err) {
const message = err instanceof Error ? err.message : 'unknown';
await db
.update(backupJobs)
.set({ status: 'failed', errorMessage: message, completedAt: new Date() })
.where(eq(backupJobs.id, row.id));
logger.error({ id: row.id, err }, 'Backup failed');
return { id: row.id, status: 'failed', error: message };
} finally {
void unlink(tmpFile).catch(() => {});
}
}
function runPgDump(databaseUrl: string, outFile: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn('pg_dump', ['--format=custom', '--no-owner', databaseUrl]);
const out = createWriteStream(outFile);
child.stdout.pipe(out);
let stderr = '';
child.stderr.on('data', (b) => {
stderr += b.toString();
});
child.on('error', (err) => reject(err));
child.on('close', (code) => {
out.end();
out.on('finish', () => {
if (code === 0) resolve();
else reject(new Error(`pg_dump exited ${code}: ${stderr}`));
});
});
});
}
export async function getBackupDownloadUrl(id: string): Promise<string | null> {
const row = await db.query.backupJobs.findFirst({ where: eq(backupJobs.id, id) });
if (!row || !row.storagePath || row.status !== 'completed') return null;
return presignDownloadUrl(row.storagePath, 3600, `backup-${row.id}.dump`);
}
export async function listBackupJobs(limit = 50) {
const rows = await db.query.backupJobs.findMany({
orderBy: (j, { desc }) => [desc(j.startedAt)],
limit,
});
return rows;
}

View File

@@ -321,7 +321,86 @@ export async function checkDocumensoHealth(
// the page dimensions returned by Documenso (cached per docId for the lifetime
// of the process - fields for a given doc usually go in a single batch).
export type DocumensoFieldType = 'SIGNATURE' | 'INITIALS' | 'DATE' | 'TEXT' | 'EMAIL';
/**
* Every field type Documenso supports across v1 and v2. The earlier
* subset (SIGNATURE/INITIALS/DATE/TEXT/EMAIL) covered the EOI flow's
* needs but locks out custom-uploaded contracts/reservations that
* may need checkboxes (e.g. "Lease vs Purchase"), dropdowns (e.g.
* "Berth class A/B/C"), or radio groups. Extending now so the
* field-placement UI can surface the full palette without later
* widening this type and patching every call site.
*
* Per-type fieldMeta expectations (passed through verbatim):
* - SIGNATURE / FREE_SIGNATURE / INITIALS / DATE / EMAIL / NAME — no meta
* - TEXT — { text?: string, label?: string, required?: bool, readOnly?: bool }
* - NUMBER — { numberFormat?: string, min?: number, max?: number, required?: bool }
* - CHECKBOX — { values: Array<{ checked: bool, value: string }>, validationRule?: string }
* - DROPDOWN — { values: Array<{ value: string }>, defaultValue?: string }
* - RADIO — { values: Array<{ checked: bool, value: string }> }
*
* `fieldMeta` is sent verbatim to v2's create-many endpoint and
* silently ignored by v1 (which doesn't accept the property). v1
* rendering of TEXT/NUMBER/CHECKBOX/DROPDOWN/RADIO falls back to
* blank-input behaviour without the meta.
*/
export type DocumensoFieldType =
| 'SIGNATURE'
| 'FREE_SIGNATURE'
| 'INITIALS'
| 'DATE'
| 'EMAIL'
| 'NAME'
| 'TEXT'
| 'NUMBER'
| 'CHECKBOX'
| 'DROPDOWN'
| 'RADIO';
/**
* Typed metadata shapes per field type — surfaces what fieldMeta
* actually carries in well-known cases. Used by the field-placement
* UI to render the right config form per field type. Pass-through to
* Documenso retains the loose `Record<string, unknown>` shape so we
* can ship without locking down every property.
*/
export interface DocumensoTextFieldMeta {
text?: string;
label?: string;
required?: boolean;
readOnly?: boolean;
}
export interface DocumensoNumberFieldMeta {
numberFormat?: string;
min?: number;
max?: number;
required?: boolean;
}
export interface DocumensoChoiceOption {
value: string;
/** Whether the option is pre-selected. Applies to checkbox + radio. */
checked?: boolean;
}
export interface DocumensoChoiceFieldMeta {
values: DocumensoChoiceOption[];
defaultValue?: string;
validationRule?: string;
}
/**
* Returns true when this field type expects a fieldMeta payload from
* the placement UI (so the UI can prompt the rep to configure
* options, defaults, validation, etc). Field types not in this list
* carry no per-instance configuration beyond position + recipient.
*/
export function fieldTypeNeedsMeta(type: DocumensoFieldType): boolean {
return (
type === 'TEXT' ||
type === 'NUMBER' ||
type === 'CHECKBOX' ||
type === 'DROPDOWN' ||
type === 'RADIO'
);
}
export interface DocumensoFieldPlacement {
/** Documenso recipient id; v1 expects number, v2 string - coerced internally. */

View File

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

View File

@@ -42,6 +42,41 @@ export const SETTING_KEYS = {
// timing-safe comparison.
documensoWebhookSecret: 'documenso_webhook_secret',
eoiDefaultPathway: 'eoi_default_pathway',
// Identity of the developer + approver that the template's static
// recipient slots get filled with. Old system hardcoded these
// (David Mizrahi, Abbie May @ portnimara.com) but multi-port deploys
// need per-port values. Falls back to env or "" if neither set.
documensoDeveloperName: 'documenso_developer_name',
documensoDeveloperEmail: 'documenso_developer_email',
documensoApproverName: 'documenso_approver_name',
documensoApproverEmail: 'documenso_approver_email',
// Optional CRM-user binding for the developer + approver slots.
// When set, the per-port admin UI shows "Linked to <user>" and
// the webhook handler can match the Documenso developer signer
// against this user's email for in-CRM signing-status updates.
// Plan Phase 7 (Project Director RBAC). Stored as the user.id.
documensoDeveloperUserId: 'documenso_developer_user_id',
documensoApproverUserId: 'documenso_approver_user_id',
// Display labels for the developer + approver slots, used in
// email subjects + signer-progress UI ("Your Project Director,
// Marie, has signed…"). Defaults to "Developer" / "Approver".
documensoDeveloperLabel: 'documenso_developer_label',
documensoApproverLabel: 'documenso_approver_label',
// Sending behavior for the initial "please sign" invitation email
// after a document is generated. 'auto' = our branded email goes
// out immediately; 'manual' = doc generated, signing URL shown in
// UI, rep clicks a Send button to dispatch. Per-port so different
// ports can default to different rep workflows.
eoiSendMode: 'eoi_send_mode',
// Public-facing host where embedded signing pages live. Used to
// transform raw Documenso signing URLs into branded
// {host}/sign/<type>/<token> URLs that go in our outbound emails.
// Falls back to APP_URL when unset.
embeddedSigningHost: 'embedded_signing_host',
// Documenso template IDs for contract / reservation if the port
// uses templates rather than per-deal uploads. Optional.
documensoContractTemplateId: 'documenso_contract_template_id',
documensoReservationTemplateId: 'documenso_reservation_template_id',
// Branding
brandingLogoUrl: 'branding_logo_url',
@@ -142,6 +177,7 @@ export async function getPortEmailConfig(portId: string): Promise<PortEmailConfi
export type EoiPathway = 'documenso-template' | 'inapp';
export type DocumensoApiVersion = 'v1' | 'v2';
export type EoiSendMode = 'auto' | 'manual';
export interface PortDocumensoConfig {
apiUrl: string;
@@ -153,6 +189,39 @@ export interface PortDocumensoConfig {
clientRecipientId: number;
developerRecipientId: number;
approvalRecipientId: number;
/** Static developer + approver identity per port (was hardcoded in old system). */
developerName: string;
developerEmail: string;
approverName: string;
approverEmail: string;
/**
* Auto = system sends our branded "please sign" email immediately
* after generation. Manual = generates only; rep clicks a separate
* Send button. Defaults to 'manual' to match the old system's
* behavior (which also doesn't auto-send).
*/
sendMode: EoiSendMode;
/**
* Host that wraps Documenso signing URLs into branded embed URLs.
* Outbound emails point here for the actual sign UI. e.g.
* `https://portnimara.com` makes sign URLs look like
* `https://portnimara.com/sign/<type>/<token>`.
*/
embeddedSigningHost: string | null;
/** Optional template IDs for contract / reservation. null = use
* upload-and-place-fields per deal instead of templates. */
contractTemplateId: number | null;
reservationTemplateId: number | null;
/** Per-port display labels for the developer + approver slots — drive
* email subjects and signer-progress UI copy. */
developerLabel: string;
approverLabel: string;
/** Optional CRM-user binding for the developer / approver slots.
* When set, the per-port admin UI auto-fills name/email from the
* user's profile and the webhook handler matches against this
* user's email for in-CRM signing-status updates. */
developerUserId: string | null;
approverUserId: string | null;
}
function toIntOrNull(raw: unknown): number | null {
@@ -174,6 +243,18 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
developerRecipientId,
approvalRecipientId,
defaultPathway,
developerName,
developerEmail,
approverName,
approverEmail,
sendMode,
embeddedSigningHost,
contractTemplateId,
reservationTemplateId,
developerLabel,
approverLabel,
developerUserId,
approverUserId,
] = await Promise.all([
readSetting<string>(SETTING_KEYS.documensoApiUrlOverride, portId),
readSetting<string>(SETTING_KEYS.documensoApiKeyOverride, portId),
@@ -183,6 +264,18 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
readSetting<string | number>(SETTING_KEYS.documensoDeveloperRecipientId, portId),
readSetting<string | number>(SETTING_KEYS.documensoApprovalRecipientId, portId),
readSetting<EoiPathway>(SETTING_KEYS.eoiDefaultPathway, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperName, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperEmail, portId),
readSetting<string>(SETTING_KEYS.documensoApproverName, portId),
readSetting<string>(SETTING_KEYS.documensoApproverEmail, portId),
readSetting<EoiSendMode>(SETTING_KEYS.eoiSendMode, portId),
readSetting<string>(SETTING_KEYS.embeddedSigningHost, portId),
readSetting<string | number>(SETTING_KEYS.documensoContractTemplateId, portId),
readSetting<string | number>(SETTING_KEYS.documensoReservationTemplateId, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperLabel, portId),
readSetting<string>(SETTING_KEYS.documensoApproverLabel, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperUserId, portId),
readSetting<string>(SETTING_KEYS.documensoApproverUserId, portId),
]);
return {
@@ -194,6 +287,18 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
developerRecipientId: toIntOrNull(developerRecipientId) ?? env.DOCUMENSO_DEVELOPER_RECIPIENT_ID,
approvalRecipientId: toIntOrNull(approvalRecipientId) ?? env.DOCUMENSO_APPROVAL_RECIPIENT_ID,
defaultPathway: defaultPathway ?? 'documenso-template',
developerName: developerName ?? '',
developerEmail: developerEmail ?? '',
approverName: approverName ?? '',
approverEmail: approverEmail ?? '',
sendMode: sendMode ?? 'manual',
embeddedSigningHost: embeddedSigningHost ?? null,
contractTemplateId: toIntOrNull(contractTemplateId),
reservationTemplateId: toIntOrNull(reservationTemplateId),
developerLabel: developerLabel ?? 'Developer',
approverLabel: approverLabel ?? 'Approver',
developerUserId: developerUserId ?? null,
approverUserId: approverUserId ?? null,
};
}

View File

@@ -0,0 +1,162 @@
/**
* Per-port configurable pipeline stages for residential interests.
*
* The stage list is stored as a JSON array on
* `system_settings.residential_pipeline_stages`. When the setting is
* unset, callers fall back to `DEFAULT_RESIDENTIAL_PIPELINE_STAGES`
* from the validators module.
*
* Each stage carries:
* - id (machine — used in the DB pipeline_stage column)
* - label (display)
* - terminal hint ('won' | 'lost' | null) — drives funnel reports
*
* Removal safety: when an admin removes a stage that still has
* interests at it, `validateStagesAgainstUsage` returns the affected
* interest ids so the UI can prompt for reassignment before saving.
*/
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { residentialInterests } from '@/lib/db/schema/residential';
import { systemSettings } from '@/lib/db/schema';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError } from '@/lib/errors';
const SETTING_KEY = 'residential_pipeline_stages';
export interface ResidentialStage {
id: string;
label: string;
/** 'won' / 'lost' for funnel-terminal stages, null for in-progress. */
terminal: 'won' | 'lost' | null;
}
const DEFAULT_STAGES: ResidentialStage[] = [
{ id: 'new', label: 'New', terminal: null },
{ id: 'contacted', label: 'Contacted', terminal: null },
{ id: 'viewing_scheduled', label: 'Viewing scheduled', terminal: null },
{ id: 'offer_made', label: 'Offer made', terminal: null },
{ id: 'offer_accepted', label: 'Offer accepted', terminal: null },
{ id: 'closed_won', label: 'Closed — won', terminal: 'won' },
{ id: 'closed_lost', label: 'Closed — lost', terminal: 'lost' },
];
export async function listStages(portId: string): Promise<ResidentialStage[]> {
const row = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, SETTING_KEY), eq(systemSettings.portId, portId)),
});
if (!row || !Array.isArray(row.value)) return DEFAULT_STAGES;
// Defensive: filter out anything that doesn't match the expected shape
// so a malformed row doesn't crash the whole residential UI.
const valid = (row.value as unknown[]).filter(
(s): s is ResidentialStage =>
!!s &&
typeof s === 'object' &&
typeof (s as ResidentialStage).id === 'string' &&
typeof (s as ResidentialStage).label === 'string',
);
return valid.length > 0 ? valid : DEFAULT_STAGES;
}
/**
* Return interest ids currently parked at a stage that is NOT in the
* proposed new list. Empty array means the swap is safe.
*/
export async function findOrphanInterests(
portId: string,
newStageIds: string[],
): Promise<Array<{ id: string; pipelineStage: string }>> {
const orphans = await db
.select({ id: residentialInterests.id, pipelineStage: residentialInterests.pipelineStage })
.from(residentialInterests)
.where(eq(residentialInterests.portId, portId));
return orphans.filter((row) => !newStageIds.includes(row.pipelineStage));
}
export interface SaveStagesArgs {
portId: string;
stages: ResidentialStage[];
/** Optional reassignment map: orphaned interest id → new stage id.
* When `force=true` and a non-empty reassignments map is supplied,
* the service applies the reassignments inside the same transaction
* as the stage-list write. */
reassignments?: Record<string, string>;
/** When true, save proceeds even if reassignments don't cover every
* orphan — remaining orphans are left at their old (now-removed)
* stage and will need a follow-up cleanup. */
force?: boolean;
}
export async function saveStages(args: SaveStagesArgs, meta: AuditMeta): Promise<void> {
const ids = args.stages.map((s) => s.id);
if (ids.length === 0) {
throw new ConflictError('At least one stage is required');
}
if (new Set(ids).size !== ids.length) {
throw new ConflictError('Stage ids must be unique');
}
const orphans = await findOrphanInterests(args.portId, ids);
const uncovered = orphans.filter((o) => !args.reassignments || !args.reassignments[o.id]);
if (uncovered.length > 0 && !args.force) {
throw new ConflictError(
`${uncovered.length} interest${uncovered.length === 1 ? '' : 's'} sit on a stage you're removing. Reassign them or pass force=true.`,
);
}
// Apply reassignments first (so any orphan handlers see the new
// stage ids). One UPDATE per target stage to keep the SQL simple.
if (args.reassignments) {
const byTarget = new Map<string, string[]>();
for (const [interestId, newStage] of Object.entries(args.reassignments)) {
if (!ids.includes(newStage)) {
throw new ConflictError(`Reassignment target stage '${newStage}' is not in the new list`);
}
const list = byTarget.get(newStage) ?? [];
list.push(interestId);
byTarget.set(newStage, list);
}
for (const [target, interestIds] of byTarget.entries()) {
await db
.update(residentialInterests)
.set({ pipelineStage: target, updatedAt: new Date() })
.where(
and(
eq(residentialInterests.portId, args.portId),
inArray(residentialInterests.id, interestIds),
),
);
}
}
// Upsert the stage list.
const existing = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, SETTING_KEY), eq(systemSettings.portId, args.portId)),
});
if (existing) {
await db
.update(systemSettings)
.set({ value: args.stages, updatedBy: meta.userId, updatedAt: new Date() })
.where(and(eq(systemSettings.key, SETTING_KEY), eq(systemSettings.portId, args.portId)));
} else {
await db.insert(systemSettings).values({
key: SETTING_KEY,
value: args.stages,
portId: args.portId,
updatedBy: meta.userId,
});
}
void createAuditLog({
userId: meta.userId,
portId: args.portId,
action: existing ? 'update' : 'create',
entityType: 'setting',
entityId: SETTING_KEY,
oldValue: existing ? { value: existing.value } : undefined,
newValue: { stages: args.stages, reassignments: args.reassignments ?? null },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}

View File

@@ -1,7 +1,6 @@
import { db } from '@/lib/db';
import { auditLogs, errorEvents } from '@/lib/db/schema';
import { redis } from '@/lib/redis';
import { minioClient } from '@/lib/minio/index';
import { getQueue, QUEUE_CONFIGS, type QueueName } from '@/lib/queue';
import { createAuditLog } from '@/lib/audit';
import { env } from '@/lib/env';
@@ -141,13 +140,21 @@ async function checkRedis(): Promise<ServiceStatus> {
}
async function checkMinio(): Promise<ServiceStatus> {
// Health-checks the ACTIVE storage backend (S3 or filesystem) via
// the abstraction so a port running on filesystem still gets a
// useful "Storage" status row instead of a meaningless "MinIO down".
// Probe key is a sentinel that's never written; head() returns null
// for a missing object on both backends, which counts as healthy
// (the connection itself worked).
const start = Date.now();
try {
await withTimeout(minioClient.bucketExists(env.MINIO_BUCKET), 5000);
return { name: 'MinIO', status: 'healthy', responseTimeMs: Date.now() - start };
const { getStorageBackend } = await import('@/lib/storage');
const backend = await getStorageBackend();
await withTimeout(backend.head('__health_probe__'), 5000);
return { name: 'Storage', status: 'healthy', responseTimeMs: Date.now() - start };
} catch (err) {
return {
name: 'MinIO',
name: 'Storage',
status: 'down',
responseTimeMs: Date.now() - start,
details: err instanceof Error ? err.message : 'Unknown error',

View File

@@ -219,6 +219,11 @@ export interface MigrationOptions {
from: StorageBackendName;
to: StorageBackendName;
dryRun: boolean;
/** Skip the file copy and just flip the active backend pointer.
* Existing files become inaccessible until they're migrated later
* or the backend is reverted. Rare — surfaced in the admin UI as
* a clearly-warned alternative to switch + migrate. */
skipMigration?: boolean;
/** Override for tests. */
source?: StorageBackend;
target?: StorageBackend;
@@ -245,14 +250,30 @@ export async function runMigration(opts: MigrationOptions): Promise<MigrationRes
try {
await ensureProgressTable();
const source = opts.source ?? (await buildBackendForMigration(opts.from));
const target = opts.target ?? (await buildBackendForMigration(opts.to));
let rowsConsidered = 0;
let rowsMigrated = 0;
let rowsSkippedAlreadyDone = 0;
let totalBytes = 0;
// Skip-migration shortcut: don't touch storage at all, just flip
// the active-backend pointer. Existing files become unreachable
// until a future migration. Surfaced as a clearly-warned option
// in the admin UI; almost never the right choice.
if (opts.skipMigration && !opts.dryRun) {
await flipBackendSetting(opts.to, opts.userId ?? 'cli:migrate-storage');
return {
rowsConsidered: 0,
rowsMigrated: 0,
rowsSkippedAlreadyDone: 0,
totalBytes: 0,
flipped: true,
dryRun: false,
};
}
const source = opts.source ?? (await buildBackendForMigration(opts.from));
const target = opts.target ?? (await buildBackendForMigration(opts.to));
for (const tbl of TABLES_WITH_STORAGE_KEYS) {
const refs = await listKeysFor(tbl);
rowsConsidered += refs.length;

View File

@@ -32,7 +32,7 @@ export const createClientSchema = z.object({
preferredLanguage: z.string().optional(),
/** IANA timezone (e.g. 'Europe/Warsaw'). */
timezone: optionalIanaTimezoneSchema.optional(),
source: z.enum(['website', 'manual', 'referral', 'broker']).optional(),
source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(),
sourceDetails: z.string().optional(),
tagIds: z.array(z.string()).optional().default([]),
});
@@ -46,7 +46,7 @@ export const updateClientSchema = createClientSchema
// ─── List ─────────────────────────────────────────────────────────────────────
export const listClientsSchema = baseListQuerySchema.extend({
source: z.enum(['website', 'manual', 'referral', 'broker']).optional(),
source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(),
nationality: z.string().optional(),
tagIds: z
.string()

View File

@@ -33,7 +33,7 @@ export const createResidentialClientSchema = z.object({
subdivisionIso: optionalSubdivisionIsoSchema.optional(),
preferredContactMethod: z.enum(['email', 'phone']).optional(),
status: z.enum(['prospect', 'active', 'inactive']).optional().default('prospect'),
source: z.enum(['website', 'manual', 'referral', 'broker']).optional(),
source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(),
notes: z.string().optional(),
});
@@ -41,12 +41,22 @@ export const updateResidentialClientSchema = createResidentialClientSchema.parti
export const listResidentialClientsSchema = baseListQuerySchema.extend({
status: z.enum(['prospect', 'active', 'inactive']).optional(),
source: z.enum(['website', 'manual', 'referral', 'broker']).optional(),
source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(),
});
// ─── Residential interest ────────────────────────────────────────────────────
export const PIPELINE_STAGES = [
/**
* Default pipeline stages — used as the fallback when a port hasn't
* configured its own list via the residential admin page. Mirror the
* legacy hard-coded set so existing data continues to validate.
*
* Per-port stages are stored in `system_settings.residential_pipeline_stages`
* (JSON array of stage ids). The validators below accept any string and
* defer the membership check to a runtime helper that reads the live
* stage list. This lets admins add/rename stages without a deploy.
*/
export const DEFAULT_RESIDENTIAL_PIPELINE_STAGES = [
'new',
'contacted',
'viewing_scheduled',
@@ -56,10 +66,13 @@ export const PIPELINE_STAGES = [
'closed_lost',
] as const;
/** Backwards-compat alias kept for any existing imports. */
export const PIPELINE_STAGES = DEFAULT_RESIDENTIAL_PIPELINE_STAGES;
export const createResidentialInterestSchema = z.object({
residentialClientId: z.string().min(1),
pipelineStage: z.enum(PIPELINE_STAGES).optional().default('new'),
source: z.enum(['website', 'manual', 'referral', 'broker']).optional(),
pipelineStage: z.string().optional().default('new'),
source: z.enum(['website', 'manual', 'referral', 'broker', 'other']).optional(),
notes: z.string().optional(),
preferences: z.string().optional(),
assignedTo: z.string().optional(),
@@ -70,7 +83,7 @@ export const updateResidentialInterestSchema = createResidentialInterestSchema
.partial();
export const listResidentialInterestsSchema = baseListQuerySchema.extend({
pipelineStage: z.enum(PIPELINE_STAGES).optional(),
pipelineStage: z.string().optional(),
assignedTo: z.string().optional(),
residentialClientId: z.string().optional(),
});