feat: autonomous backlog push — admin UX overhaul + storage parity + residential parity + Documenso Phase 1
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:
124
src/lib/services/backup.service.ts
Normal file
124
src/lib/services/backup.service.ts
Normal 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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
263
src/lib/services/document-signing-emails.service.ts
Normal file
263
src/lib/services/document-signing-emails.service.ts
Normal 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.
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
162
src/lib/services/residential-stages.service.ts
Normal file
162
src/lib/services/residential-stages.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user