chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
This commit is contained in:
@@ -8,7 +8,7 @@ import { interests } from '@/lib/db/schema/interests';
|
||||
* An interest is **active** when it is:
|
||||
* - scoped to the given `portId`,
|
||||
* - not soft-archived (`archived_at IS NULL`), and
|
||||
* - not yet terminal (`outcome IS NULL` — i.e. not won, lost, or
|
||||
* - not yet terminal (`outcome IS NULL` - i.e. not won, lost, or
|
||||
* cancelled).
|
||||
*
|
||||
* "Won" deals are explicitly **not active** under this definition: a won
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function setAiBudget(
|
||||
throw new ValidationError('softCapTokens cannot exceed hardCapTokens');
|
||||
}
|
||||
// True upsert (atomic on the (key, port_id) NULLS NOT DISTINCT index
|
||||
// — migration 0047). Replaces a delete-then-insert pattern that had a
|
||||
// - migration 0047). Replaces a delete-then-insert pattern that had a
|
||||
// race window where two concurrent updates could both DELETE and both
|
||||
// INSERT, accumulating duplicates.
|
||||
await db
|
||||
|
||||
@@ -166,7 +166,7 @@ export async function computeOccupancyTimeline(
|
||||
|
||||
// Occupancy = cumulative count of berths sold (i.e. won deals) on or
|
||||
// before each day. Per 2026-05-14 decision, the canonical occupancy
|
||||
// signal is "the deal closed and money changed hands" — reservations
|
||||
// signal is "the deal closed and money changed hands" - reservations
|
||||
// are merely holds and don't count as occupied. Sources from
|
||||
// `interests.outcome='won'` + `outcome_at::date`; primary-berth link
|
||||
// via `interest_berths` so multi-berth deals contribute every linked
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// L-AU04: the legacy ILIKE-based `listAuditLogs` was superseded by the
|
||||
// FTS-backed `searchAuditLogs` (audit-search.service.ts) in 2026-05-08.
|
||||
// Nothing imports the old function anymore — keeping a stub file rather
|
||||
// Nothing imports the old function anymore - keeping a stub file rather
|
||||
// than deleting outright in case the module path resolves elsewhere in
|
||||
// build tooling. Remove the file in a follow-up sweep.
|
||||
//
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* `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
|
||||
* 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).
|
||||
@@ -56,7 +56,7 @@ export async function runBackup({ trigger, triggeredBy }: RunBackupArgs): Promis
|
||||
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.
|
||||
// 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), {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Per-berth interest-count rankings — investor-facing analytics surface.
|
||||
* Per-berth interest-count rankings - investor-facing analytics surface.
|
||||
* For each berth in the port, returns the count of active interests
|
||||
* (archived_at IS NULL AND outcome IS NULL) currently linked via the
|
||||
* primary `interest_berths` row.
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
/**
|
||||
* Reverse parser for per-berth PDFs (Phase 6b — see plan §4.7b and §9.2).
|
||||
* Reverse parser for per-berth PDFs (Phase 6b - see plan §4.7b and §9.2).
|
||||
*
|
||||
* Three tiers, each falling back to the next:
|
||||
*
|
||||
* 1. AcroForm — read named text fields via pdf-lib. The sample
|
||||
* 1. AcroForm - read named text fields via pdf-lib. The sample
|
||||
* `Berth_Spec_Sheet_A1.pdf` has 0 AcroForm fields (designers export the
|
||||
* PDF flat), so this tier is built defensively for future templates that
|
||||
* may include named form fields. When fields exist, this is the highest-
|
||||
* confidence path because there's no OCR loss.
|
||||
*
|
||||
* 2. OCR — Tesseract.js extracts text from the page; positional/regex
|
||||
* 2. OCR - Tesseract.js extracts text from the page; positional/regex
|
||||
* heuristics keyed off the labels documented in §9.2 pull out values.
|
||||
* Returns per-field confidence scores.
|
||||
*
|
||||
* 3. AI fallback — gated on `getResolvedOcrConfig(...)` returning a usable
|
||||
* 3. AI fallback - gated on `getResolvedOcrConfig(...)` returning a usable
|
||||
* OpenAI/Claude config. Only invoked when OCR confidence is below
|
||||
* threshold for too many fields AND the rep opts in via the diff dialog.
|
||||
* A null `apiKey` causes this tier to return a clear "not configured"
|
||||
@@ -41,7 +41,7 @@ export interface ExtractedBerthFields {
|
||||
/** Water depth at the berth (separate from a vessel's max draft). */
|
||||
waterDepth?: number | null;
|
||||
waterDepthM?: number | null;
|
||||
/** Max draught of vessel — falls back to the berth's draft column. */
|
||||
/** Max draught of vessel - falls back to the berth's draft column. */
|
||||
draftFt?: number | null;
|
||||
draftM?: number | null;
|
||||
bowFacing?: string | null;
|
||||
@@ -73,11 +73,11 @@ export interface ParsedField<T = unknown> {
|
||||
|
||||
export interface ParseResult {
|
||||
engine: ParserEngine;
|
||||
/** Sparse — only fields the parser was able to extract. */
|
||||
/** Sparse - only fields the parser was able to extract. */
|
||||
fields: Partial<Record<keyof ExtractedBerthFields, ParsedField>>;
|
||||
/** Mean confidence across all extracted fields (0..1). */
|
||||
meanConfidence: number;
|
||||
/** Raw text the OCR or AI tier produced — useful for the diff dialog audit. */
|
||||
/** Raw text the OCR or AI tier produced - useful for the diff dialog audit. */
|
||||
rawText?: string;
|
||||
/** Set when a tier degraded; the API surface uses this to decide whether to
|
||||
* surface the "AI parse" button. */
|
||||
@@ -155,7 +155,7 @@ async function tryAcroForm(buffer: Buffer): Promise<ParseResult | null> {
|
||||
const name = field.getName().toLowerCase();
|
||||
const target = ACROFORM_FIELD_MAP[name];
|
||||
if (!target) continue;
|
||||
// pdf-lib doesn't expose a generic "get value" — narrow to text fields.
|
||||
// pdf-lib doesn't expose a generic "get value" - narrow to text fields.
|
||||
let raw: string | undefined;
|
||||
try {
|
||||
const tf = form.getTextField(field.getName());
|
||||
@@ -182,12 +182,12 @@ async function tryAcroForm(buffer: Buffer): Promise<ParseResult | null> {
|
||||
|
||||
/**
|
||||
* Tier-2 extracts text directly from the PDF via `unpdf` (a serverless-
|
||||
* friendly pdfjs wrapper). This works for text-PDFs — i.e. PDFs that
|
||||
* friendly pdfjs wrapper). This works for text-PDFs - i.e. PDFs that
|
||||
* contain real text streams, not scanned page images. Scanned/raster PDFs
|
||||
* land here with empty extracted text and fall through to the AI tier.
|
||||
*
|
||||
* The earlier design called for tesseract.js rasterization, but
|
||||
* `tesseract.recognize` doesn't accept a PDF buffer — it expects an image.
|
||||
* `tesseract.recognize` doesn't accept a PDF buffer - it expects an image.
|
||||
* That old code path silently failed at runtime; unpdf is the correct
|
||||
* primitive for "pull text out of a PDF on the server."
|
||||
*
|
||||
@@ -263,7 +263,7 @@ export function extractFromOcrText(rawText: string): {
|
||||
out.mooringNumber = { value: mooringMatch[1]!.toUpperCase(), confidence: 0.85, engine: 'ocr' };
|
||||
}
|
||||
|
||||
// Length / Width / Water Depth — `Label: <imperial> / <metric>` form.
|
||||
// Length / Width / Water Depth - `Label: <imperial> / <metric>` form.
|
||||
// Imperial may be `206' 8"` style; we capture the numeric prefix in feet
|
||||
// and parse the metric independently because they're rarely lossless.
|
||||
const dimensional = (
|
||||
@@ -287,7 +287,7 @@ export function extractFromOcrText(rawText: string): {
|
||||
}
|
||||
if (ft != null && Number.isFinite(meters) && Math.abs(ft * 0.3048 - meters) / meters > 0.01) {
|
||||
warnings.push(
|
||||
`${label}: imperial/metric mismatch — ${ft}ft vs ${meters}m differ >1% (using imperial as source of truth).`,
|
||||
`${label}: imperial/metric mismatch - ${ft}ft vs ${meters}m differ >1% (using imperial as source of truth).`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -394,7 +394,7 @@ async function tryOcr(buffer: Buffer, adapter?: OcrAdapter): Promise<ParseResult
|
||||
};
|
||||
}
|
||||
const { fields, warnings } = extractFromOcrText(result.text);
|
||||
// Tesseract gives 0..100; normalize to 0..1 and use it as a global floor —
|
||||
// Tesseract gives 0..100; normalize to 0..1 and use it as a global floor -
|
||||
// per-field confidence is set by the regex tier above.
|
||||
const floor = Math.max(0, Math.min(result.confidence, 100)) / 100;
|
||||
for (const key of Object.keys(fields) as Array<keyof ExtractedBerthFields>) {
|
||||
@@ -441,7 +441,7 @@ export interface ParseBerthPdfOptions {
|
||||
* returned result's `engine` field tells callers which tier produced the
|
||||
* fields (used by the reconcile-diff dialog to colour confidence chips).
|
||||
*
|
||||
* The AI tier is never invoked from this entry point — that's a separate
|
||||
* The AI tier is never invoked from this entry point - that's a separate
|
||||
* deliberate action triggered from the diff dialog so OPENAI_API_KEY isn't
|
||||
* spent on every upload.
|
||||
*/
|
||||
@@ -499,7 +499,7 @@ function coerceFieldValue(key: keyof ExtractedBerthFields, raw: string): string
|
||||
return raw;
|
||||
}
|
||||
// Numeric columns: strip currency / unit suffixes and commas. Berth
|
||||
// dimensions / capacities / prices are all non-negative — reject
|
||||
// dimensions / capacities / prices are all non-negative - reject
|
||||
// negatives outright so an AcroForm with `length_ft="-50"` doesn't
|
||||
// poison the recommender feasibility filter when applied.
|
||||
const numeric = Number(raw.replace(/[^0-9.\-]/g, ''));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Berth PDF management service (Phase 6b — see plan §4.7b, §11.1, §14.6).
|
||||
* Berth PDF management service (Phase 6b - see plan §4.7b, §11.1, §14.6).
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Upload a per-berth PDF (versioned), via the active `StorageBackend`.
|
||||
@@ -48,10 +48,10 @@ export interface ReconcileResult {
|
||||
* shows these as a side-by-side comparison; nothing is written until the
|
||||
* rep confirms via the apply endpoint. */
|
||||
conflicts: ReconcileConflict[];
|
||||
/** Pure-warning bucket — e.g. mooring-number mismatch with the berth being
|
||||
/** Pure-warning bucket - e.g. mooring-number mismatch with the berth being
|
||||
* uploaded to (§14.6). */
|
||||
warnings: string[];
|
||||
/** Engine that produced the parse — surfaced on the diff UI. */
|
||||
/** Engine that produced the parse - surfaced on the diff UI. */
|
||||
engine: ParserEngine;
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ export interface UploadBerthPdfArgs {
|
||||
berthId: string;
|
||||
/**
|
||||
* Acting tenant. Every public service entrypoint requires this so the berth
|
||||
* lookup can be scoped to `(berth.id, port_id)` — without it a rep with
|
||||
* lookup can be scoped to `(berth.id, port_id)` - without it a rep with
|
||||
* berths:edit on port A could supply a port B berth UUID and write/read
|
||||
* cross-tenant data. NotFoundError on mismatch.
|
||||
*/
|
||||
@@ -157,7 +157,7 @@ export interface UploadBerthPdfArgs {
|
||||
sha256?: string;
|
||||
/** Pre-computed bytes (used for the size cap pre-flight on direct uploads). */
|
||||
fileSizeBytes?: number;
|
||||
/** Result of running `parseBerthPdf` server-side. Optional — the rep may
|
||||
/** Result of running `parseBerthPdf` server-side. Optional - the rep may
|
||||
* have skipped parsing on a re-upload. */
|
||||
parseResult?: ParseResult;
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export interface UploadBerthPdfResult {
|
||||
*/
|
||||
export async function uploadBerthPdf(args: UploadBerthPdfArgs): Promise<UploadBerthPdfResult> {
|
||||
// 1. Resolve the berth + port for size-cap lookup.
|
||||
// Tenant-scoped lookup — NotFoundError when the berth lives in a different
|
||||
// Tenant-scoped lookup - NotFoundError when the berth lives in a different
|
||||
// port so a rep on port A cannot upload PDFs against port B's berths.
|
||||
const berthRow = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.id, args.berthId), eq(berths.portId, args.portId)),
|
||||
@@ -195,7 +195,7 @@ export async function uploadBerthPdf(args: UploadBerthPdfArgs): Promise<UploadBe
|
||||
// 2. Per-berth advisory lock prevents two concurrent uploads from both
|
||||
// computing version `v3` and racing to write blobs (the unique index
|
||||
// on (berth_id, version_number) would catch the second insert, but
|
||||
// only AFTER its blob is already in storage — leaving an orphan).
|
||||
// only AFTER its blob is already in storage - leaving an orphan).
|
||||
// The lock is scoped to a transaction wrapping the version-number
|
||||
// read AND the blob write, so concurrent uploads serialize cleanly.
|
||||
// NB: hash the UUID into a 32-bit int for pg_advisory_xact_lock(int).
|
||||
@@ -207,7 +207,7 @@ export async function uploadBerthPdf(args: UploadBerthPdfArgs): Promise<UploadBe
|
||||
// UUID-based storage key path so two concurrent uploads can't collide
|
||||
// on the same blob path (the version_number suffix used to be in the
|
||||
// key but is now a separate DB column allocated under the per-berth
|
||||
// advisory lock — see step 4).
|
||||
// advisory lock - see step 4).
|
||||
let versionNumber = 1;
|
||||
let storageKey =
|
||||
args.storageKey ??
|
||||
@@ -232,7 +232,7 @@ export async function uploadBerthPdf(args: UploadBerthPdfArgs): Promise<UploadBe
|
||||
sizeBytes = written.sizeBytes;
|
||||
sha256 = written.sha256;
|
||||
} else if (args.storageKey) {
|
||||
// Browser uploaded directly via presigned URL — verify via HEAD + magic bytes.
|
||||
// Browser uploaded directly via presigned URL - verify via HEAD + magic bytes.
|
||||
const head = await backend.head(args.storageKey);
|
||||
if (!head) {
|
||||
throw new ValidationError('Uploaded object not found at expected storage key.');
|
||||
@@ -302,7 +302,7 @@ export async function uploadBerthPdf(args: UploadBerthPdfArgs): Promise<UploadBe
|
||||
return { versionId, storageKey, versionNumber, fileSizeBytes: sizeBytes, contentSha256: sha256 };
|
||||
}
|
||||
|
||||
/** Tx-bound variant — same SELECT MAX(...) but inside the caller's transaction. */
|
||||
/** Tx-bound variant - same SELECT MAX(...) but inside the caller's transaction. */
|
||||
async function nextVersionNumberTx(
|
||||
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
||||
berthId: string,
|
||||
@@ -316,7 +316,7 @@ async function nextVersionNumberTx(
|
||||
|
||||
/**
|
||||
* Hash a UUID berthId into a 32-bit signed integer for pg_advisory_xact_lock.
|
||||
* Uses the first 4 bytes of sha256 reinterpreted as int32 — collisions are
|
||||
* Uses the first 4 bytes of sha256 reinterpreted as int32 - collisions are
|
||||
* theoretically possible but the lock is per-berth so a collision just
|
||||
* means two different berths' uploads serialize through the same key,
|
||||
* which is harmless (correctness preserved, slight contention only).
|
||||
@@ -401,7 +401,7 @@ export async function reconcilePdfWithBerth(
|
||||
const conflicts: ReconcileConflict[] = [];
|
||||
const warnings: string[] = [...parsed.warnings];
|
||||
|
||||
// §14.6 — mooring-number mismatch warning.
|
||||
// §14.6 - mooring-number mismatch warning.
|
||||
const pdfMooring = fields.mooringNumber?.value;
|
||||
if (
|
||||
pdfMooring &&
|
||||
@@ -447,7 +447,7 @@ export async function reconcilePdfWithBerth(
|
||||
* Mooring-mismatch gate (§14.6 critical): when the version's stored
|
||||
* `parseResults.warnings` contains a mooring-mismatch warning, the apply
|
||||
* is rejected unless the caller passes `confirmMooringMismatch: true`.
|
||||
* This is the service-side enforcement of the "force re-confirm" rule —
|
||||
* This is the service-side enforcement of the "force re-confirm" rule -
|
||||
* UI confirmation alone is not enough.
|
||||
*/
|
||||
export async function applyParseResults(
|
||||
@@ -569,7 +569,7 @@ export async function listBerthPdfVersions(
|
||||
.orderBy(desc(berthPdfVersions.versionNumber));
|
||||
|
||||
const backend = await getStorageBackend();
|
||||
// Presign with bounded concurrency — for an S3 backend each call is
|
||||
// Presign with bounded concurrency - for an S3 backend each call is
|
||||
// a separate HTTP round-trip. 8 in flight at once keeps the latency
|
||||
// close to ~1× round-trip on typical 5-15-version berths while
|
||||
// preventing a 100-version pathological case from saturating the
|
||||
@@ -606,7 +606,7 @@ export async function listBerthPdfVersions(
|
||||
|
||||
/**
|
||||
* Set `berths.current_pdf_version_id` to the requested version. Per §14.6,
|
||||
* this does NOT re-parse and re-update the berth columns — that's a separate
|
||||
* this does NOT re-parse and re-update the berth columns - that's a separate
|
||||
* deliberate "extract data from this version" action.
|
||||
*/
|
||||
export async function rollbackToVersion(
|
||||
|
||||
@@ -522,7 +522,7 @@ export async function recommendBerths(args: RecommendBerthsArgs): Promise<Recomm
|
||||
0
|
||||
) AS fallthrough_max_stage,
|
||||
-- COUNT(ib.berth_id) (not COUNT(*)) so a berth with no junction
|
||||
-- rows reports 0 — the LEFT JOIN otherwise produces a single
|
||||
-- rows reports 0 - the LEFT JOIN otherwise produces a single
|
||||
-- NULL-right-side row that COUNT(*) would tally as 1 and inflate
|
||||
-- the heat interest-count component for berths with no history.
|
||||
-- The FILTER also enforces port isolation defense-in-depth: an
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function createPending(
|
||||
data.clientId,
|
||||
);
|
||||
|
||||
// Re-parse to apply coercions/defaults locally — Drizzle's .values()
|
||||
// Re-parse to apply coercions/defaults locally - Drizzle's .values()
|
||||
// wants the post-coercion shape (Date, defaulted enum), and v4's
|
||||
// z.input is too loose to satisfy that.
|
||||
const parsed = createPendingSchema.parse(data);
|
||||
|
||||
@@ -125,7 +125,7 @@ export async function evaluateRule(
|
||||
// berth-scoped advisory lock so two concurrent webhook retries can't both
|
||||
// commit the same status flip (which produces duplicate audit rows + a
|
||||
// double socket emit). Also short-circuit when the target status is
|
||||
// already in place — re-writing 'sold'→'sold' is technically harmless
|
||||
// already in place - re-writing 'sold'→'sold' is technically harmless
|
||||
// but pollutes the audit trail and the socket stream.
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// pg_advisory_xact_lock takes a single bigint. We hash port+berth into
|
||||
@@ -162,7 +162,7 @@ export async function evaluateRule(
|
||||
statusLastChangedReason: `Auto-applied by rule: ${trigger}`,
|
||||
statusLastModified: new Date(),
|
||||
// #67 Phase 1: stamp the source so the reconciliation queue
|
||||
// can filter "Manual only" — rules-engine writes are never
|
||||
// can filter "Manual only" - rules-engine writes are never
|
||||
// candidates for catch-up because they already have a backing
|
||||
// interest driving them.
|
||||
statusOverrideMode: 'automated',
|
||||
@@ -198,7 +198,7 @@ export async function evaluateRule(
|
||||
return { action: 'applied', newStatus: rule.targetStatus };
|
||||
}
|
||||
|
||||
// suggest mode — the decision-trace audit above already records the suggestion.
|
||||
// suggest mode - the decision-trace audit above already records the suggestion.
|
||||
return {
|
||||
action: 'suggested',
|
||||
newStatus: rule.targetStatus,
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
||||
}
|
||||
|
||||
// Default ordering is natural alphanumeric on mooring number
|
||||
// (A1, A2, A10, B1...) — Postgres' default lexicographic sort
|
||||
// (A1, A2, A10, B1...) - Postgres' default lexicographic sort
|
||||
// would put A10 before A2, which is the wrong story for a marina
|
||||
// map. The mooring format is locked at `^[A-Z]+\d+$` so the regexp
|
||||
// splits are safe.
|
||||
@@ -80,7 +80,7 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
||||
const sortColumn = (() => {
|
||||
switch (query.sort) {
|
||||
case 'mooringNumber':
|
||||
// Honoured via customOrderBy below — caller asked for mooring
|
||||
// Honoured via customOrderBy below - caller asked for mooring
|
||||
// sort explicitly, give them the natural order.
|
||||
return null;
|
||||
case 'area':
|
||||
@@ -95,7 +95,7 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
||||
// Sorted via correlated subquery in customOrderBy below.
|
||||
return null;
|
||||
case 'latestInterestStage':
|
||||
// Sorted via correlated subquery in customOrderBy below — the
|
||||
// Sorted via correlated subquery in customOrderBy below - the
|
||||
// column doesn't exist on berths; it's the highest-ranked
|
||||
// active interest's pipeline stage per berth.
|
||||
return null;
|
||||
@@ -257,7 +257,7 @@ async function getLatestInterestStageByBerth(
|
||||
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
|
||||
.where(and(activeInterestsWhere(portId), inArray(interestBerths.berthId, berthIds)));
|
||||
|
||||
// Pipeline stages are an ordered enum — rank by position in PIPELINE_STAGES
|
||||
// Pipeline stages are an ordered enum - rank by position in PIPELINE_STAGES
|
||||
// so "contract_signed" beats "eoi_sent". Falls back to 0 for any unknown
|
||||
// legacy values so they're treated as least-advanced.
|
||||
const rankOf = (stage: string) => {
|
||||
@@ -523,7 +523,7 @@ export async function bulkUpdateBerthPrices(
|
||||
}
|
||||
});
|
||||
|
||||
// Realtime fan-out — one event per updated berth so any open list view
|
||||
// Realtime fan-out - one event per updated berth so any open list view
|
||||
// refetches the affected rows.
|
||||
for (const id of updatedIds) {
|
||||
emitToRoom(`port:${portId}`, 'berth:updated', {
|
||||
@@ -623,7 +623,7 @@ export async function updateBerthStatus(
|
||||
// #67 Phase 3: surfaces every berth whose status was set manually (i.e.
|
||||
// statusOverrideMode === 'manual') AND that has no active linked interest
|
||||
// backing the status change. These are the rows the catch-up wizard
|
||||
// targets — a rep flipped them to under_offer / sold without ever
|
||||
// targets - a rep flipped them to under_offer / sold without ever
|
||||
// creating the matching deal. Sorted by status_last_modified DESC so the
|
||||
// freshest manual flips show up first.
|
||||
|
||||
@@ -643,7 +643,7 @@ export async function listManualReconcileBerths(portId: string): Promise<{
|
||||
}> {
|
||||
// Use a NOT EXISTS subquery against interest_berths joined with the active
|
||||
// interests predicate so a berth currently linked to any open deal drops
|
||||
// out of the queue — even if the rep set the status manually first and
|
||||
// out of the queue - even if the rep set the status manually first and
|
||||
// only later created the interest, that follow-up is the catch-up.
|
||||
const activeBerthIds = db
|
||||
.select({ berthId: interestBerths.berthId })
|
||||
@@ -682,7 +682,7 @@ export async function listManualReconcileBerths(portId: string): Promise<{
|
||||
// reconciliation queue, and stamps the reason with the interest id so the
|
||||
// audit trail records the reconciliation event explicitly.
|
||||
//
|
||||
// Intentionally NOT called from setPrimaryBerth/upsertInterestBerth — those
|
||||
// Intentionally NOT called from setPrimaryBerth/upsertInterestBerth - those
|
||||
// run on every berth-link write (including drag-drop reorders that have
|
||||
// nothing to do with a manual override) and would silently clear the flag
|
||||
// behind the rep's back. Only the wizard owns the clear semantics.
|
||||
@@ -732,7 +732,7 @@ export async function clearBerthOverride(
|
||||
// service helpers (each of which already runs in its own transaction
|
||||
// with its own audit-log emit). We pull them together here so the API
|
||||
// layer has a single call to make, but the actual work stays inside the
|
||||
// already-tested helpers — wrapping ALL of this in one transaction would
|
||||
// already-tested helpers - wrapping ALL of this in one transaction would
|
||||
// require restructuring the audit-log emits to be queued + flushed at
|
||||
// commit, which is out of scope for this feature.
|
||||
|
||||
@@ -1130,7 +1130,7 @@ export async function bulkAddBerths(
|
||||
*
|
||||
* Reasoning chain:
|
||||
* 1. Block if there's an active (non-archived, no-outcome) interest
|
||||
* still linked — archiving with deals in flight breaks reports.
|
||||
* still linked - archiving with deals in flight breaks reports.
|
||||
* 2. Stamp archived_at + archived_by + archive_reason in a single update.
|
||||
* 3. Audit log captures the reason so /admin/audit shows the why.
|
||||
* 4. Emit a socket alert so any open berth-detail page bounces.
|
||||
@@ -1149,7 +1149,7 @@ export async function archiveBerth(
|
||||
throw new ConflictError('Berth is already archived');
|
||||
}
|
||||
|
||||
// Block archive when an active interest still depends on the berth —
|
||||
// Block archive when an active interest still depends on the berth -
|
||||
// forces the rep to resolve the deal first instead of orphaning it.
|
||||
const activeLink = await db
|
||||
.select({ interestId: interestBerths.interestId })
|
||||
@@ -1234,7 +1234,7 @@ export async function restoreBerth(id: string, portId: string, meta: AuditMeta)
|
||||
|
||||
/**
|
||||
* @deprecated Use `archiveBerth` instead. Kept temporarily for callers
|
||||
* that haven't migrated. Calls archiveBerth under the hood — the
|
||||
* that haven't migrated. Calls archiveBerth under the hood - the
|
||||
* "hard delete" name is now a lie but we don't break the import sites
|
||||
* in a single PR.
|
||||
*/
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface BootstrapInput {
|
||||
/**
|
||||
* Atomically: create the better-auth user, create the user_profiles row
|
||||
* with isSuperAdmin=true. Refuses to run when a super-admin already
|
||||
* exists — the only safe-by-design self-registration path.
|
||||
* exists - the only safe-by-design self-registration path.
|
||||
*
|
||||
* Returns the new user's id on success.
|
||||
*/
|
||||
@@ -48,7 +48,7 @@ export async function createInitialSuperAdmin(input: BootstrapInput): Promise<st
|
||||
}
|
||||
|
||||
// Re-check inside the critical path so two concurrent first-run
|
||||
// submissions can't both win — the first to insert the profile row
|
||||
// submissions can't both win - the first to insert the profile row
|
||||
// closes the window for everyone else.
|
||||
if (await hasAnySuperAdmin()) {
|
||||
throw new ConflictError('A super-administrator account already exists');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Brochures + brochure-versions service (Phase 7 — see plan §3.3 / §4.7).
|
||||
* Brochures + brochure-versions service (Phase 7 - see plan §3.3 / §4.7).
|
||||
*
|
||||
* Brochures are port-wide marketing PDFs (the sample `Port-Nimara-Brochure-March-2025`
|
||||
* is 10.26 MB). Each `brochures` row groups a logical brochure (e.g.
|
||||
@@ -7,7 +7,7 @@
|
||||
* to that brochure. The default brochure is the one the send-out flow picks
|
||||
* when the rep doesn't pick explicitly (§14.7).
|
||||
*
|
||||
* Storage goes through `getStorageBackend()` (Phase 6a) — never minio
|
||||
* Storage goes through `getStorageBackend()` (Phase 6a) - never minio
|
||||
* directly. The version row's `storageKey` follows the §4.7a convention.
|
||||
*/
|
||||
import { and, asc, desc, eq, isNull } from 'drizzle-orm';
|
||||
@@ -95,7 +95,7 @@ export async function getBrochure(
|
||||
* Resolve the brochure that the send-out flow should default to. Returns the
|
||||
* default brochure when one exists and is non-archived; falls back to the
|
||||
* most recently created non-archived brochure with a version; null when
|
||||
* the port has no usable brochures (the send UI hides the button — §14.7).
|
||||
* the port has no usable brochures (the send UI hides the button - §14.7).
|
||||
*/
|
||||
export async function getDefaultBrochure(
|
||||
portId: string,
|
||||
|
||||
@@ -114,7 +114,7 @@ export interface ClientArchiveDossier {
|
||||
portId: string;
|
||||
archivedAt: string | null;
|
||||
};
|
||||
/** The headline classification — drives whether the bulk wizard
|
||||
/** The headline classification - drives whether the bulk wizard
|
||||
* treats this client as low-stakes (auto) or high-stakes (per-row
|
||||
* confirmation + reason required). */
|
||||
stakeLevel: ArchiveStakeLevel;
|
||||
@@ -123,7 +123,7 @@ export interface ClientArchiveDossier {
|
||||
* Null when low-stakes. */
|
||||
highStakesStage: PipelineStage | null;
|
||||
|
||||
// Sections — empty arrays mean "nothing to handle in this category"
|
||||
// Sections - empty arrays mean "nothing to handle in this category"
|
||||
interests: DossierInterest[];
|
||||
berths: DossierBerth[];
|
||||
yachts: DossierYacht[];
|
||||
@@ -133,7 +133,7 @@ export interface ClientArchiveDossier {
|
||||
documents: DossierDocument[];
|
||||
hasPortalUser: boolean;
|
||||
|
||||
/** Hard blockers — cannot proceed with archive at all until these are
|
||||
/** Hard blockers - cannot proceed with archive at all until these are
|
||||
* resolved manually. Currently the only one is "active reservation
|
||||
* on a sold berth" (since you can't unsell a berth from this flow). */
|
||||
blockers: string[];
|
||||
@@ -271,7 +271,7 @@ export async function getClientArchiveDossier(
|
||||
.limit(10);
|
||||
|
||||
// Every linked interest belonging to THIS client (multiple
|
||||
// interests can share a berth — primary flag is at most one per
|
||||
// interests can share a berth - primary flag is at most one per
|
||||
// interest, not per berth).
|
||||
const linkedInterestIds = Array.from(
|
||||
new Set(interestBerthRows.filter((r) => r.berthId === berthId).map((r) => r.interestId)),
|
||||
@@ -310,7 +310,7 @@ export async function getClientArchiveDossier(
|
||||
),
|
||||
);
|
||||
|
||||
// ─── Company memberships (current — no end_date) ─────────────────────────
|
||||
// ─── Company memberships (current - no end_date) ─────────────────────────
|
||||
const memberRows = await db
|
||||
.select({
|
||||
companyId: companies.id,
|
||||
@@ -376,7 +376,7 @@ export async function getClientArchiveDossier(
|
||||
.limit(1);
|
||||
|
||||
// ─── Hard blockers ───────────────────────────────────────────────────────
|
||||
// The only true blocker is an active reservation on a SOLD berth — we
|
||||
// The only true blocker is an active reservation on a SOLD berth - we
|
||||
// can't auto-handle this without crossing into refund territory. Force
|
||||
// the operator to handle it via the existing reservation UI first.
|
||||
const blockers: string[] = [];
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* External-system cleanup (Documenso envelope void/delete, mass email
|
||||
* notifications to next-in-line interests) happens AFTER the local
|
||||
* commit — best-effort, queued for retry, never blocks the archive.
|
||||
* commit - best-effort, queued for retry, never blocks the archive.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
@@ -44,7 +44,7 @@ export type BerthDecision = {
|
||||
export type YachtDecision = {
|
||||
yachtId: string;
|
||||
action: 'transfer' | 'mark_sold_away' | 'retain';
|
||||
/** Required when action='transfer' — the new owner's client/company id. */
|
||||
/** Required when action='transfer' - the new owner's client/company id. */
|
||||
newOwnerType?: 'client' | 'company';
|
||||
newOwnerId?: string;
|
||||
};
|
||||
@@ -52,7 +52,7 @@ export type YachtDecision = {
|
||||
export type ReservationDecision = {
|
||||
reservationId: string;
|
||||
action: 'cancel' | 'transfer';
|
||||
/** Required when action='transfer' — the new client id. */
|
||||
/** Required when action='transfer' - the new client id. */
|
||||
transferToClientId?: string;
|
||||
};
|
||||
|
||||
@@ -120,7 +120,7 @@ export interface ArchiveResult {
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
/** Other interests that should be notified about this berth becoming
|
||||
* available — drives the "next in line" notification fire. */
|
||||
* available - drives the "next in line" notification fire. */
|
||||
nextInLineInterestIds: string[];
|
||||
}>;
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export async function archiveClientWithDecisions(args: {
|
||||
// Lock the berth row so a concurrent sale can't flip the status
|
||||
// between our read of dossier.berths (outside the tx) and our
|
||||
// write below. Without this lock, A archives client X while B
|
||||
// sells berth A1 to client Y — A's pre-tx read says
|
||||
// sells berth A1 to client Y - A's pre-tx read says
|
||||
// status='under_offer', B commits status='sold', A's update
|
||||
// would flip it back to 'available'.
|
||||
const [locked] = await tx
|
||||
@@ -201,7 +201,7 @@ export async function archiveClientWithDecisions(args: {
|
||||
);
|
||||
// If no remaining interestBerths row marks this berth as
|
||||
// is_specific_interest, set the berth status back to available.
|
||||
// Sold berths are immutable from this flow — also re-checked
|
||||
// Sold berths are immutable from this flow - also re-checked
|
||||
// against the freshly-locked row, not the pre-tx dossier read.
|
||||
if (lockedStatus !== 'sold') {
|
||||
const [stillUnderOffer] = await tx
|
||||
@@ -319,7 +319,7 @@ export async function archiveClientWithDecisions(args: {
|
||||
const doc = dossier.documents.find((x) => x.documentId === d.documentId);
|
||||
if (!doc) continue;
|
||||
if (d.action === 'void_documenso' && doc.documensoEnvelopeId) {
|
||||
// Local marker — actual API call queued post-commit.
|
||||
// Local marker - actual API call queued post-commit.
|
||||
await tx
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
@@ -348,7 +348,7 @@ export async function archiveClientWithDecisions(args: {
|
||||
persistedDecisions.push({ kind: 'portal_user_revoked', refId: clientId });
|
||||
}
|
||||
|
||||
// Auto-end company memberships (no decision needed — preserves history
|
||||
// Auto-end company memberships (no decision needed - preserves history
|
||||
// via end_date instead of deleting the membership row).
|
||||
await tx
|
||||
.update(companyMemberships)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*
|
||||
* The DB cascade story:
|
||||
* - cascade FKs handle: companies, addresses, contacts, notes, tags,
|
||||
* portal users, GDPR records — see ON DELETE CASCADE on the FK
|
||||
* portal users, GDPR records - see ON DELETE CASCADE on the FK
|
||||
* definitions in src/lib/db/schema/clients.ts.
|
||||
* - non-cascade nullable FKs (files, documents, form_submissions,
|
||||
* email_messages, reminders, document_sends) get cleared inline so
|
||||
@@ -203,7 +203,7 @@ export async function hardDeleteClient(args: {
|
||||
if (!locked.archivedAt) throw new ConflictError('Client must be archived');
|
||||
|
||||
// Read email contacts BEFORE the cascade so we can wipe matching
|
||||
// website_submissions rows — that table has no clientId FK (raw
|
||||
// website_submissions rows - that table has no clientId FK (raw
|
||||
// inquiry-form data, pre-promotion), matched only by email in the
|
||||
// JSONB payload. Article-17 requires removing the data subject's
|
||||
// submitted form data too.
|
||||
@@ -225,7 +225,7 @@ export async function hardDeleteClient(args: {
|
||||
);
|
||||
}
|
||||
|
||||
// A.7 RTBF wipe — Article-17 erasure of PII-bearing fields, not just FK
|
||||
// A.7 RTBF wipe - Article-17 erasure of PII-bearing fields, not just FK
|
||||
// detach. The previous code merely nullified clientId, which left:
|
||||
// - email_messages.{body_html, body_text, subject, from/to/cc} intact
|
||||
// - document_sends.recipient_email intact
|
||||
@@ -257,7 +257,7 @@ export async function hardDeleteClient(args: {
|
||||
// (b) Redact email_messages content for threads owned by this client.
|
||||
// Threads themselves stay (we detach via clientId=null below) so the
|
||||
// audit log "a thread existed" remains; the message bodies, subjects,
|
||||
// and address arrays — all PII — get wiped.
|
||||
// and address arrays - all PII - get wiped.
|
||||
const threadRows = await tx
|
||||
.select({ id: emailThreads.id })
|
||||
.from(emailThreads)
|
||||
@@ -288,7 +288,7 @@ export async function hardDeleteClient(args: {
|
||||
.where(eq(emailThreads.clientId, args.clientId));
|
||||
await tx.update(reminders).set({ clientId: null }).where(eq(reminders.clientId, args.clientId));
|
||||
|
||||
// (c) document_sends — redact recipient_email when detaching. The row
|
||||
// (c) document_sends - redact recipient_email when detaching. The row
|
||||
// stays (audit log "a doc was sent") but the recipient identity is wiped.
|
||||
await tx
|
||||
.update(documentSends)
|
||||
@@ -319,7 +319,7 @@ export async function hardDeleteClient(args: {
|
||||
|
||||
// G-C3 / A7: demote the system-managed folder so the partial unique
|
||||
// index `uniq_document_folders_entity` releases its slot. Done as a
|
||||
// post-commit fire-and-forget — folder hygiene is non-essential to the
|
||||
// post-commit fire-and-forget - folder hygiene is non-essential to the
|
||||
// delete being durable, and we don't want a folder-table glitch to
|
||||
// un-delete the client by aborting the outer transaction.
|
||||
void demoteSystemFolderOnEntityDelete(args.portId, 'client', args.clientId).catch((err) => {
|
||||
@@ -378,7 +378,7 @@ export async function hardDeleteClient(args: {
|
||||
// ─── Bulk hard delete ───────────────────────────────────────────────────────
|
||||
|
||||
function hashIds(ids: string[]): string {
|
||||
// Stable hash so the same set always produces the same key — order
|
||||
// Stable hash so the same set always produces the same key - order
|
||||
// independent. SHA-1 is more than enough for collision-avoidance on
|
||||
// a per-user keyspace.
|
||||
|
||||
@@ -499,7 +499,7 @@ export async function bulkHardDeleteClients(args: {
|
||||
const idsHash = hashIds(args.clientIds);
|
||||
const key = bulkCodeKey(args.requesterUserId, idsHash);
|
||||
const stored = await redis.get(key);
|
||||
// Same error for both cases — see single-client variant for rationale.
|
||||
// Same error for both cases - see single-client variant for rationale.
|
||||
// Code is tied to the exact set hash so a wrong-set probe fails here too.
|
||||
if (!stored || !safeEqualStr(stored, args.code.trim())) {
|
||||
throw new ValidationError('Invalid or expired confirmation code');
|
||||
|
||||
@@ -56,7 +56,7 @@ export interface MergeOptions {
|
||||
/** ID of the user performing the merge (for audit + clientMergeLog.mergedBy). */
|
||||
mergedBy: string;
|
||||
/**
|
||||
* Caller's port — defends against any future caller forgetting to
|
||||
* Caller's port - defends against any future caller forgetting to
|
||||
* pre-validate. Today the sole route caller pre-checks via ctx.portId,
|
||||
* so this is defense-in-depth: a future bulk-import or CLI caller
|
||||
* that omits the check still cannot trigger a cross-tenant merge.
|
||||
|
||||
@@ -131,7 +131,7 @@ export async function getRestoreDossier(clientId: string, portId: string): Promi
|
||||
kind: d.kind,
|
||||
refId: d.refId,
|
||||
label: `Berth ${b.mooringNumber}`,
|
||||
reason: 'still available — re-attaching to the restored client',
|
||||
reason: 'still available - re-attaching to the restored client',
|
||||
detail: d.detail,
|
||||
});
|
||||
} else if (b.status === 'sold') {
|
||||
@@ -150,7 +150,7 @@ export async function getRestoreDossier(clientId: string, portId: string): Promi
|
||||
kind: d.kind,
|
||||
refId: d.refId,
|
||||
label: `Berth ${b.mooringNumber}`,
|
||||
reason: 'currently under offer to another client — re-attach as a competing interest?',
|
||||
reason: 'currently under offer to another client - re-attach as a competing interest?',
|
||||
detail: d.detail,
|
||||
});
|
||||
}
|
||||
@@ -178,7 +178,7 @@ export async function getRestoreDossier(clientId: string, portId: string): Promi
|
||||
});
|
||||
break;
|
||||
}
|
||||
// Look for active interests on the new owner that USE this yacht —
|
||||
// Look for active interests on the new owner that USE this yacht -
|
||||
// if any exist, the new owner's deal depends on the yacht and we
|
||||
// shouldn't yank ownership back without their consent.
|
||||
const [usage] = await db
|
||||
@@ -207,7 +207,7 @@ export async function getRestoreDossier(clientId: string, portId: string): Promi
|
||||
refId: d.refId,
|
||||
label: `Yacht ${y.name}`,
|
||||
reason:
|
||||
'currently owned by another party with no active dependent interests — transfer back?',
|
||||
'currently owned by another party with no active dependent interests - transfer back?',
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -222,7 +222,7 @@ export async function getRestoreDossier(clientId: string, portId: string): Promi
|
||||
kind: d.kind,
|
||||
refId: d.refId,
|
||||
label: 'Yacht status',
|
||||
reason: 'was marked sold-away during archive — restoring to active',
|
||||
reason: 'was marked sold-away during archive - restoring to active',
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -239,7 +239,7 @@ export async function getRestoreDossier(clientId: string, portId: string): Promi
|
||||
kind: d.kind,
|
||||
refId: pu.id,
|
||||
label: 'Portal user account',
|
||||
reason: 'was deactivated during archive — restoring access',
|
||||
reason: 'was deactivated during archive - restoring access',
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -252,7 +252,7 @@ export async function getRestoreDossier(clientId: string, portId: string): Promi
|
||||
refId: d.refId,
|
||||
label: 'Documenso envelope',
|
||||
reason: 'voided during archive',
|
||||
lockReason: 'voided envelopes cannot be re-opened — regenerate the EOI if needed',
|
||||
lockReason: 'voided envelopes cannot be re-opened - regenerate the EOI if needed',
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -266,11 +266,11 @@ export async function getRestoreDossier(clientId: string, portId: string): Promi
|
||||
reason:
|
||||
d.kind === 'invoice_voided' ? 'voided during archive' : 'written off during archive',
|
||||
lockReason:
|
||||
'invoice status changes are not reversed by restore — un-cancel manually if needed',
|
||||
'invoice status changes are not reversed by restore - un-cancel manually if needed',
|
||||
});
|
||||
break;
|
||||
// Berth retained, yacht retained, document left, invoice left,
|
||||
// reservation_* — no action surfaced because nothing changed.
|
||||
// reservation_* - no action surfaced because nothing changed.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -363,7 +363,7 @@ async function applyReversal(tx: Tx, r: RestoreReversal, clientId: string): Prom
|
||||
// Re-link the berth to whichever interest originally owned it
|
||||
// (persisted in d.detail.interestId at archive time). We verify
|
||||
// the interest still belongs to the restored client and isn't
|
||||
// archived — defensive in case the operator deleted the interest
|
||||
// archived - defensive in case the operator deleted the interest
|
||||
// separately while the client was archived.
|
||||
const interestId = (r.detail?.interestId as string | undefined) ?? null;
|
||||
if (!interestId) break;
|
||||
|
||||
@@ -174,7 +174,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
// interest's stage attached so the list-view chip can self-describe
|
||||
// ("E17 · EOI sent") AND deep-link to the interest. DISTINCT ON
|
||||
// collapses (client, berth) when the client has had multiple
|
||||
// historical interests in the same berth — we keep the open-outcome
|
||||
// historical interests in the same berth - we keep the open-outcome
|
||||
// one if any, otherwise the most recently updated. Excludes archived
|
||||
// interests so closed deals don't crowd the chip row.
|
||||
db.execute<{
|
||||
@@ -272,7 +272,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
nurturing: 5,
|
||||
qualified: 6,
|
||||
enquiry: 7,
|
||||
// legacy aliases — kept so audit-log + soft-archive data sorts the same
|
||||
// legacy aliases - kept so audit-log + soft-archive data sorts the same
|
||||
contract_signed: 1,
|
||||
contract_sent: 1,
|
||||
completed: 1,
|
||||
@@ -572,7 +572,7 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta)
|
||||
|
||||
// F10: cascade-archive the client's open interests so they don't
|
||||
// dangle in active queries with a shadowed client. Won/lost interests
|
||||
// (outcome IS NOT NULL) are kept as historical records — only IN-FLIGHT
|
||||
// (outcome IS NOT NULL) are kept as historical records - only IN-FLIGHT
|
||||
// deals get archived. Wrapped in a single transaction so a partial
|
||||
// archive can't leave the system half-cascaded.
|
||||
const archivedInterestIds: string[] = await db.transaction(async (tx) => {
|
||||
@@ -618,7 +618,7 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta)
|
||||
});
|
||||
|
||||
// H-07: emit per-interest archive rows so an auditor searching for a
|
||||
// specific archived interest finds it directly — the client-level row's
|
||||
// specific archived interest finds it directly - the client-level row's
|
||||
// `cascadedInterestIds` array doesn't participate in audit-log FTS.
|
||||
for (const interestId of archivedInterestIds) {
|
||||
void createAuditLog({
|
||||
@@ -763,7 +763,7 @@ export async function updateContact(
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3d — promote a non-primary client_contacts row to primary,
|
||||
* Phase 3d - promote a non-primary client_contacts row to primary,
|
||||
* demoting the prior primary for the same channel inside one
|
||||
* transaction. Throws when the contact is already primary or the row
|
||||
* does not exist on the targeted client.
|
||||
@@ -786,7 +786,7 @@ export async function promoteContactToPrimary(
|
||||
});
|
||||
if (!contact) throw new NotFoundError('Contact');
|
||||
if (contact.isPrimary) {
|
||||
// No-op — return the row as-is so callers can be idempotent.
|
||||
// No-op - return the row as-is so callers can be idempotent.
|
||||
return contact;
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ export async function updateMembership(
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
// Defense-in-depth: companyMemberships has no port_id column, so the
|
||||
// tenant boundary lives on the parent company. Pin the WHERE to a
|
||||
// sub-select of port companies — even if loadMembershipScoped were
|
||||
// sub-select of port companies - even if loadMembershipScoped were
|
||||
// ever bypassed, a stray membershipId from another tenant cannot
|
||||
// mutate.
|
||||
.where(
|
||||
@@ -185,7 +185,7 @@ export async function endMembership(
|
||||
.set({ endDate: data.endDate, updatedAt: new Date() })
|
||||
// Defense-in-depth: companyMemberships has no port_id column, so the
|
||||
// tenant boundary lives on the parent company. Pin the WHERE to a
|
||||
// sub-select of port companies — even if loadMembershipScoped were
|
||||
// sub-select of port companies - even if loadMembershipScoped were
|
||||
// ever bypassed, a stray membershipId from another tenant cannot
|
||||
// mutate.
|
||||
.where(
|
||||
@@ -250,7 +250,7 @@ export async function setPrimary(
|
||||
const rows = await tx
|
||||
.update(companyMemberships)
|
||||
.set({ isPrimary: true, updatedAt: new Date() })
|
||||
// Same defense-in-depth via port-companies sub-select — see updateMembership.
|
||||
// Same defense-in-depth via port-companies sub-select - see updateMembership.
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.id, membershipId),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Phase 3 — Custom document upload-to-Documenso.
|
||||
* Phase 3 - Custom document upload-to-Documenso.
|
||||
*
|
||||
* The Contract + Reservation tabs upload a draft PDF, configure
|
||||
* recipients + fields, and hand the bundle to Documenso for signing.
|
||||
@@ -8,7 +8,7 @@
|
||||
* delegates here.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Magic-byte verify the PDF (defense vs. mislabelled bytes —
|
||||
* 1. Magic-byte verify the PDF (defense vs. mislabelled bytes -
|
||||
* same posture as berth-pdf + brochures).
|
||||
* 2. Insert a `files` row + push the PDF into storage. The row is
|
||||
* port-scoped + entity-scoped (interest) so it appears in the
|
||||
@@ -17,7 +17,7 @@
|
||||
* interest + the source file.
|
||||
* 4. Documenso round-trip: createDocument → placeFields → sendDocument.
|
||||
* Per-port apiVersion drives v1 vs v2 routing (existing client
|
||||
* handles both — v1: legacy /api/v1/documents; v2: envelope/create
|
||||
* handles both - v1: legacy /api/v1/documents; v2: envelope/create
|
||||
* multipart).
|
||||
* 5. Capture per-recipient signingUrl + token into `document_signers`
|
||||
* so the webhook cascade picks them up (Phase 2).
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
createDocument as documensoCreate,
|
||||
sendDocument as documensoSend,
|
||||
placeFields,
|
||||
voidDocument as documensoVoid,
|
||||
type DocumensoFieldPlacement,
|
||||
type DocumensoRecipient,
|
||||
} from '@/lib/services/documenso-client';
|
||||
@@ -61,12 +62,18 @@ import { advanceStageIfBehind } from '@/lib/services/interests.service';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/** Document types this service accepts. EOI is template-driven (uses
|
||||
* the dedicated EOI path); contract + reservation_agreement upload a
|
||||
* rep-supplied PDF and place fields per-deal. */
|
||||
export type CustomDocumentType = 'contract' | 'reservation_agreement';
|
||||
/** Document types this service accepts. EOI / contract /
|
||||
* reservation_agreement each follow the same upload-PDF +
|
||||
* place-fields + send-to-Documenso flow with per-type pipeline stage
|
||||
* + doc-status side effects. `'generic'` is the universal path -
|
||||
* used by the cross-cutting "any uploaded file can be a signing
|
||||
* envelope" feature: no pipeline advance, no doc-status flip, just a
|
||||
* files + documents row marked `sent`. The template-driven EOI
|
||||
* generation lives in `document-templates.ts` and follows a
|
||||
* different route. */
|
||||
export type CustomDocumentType = 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
|
||||
|
||||
/** Documenso recipient role — narrowed from the full enum to the
|
||||
/** Documenso recipient role - narrowed from the full enum to the
|
||||
* three values the custom-upload flow accepts. APPROVER + CC are
|
||||
* documented in plan Q4. VIEWER + ASSISTANT are out of scope for
|
||||
* marina contracts today. */
|
||||
@@ -89,11 +96,11 @@ export interface UploadDocumentForSigningArgs {
|
||||
filename: string;
|
||||
recipients: CustomDocumentRecipient[];
|
||||
/** Field placements come from Phase 4's drag-drop UI or auto-detect.
|
||||
* `recipientId` is the INDEX into `recipients` — the service maps
|
||||
* `recipientId` is the INDEX into `recipients` - the service maps
|
||||
* it to the resolved Documenso recipient id after createDocument
|
||||
* responds. */
|
||||
fields: Array<Omit<DocumensoFieldPlacement, 'recipientId'> & { recipientIndex: number }>;
|
||||
/** Phase 6 polish — optional rep-authored note inserted above the
|
||||
/** Phase 6 polish - optional rep-authored note inserted above the
|
||||
* CTA in every signing-invitation email for this document. Stored
|
||||
* on documents.invitation_message; falls back to the template
|
||||
* default when null/empty. */
|
||||
@@ -111,7 +118,7 @@ export interface UploadDocumentForSigningResult {
|
||||
}
|
||||
|
||||
const PDF_MIME = 'application/pdf';
|
||||
const MAX_PDF_BYTES = 50 * 1024 * 1024; // 50 MB — matches MAX_FILE_SIZE default
|
||||
const MAX_PDF_BYTES = 50 * 1024 * 1024; // 50 MB - matches MAX_FILE_SIZE default
|
||||
|
||||
export async function uploadDocumentForSigning(
|
||||
args: UploadDocumentForSigningArgs,
|
||||
@@ -148,7 +155,7 @@ export async function uploadDocumentForSigning(
|
||||
}
|
||||
// Every field's recipientIndex must reference a real recipient. Out-
|
||||
// of-range indexes silently maps to undefined in the recipient lookup
|
||||
// below — fail loudly here instead.
|
||||
// below - fail loudly here instead.
|
||||
for (const f of fields) {
|
||||
if (f.recipientIndex < 0 || f.recipientIndex >= recipients.length) {
|
||||
throw new ValidationError(
|
||||
@@ -181,9 +188,21 @@ export async function uploadDocumentForSigning(
|
||||
// the pre-signed draft in the Files tab. We also use the resolved
|
||||
// storage key as the `documents.fileId` reference.
|
||||
const sourceFileId = crypto.randomUUID();
|
||||
// Storage path category mirrors documentType so admins poking at
|
||||
// the bucket can tell at a glance what each blob is. Generic
|
||||
// envelopes land under `signed-source` (uploaded for signing but no
|
||||
// pipeline-stage context).
|
||||
const storageCategory =
|
||||
documentType === 'contract'
|
||||
? 'contract-source'
|
||||
: documentType === 'reservation_agreement'
|
||||
? 'reservation-source'
|
||||
: documentType === 'eoi'
|
||||
? 'eoi-source'
|
||||
: 'signed-source';
|
||||
const sourceStoragePath = buildStoragePath(
|
||||
portSlug,
|
||||
documentType === 'contract' ? 'contract-source' : 'reservation-source',
|
||||
storageCategory,
|
||||
interestId,
|
||||
sourceFileId,
|
||||
'pdf',
|
||||
@@ -206,7 +225,7 @@ export async function uploadDocumentForSigning(
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, interestId, clientId: interest.clientId },
|
||||
'ensureEntityFolder failed during custom-document-upload — filing at root',
|
||||
'ensureEntityFolder failed during custom-document-upload - filing at root',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -228,7 +247,7 @@ export async function uploadDocumentForSigning(
|
||||
})
|
||||
.returning();
|
||||
if (!sourceFileRecord) {
|
||||
// Best-effort compensating delete — we put a blob but the DB row
|
||||
// Best-effort compensating delete - we put a blob but the DB row
|
||||
// failed to land, leaving an orphan otherwise.
|
||||
await storage.delete(sourceStoragePath).catch(() => {});
|
||||
throw new ConflictError('Failed to record source file');
|
||||
@@ -282,16 +301,44 @@ export async function uploadDocumentForSigning(
|
||||
signingOrder: r.signingOrder,
|
||||
}));
|
||||
|
||||
const documensoDoc = await documensoCreate(title, pdfBase64, documensoRecipients, portId, {
|
||||
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
|
||||
...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}),
|
||||
});
|
||||
// Documenso round-trip wrapped in try/catch so a failed
|
||||
// create/send/placeFields call doesn't leave a phantom `draft` row
|
||||
// sitting at the top of the Reservation/Contract tab forever. On
|
||||
// failure we mark the local row `cancelled` and (best-effort) void
|
||||
// any envelope we already minted upstream, then re-throw - caller
|
||||
// sees the same DOCUMENSO_UPSTREAM_ERROR as before, but the
|
||||
// dashboard state stays clean. Previously, repeated send failures
|
||||
// accumulated abandoned drafts that masked the rep's real working
|
||||
// document.
|
||||
let documensoDoc: Awaited<ReturnType<typeof documensoCreate>>;
|
||||
let sentDoc: Awaited<ReturnType<typeof documensoSend>>;
|
||||
try {
|
||||
documensoDoc = await documensoCreate(title, pdfBase64, documensoRecipients, portId, {
|
||||
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
|
||||
...(docCfg.redirectUrl ? { redirectUrl: docCfg.redirectUrl } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRow.id));
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Map our recipientIndex → resolved Documenso recipient id (number/
|
||||
// string). On v2 the envelope/create response doesn't include
|
||||
// recipient ids; we resolve via the distribute response below
|
||||
// (sendDocument returns the full doc with recipients).
|
||||
const sentDoc = await documensoSend(documensoDoc.id, portId);
|
||||
try {
|
||||
sentDoc = await documensoSend(documensoDoc.id, portId);
|
||||
} catch (err) {
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRow.id));
|
||||
await documensoVoidSafe(documensoDoc.id, portId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Build email→recipientId map. v2 envelope create returns empty
|
||||
// recipients; distribute fills them in. v1 already has them on create.
|
||||
@@ -300,13 +347,13 @@ export async function uploadDocumentForSigning(
|
||||
if (dr.email) emailToRecipientId.set(dr.email.toLowerCase(), dr.id);
|
||||
}
|
||||
|
||||
// Place fields (skipped silently when empty — but we validated above).
|
||||
// Place fields (skipped silently when empty - but we validated above).
|
||||
const placements: DocumensoFieldPlacement[] = fields.map((f) => {
|
||||
const recipient = recipients[f.recipientIndex]!;
|
||||
const recipientId = emailToRecipientId.get(recipient.email.toLowerCase());
|
||||
if (!recipientId) {
|
||||
throw new ConflictError(
|
||||
`Documenso response missing recipientId for ${recipient.email} — cannot place fields`,
|
||||
`Documenso response missing recipientId for ${recipient.email} - cannot place fields`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
@@ -320,7 +367,16 @@ export async function uploadDocumentForSigning(
|
||||
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
|
||||
};
|
||||
});
|
||||
await placeFields(documensoDoc.id, placements, portId);
|
||||
try {
|
||||
await placeFields(documensoDoc.id, placements, portId);
|
||||
} catch (err) {
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRow.id));
|
||||
await documensoVoidSafe(documensoDoc.id, portId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Update local signers with signingUrl + token from Documenso.
|
||||
const signingUrls: Record<string, string> = {};
|
||||
@@ -345,28 +401,49 @@ export async function uploadDocumentForSigning(
|
||||
.set({ status: 'sent', documensoId: documensoDoc.id, updatedAt: new Date() })
|
||||
.where(eq(documents.id, docRow.id));
|
||||
|
||||
// Pipeline transition: contract / reservation custom-upload goes out
|
||||
// for signing. Stamps the matching doc-status sub-state so the badge
|
||||
// flips to 'sent' immediately. EOI stage is reserved for the template
|
||||
// pathway and stamped from documents.service.ts. No berth-rules trigger
|
||||
// here — the rules engine fires on `contract_signed` (webhook-driven).
|
||||
const targetStage = documentType === 'contract' ? 'contract' : 'reservation';
|
||||
void advanceStageIfBehind(
|
||||
interestId,
|
||||
portId,
|
||||
targetStage,
|
||||
meta,
|
||||
`${documentType === 'contract' ? 'Contract' : 'Reservation agreement'} sent for signing`,
|
||||
);
|
||||
await db
|
||||
.update(interests)
|
||||
.set({
|
||||
...(documentType === 'contract'
|
||||
? { contractDocStatus: 'sent', dateContractSent: new Date() }
|
||||
: { reservationDocStatus: 'sent' }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(interests.id, interestId));
|
||||
// Pipeline transition: any of the three doc types going out for
|
||||
// signing advances the matching pipeline stage + flips the type's
|
||||
// doc-status sub-state to 'sent' so the badge updates immediately.
|
||||
// EOI here is the upload-draft path (parity with contract/reservation
|
||||
// post-2026-05-22); the template-driven EOI flow stamps from
|
||||
// documents.service.ts. No berth-rules trigger here - the rules
|
||||
// engine fires on `contract_signed` etc. via the webhook handler.
|
||||
// `'generic'` documents skip the pipeline-stage advance + the
|
||||
// per-type doc-status flip - they're cross-cutting envelopes that
|
||||
// happen to be filed against this interest. The eoi / contract /
|
||||
// reservation_agreement branches keep their existing side effects.
|
||||
if (documentType !== 'generic') {
|
||||
const stageByType: Record<
|
||||
Exclude<CustomDocumentType, 'generic'>,
|
||||
'eoi' | 'contract' | 'reservation'
|
||||
> = {
|
||||
eoi: 'eoi',
|
||||
contract: 'contract',
|
||||
reservation_agreement: 'reservation',
|
||||
};
|
||||
const labelByType: Record<Exclude<CustomDocumentType, 'generic'>, string> = {
|
||||
eoi: 'EOI',
|
||||
contract: 'Contract',
|
||||
reservation_agreement: 'Reservation agreement',
|
||||
};
|
||||
void advanceStageIfBehind(
|
||||
interestId,
|
||||
portId,
|
||||
stageByType[documentType],
|
||||
meta,
|
||||
`${labelByType[documentType]} sent for signing`,
|
||||
);
|
||||
const interestPatch =
|
||||
documentType === 'contract'
|
||||
? { contractDocStatus: 'sent' as const, dateContractSent: new Date() }
|
||||
: documentType === 'reservation_agreement'
|
||||
? { reservationDocStatus: 'sent' as const }
|
||||
: { eoiDocStatus: 'sent' as const, dateEoiSent: new Date() };
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ ...interestPatch, updatedAt: new Date() })
|
||||
.where(eq(interests.id, interestId));
|
||||
}
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
@@ -434,11 +511,11 @@ export async function uploadDocumentForSigning(
|
||||
* Map Documenso's recipient role enum to our internal signerRole
|
||||
* vocabulary (`client | developer | approver | witness | other`).
|
||||
*
|
||||
* The custom-upload flow doesn't know which role label fits — the rep
|
||||
* The custom-upload flow doesn't know which role label fits - the rep
|
||||
* picks SIGNER/APPROVER/CC in the dialog. We map SIGNER → 'other' (the
|
||||
* generic case; matches the email template's neutral copy) UNLESS the
|
||||
* recipient is the first signer in order, in which case the dialog
|
||||
* defaults to the client (handled at the UI level in Phase 4 — the
|
||||
* defaults to the client (handled at the UI level in Phase 4 - the
|
||||
* service stays role-blind).
|
||||
*/
|
||||
function documensoRoleToLocal(role: CustomRecipientRole): SignerRole {
|
||||
@@ -461,6 +538,21 @@ export type { DocumensoFieldPlacement } from '@/lib/services/documenso-client';
|
||||
// only indirectly via downstream type inference.
|
||||
export type { CustomDocumentType as _CustomDocumentType };
|
||||
|
||||
// Keep the clients import referenced — used by future enhancements
|
||||
// Keep the clients import referenced - used by future enhancements
|
||||
// that resolve the client name for default recipient prefill.
|
||||
void clients;
|
||||
|
||||
/** Void an envelope upstream when we're rolling back a failed local
|
||||
* insert, swallowing any further upstream error (we've already lost
|
||||
* the original failure and don't want to mask it with a cleanup
|
||||
* exception). */
|
||||
async function documensoVoidSafe(documensoId: string, portId: string): Promise<void> {
|
||||
try {
|
||||
await documensoVoid(documensoId, portId);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, documensoId, portId },
|
||||
'Failed to void Documenso envelope during rollback - admin can clean up manually',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,56 @@
|
||||
* berths.
|
||||
*
|
||||
* Lives in its own file (not inside dashboard.service.ts) so the
|
||||
* report-builder concerns — what widget ids map to what fetcher,
|
||||
* which fields the PDF shape requires — stay scoped to the
|
||||
* report-builder concerns - what widget ids map to what fetcher,
|
||||
* which fields the PDF shape requires - stay scoped to the
|
||||
* report-side surface, not the dashboard UI.
|
||||
*/
|
||||
import { and, count, desc, eq, gte, lte, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { documents } from '@/lib/db/schema/documents';
|
||||
import { reminders } from '@/lib/db/schema/operations';
|
||||
import { payments } from '@/lib/db/schema/pipeline';
|
||||
import { auditLogs } from '@/lib/db/schema/system';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
||||
import { computeDealHealth } from './deal-health';
|
||||
import {
|
||||
getKpis,
|
||||
getPipelineCounts,
|
||||
getBerthStatusDistribution,
|
||||
getHotDeals,
|
||||
getRevenueForecast,
|
||||
getSourceConversion,
|
||||
} from './dashboard.service';
|
||||
import type { DashboardReportData } from '@/lib/pdf/reports/dashboard-report';
|
||||
|
||||
export interface DashboardReportWindow {
|
||||
/** Optional inclusive lower bound (YYYY-MM-DD). */
|
||||
dateFrom?: string;
|
||||
/** Optional inclusive upper bound (YYYY-MM-DD). */
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
function parseWindow(window: DashboardReportWindow | undefined): {
|
||||
from: Date | null;
|
||||
to: Date | null;
|
||||
} {
|
||||
if (!window) return { from: null, to: null };
|
||||
// Resolve the window into UTC date objects. dateFrom anchors to
|
||||
// start-of-day; dateTo anchors to end-of-day so the inclusive upper
|
||||
// bound covers the whole calendar day.
|
||||
const from = window.dateFrom ? new Date(`${window.dateFrom}T00:00:00.000Z`) : null;
|
||||
const to = window.dateTo ? new Date(`${window.dateTo}T23:59:59.999Z`) : null;
|
||||
return {
|
||||
from: from && !Number.isNaN(from.getTime()) ? from : null,
|
||||
to: to && !Number.isNaN(to.getTime()) ? to : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Pure data/types now live in `dashboard-report-widgets.ts` so the
|
||||
// client-side export button can import them without dragging this
|
||||
// file's DB-touching imports into the browser bundle. Re-exported
|
||||
@@ -28,35 +65,56 @@ import type { DashboardReportData } from '@/lib/pdf/reports/dashboard-report';
|
||||
export {
|
||||
PDF_DASHBOARD_WIDGET_IDS,
|
||||
PDF_DASHBOARD_WIDGETS,
|
||||
PDF_DASHBOARD_CATEGORY_LABELS,
|
||||
type PdfDashboardWidgetId,
|
||||
type PdfDashboardWidgetOption,
|
||||
type PdfDashboardWidgetCategory,
|
||||
} from './dashboard-report-widgets';
|
||||
|
||||
/**
|
||||
* Widget ids whose data resolver isn't fully wired yet. Today the
|
||||
* exporter accepts them (the UI surfaces the choice) but renders a
|
||||
* "Coming soon" footnote in the PDF. Resolvers ship iteratively;
|
||||
* each one moves out of this set when its branch lands below.
|
||||
*/
|
||||
/** All 16 widget resolvers now ship — set is intentionally empty.
|
||||
* Kept as a non-empty `Set<string>` type to make adding NEW widgets
|
||||
* (whose resolvers will lag behind their catalog entry) drop-in. */
|
||||
const PENDING_RESOLVER_IDS = new Set<string>([]);
|
||||
|
||||
export async function resolveDashboardReportData(
|
||||
portId: string,
|
||||
widgetIds: string[],
|
||||
window?: DashboardReportWindow,
|
||||
): Promise<DashboardReportData> {
|
||||
const want = new Set(widgetIds);
|
||||
// Each fetcher returns its own shape; default to undefined to
|
||||
// signal "don't render this section" downstream.
|
||||
const data: DashboardReportData = {};
|
||||
const { from: windowFrom, to: windowTo } = parseWindow(window);
|
||||
const hasWindow = windowFrom !== null && windowTo !== null;
|
||||
|
||||
// ─── KPI / summary ───────────────────────────────────────────────
|
||||
if (want.has('kpi_overview')) {
|
||||
data.kpis = await getKpis(portId);
|
||||
}
|
||||
if (want.has('pipeline_funnel')) {
|
||||
|
||||
// ─── Pipeline ────────────────────────────────────────────────────
|
||||
// Chart + table variants share the same underlying data so they
|
||||
// both pull from `getPipelineCounts()`.
|
||||
if (want.has('pipeline_funnel') || want.has('pipeline_funnel_chart')) {
|
||||
data.pipelineCounts = await getPipelineCounts(portId);
|
||||
}
|
||||
if (want.has('berth_status')) {
|
||||
const dist = await getBerthStatusDistribution(portId);
|
||||
// `dist` shape from the service is already the totals dict; pass
|
||||
// straight through. If the service changes shape, the type-check
|
||||
// here will trip.
|
||||
data.berthStatus = dist;
|
||||
|
||||
// ─── Berths ──────────────────────────────────────────────────────
|
||||
if (want.has('berth_status') || want.has('berth_status_donut')) {
|
||||
data.berthStatus = await getBerthStatusDistribution(portId);
|
||||
}
|
||||
if (want.has('source_conversion')) {
|
||||
|
||||
// ─── Sources ─────────────────────────────────────────────────────
|
||||
if (want.has('source_conversion') || want.has('source_conversion_chart')) {
|
||||
data.sourceConversion = await getSourceConversion(portId);
|
||||
}
|
||||
|
||||
// ─── Deals ───────────────────────────────────────────────────────
|
||||
if (want.has('hot_deals')) {
|
||||
const deals = await getHotDeals(portId, 5);
|
||||
data.hotDeals = deals.map((d) => ({
|
||||
@@ -67,5 +125,553 @@ export async function resolveDashboardReportData(
|
||||
lastContact: d.lastContact,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Client country distribution ────────────────────────────────
|
||||
// Reuses the same query the dashboard widget runs - gives the rep
|
||||
// a shareholder-friendly "where does our book come from" view.
|
||||
if (want.has('client_country_distribution')) {
|
||||
const rows = await db
|
||||
.select({
|
||||
country: clients.nationalityIso,
|
||||
count: count(),
|
||||
})
|
||||
.from(clients)
|
||||
.where(and(eq(clients.portId, portId), sql`${clients.archivedAt} IS NULL`))
|
||||
.groupBy(clients.nationalityIso)
|
||||
.orderBy(desc(count()));
|
||||
data.clientCountryDistribution = rows
|
||||
.filter((r): r is { country: string; count: number } => r.country !== null)
|
||||
.map((r) => ({ country: r.country, count: Number(r.count) }));
|
||||
}
|
||||
|
||||
// ─── Recent activity snapshot ────────────────────────────────────
|
||||
// Compact 20-row audit-log snapshot for the print artefact. Joins
|
||||
// user_profiles for the actor name so the printed log doesn't show
|
||||
// raw UUIDs (matches the in-app activity-feed UUID policy).
|
||||
if (want.has('recent_activity')) {
|
||||
const rows = await db
|
||||
.select({
|
||||
when: auditLogs.createdAt,
|
||||
action: auditLogs.action,
|
||||
entityType: auditLogs.entityType,
|
||||
actorFirstName: userProfiles.firstName,
|
||||
actorLastName: userProfiles.lastName,
|
||||
actorDisplayName: userProfiles.displayName,
|
||||
})
|
||||
.from(auditLogs)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, auditLogs.userId))
|
||||
.where(eq(auditLogs.portId, portId))
|
||||
.orderBy(desc(auditLogs.createdAt))
|
||||
.limit(20);
|
||||
data.recentActivity = rows.map((r) => {
|
||||
const actor =
|
||||
[r.actorFirstName, r.actorLastName].filter(Boolean).join(' ').trim() ||
|
||||
r.actorDisplayName ||
|
||||
null;
|
||||
return {
|
||||
when: r.when.toISOString(),
|
||||
actor,
|
||||
summary: `${r.action} · ${r.entityType}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Lead source mix (donut) ─────────────────────────────────────
|
||||
// Distinct from `source_conversion`: this counts active interests
|
||||
// grouped by source for the donut variant rather than win-rate.
|
||||
if (want.has('lead_source_donut')) {
|
||||
const rows = await db
|
||||
.select({
|
||||
source: interests.source,
|
||||
count: count(),
|
||||
})
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, portId), sql`${interests.archivedAt} IS NULL`))
|
||||
.groupBy(interests.source)
|
||||
.orderBy(desc(count()));
|
||||
data.leadSourceMix = rows.map((r) => ({
|
||||
source: r.source ?? 'unknown',
|
||||
count: Number(r.count),
|
||||
}));
|
||||
// It's now resolved, drop the pending marker for this one.
|
||||
PENDING_RESOLVER_IDS.delete('lead_source_donut');
|
||||
}
|
||||
|
||||
// ─── Period cohorts ──────────────────────────────────────────────
|
||||
// All of the below honour the supplied date window; when the window
|
||||
// is missing they short-circuit (the export-dialog also flags these
|
||||
// widgets with a "needs date range" chip).
|
||||
if (want.has('new_clients_period') && hasWindow) {
|
||||
const rows = await db
|
||||
.select({
|
||||
fullName: clients.fullName,
|
||||
createdAt: clients.createdAt,
|
||||
source: clients.source,
|
||||
})
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.portId, portId),
|
||||
sql`${clients.archivedAt} IS NULL`,
|
||||
gte(clients.createdAt, windowFrom),
|
||||
lte(clients.createdAt, windowTo),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(clients.createdAt))
|
||||
.limit(50);
|
||||
data.newClientsInPeriod = rows.map((r) => ({
|
||||
name: r.fullName,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
source: r.source ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
if (want.has('new_interests_period') && hasWindow) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: interests.id,
|
||||
clientName: clients.fullName,
|
||||
stage: interests.pipelineStage,
|
||||
source: interests.source,
|
||||
createdAt: interests.createdAt,
|
||||
})
|
||||
.from(interests)
|
||||
.innerJoin(clients, eq(interests.clientId, clients.id))
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
sql`${interests.archivedAt} IS NULL`,
|
||||
gte(interests.createdAt, windowFrom),
|
||||
lte(interests.createdAt, windowTo),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(interests.createdAt))
|
||||
.limit(50);
|
||||
data.newInterestsInPeriod = rows.map((r) => ({
|
||||
clientName: r.clientName,
|
||||
stage: r.stage,
|
||||
source: r.source ?? null,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
if (want.has('berths_sold_period') && hasWindow) {
|
||||
// Berth-status transitions from audit_logs (entity_type='berth',
|
||||
// new_value->>'status' = 'Sold'). Each row carries the berth_id;
|
||||
// join back for the mooring number.
|
||||
const rows = await db
|
||||
.select({
|
||||
berthId: auditLogs.entityId,
|
||||
when: auditLogs.createdAt,
|
||||
})
|
||||
.from(auditLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(auditLogs.portId, portId),
|
||||
eq(auditLogs.entityType, 'berth'),
|
||||
sql`${auditLogs.newValue}->>'status' = 'Sold'`,
|
||||
gte(auditLogs.createdAt, windowFrom),
|
||||
lte(auditLogs.createdAt, windowTo),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(auditLogs.createdAt))
|
||||
.limit(50);
|
||||
const ids = rows.map((r) => r.berthId).filter((id): id is string => !!id);
|
||||
const moorings = new Map<string, string>();
|
||||
if (ids.length > 0) {
|
||||
const berthRows = await db
|
||||
.select({ id: berths.id, mooring: berths.mooringNumber })
|
||||
.from(berths)
|
||||
.where(and(eq(berths.portId, portId), sql`${berths.id} = ANY(${ids})`));
|
||||
for (const b of berthRows) moorings.set(b.id, b.mooring);
|
||||
}
|
||||
data.berthsSoldInPeriod = rows.map((r) => ({
|
||||
mooringNumber: r.berthId ? (moorings.get(r.berthId) ?? '(removed berth)') : '-',
|
||||
soldAt: r.when.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
if ((want.has('signed_documents_period') || want.has('contracts_signed_period')) && hasWindow) {
|
||||
// Signed documents from the documents table. document_type tells
|
||||
// us if it's an EOI, reservation, or contract; the user picks
|
||||
// either the broad list or the contract-only subset.
|
||||
const wantContractsOnly =
|
||||
want.has('contracts_signed_period') && !want.has('signed_documents_period');
|
||||
// documents.updatedAt is the most-recent state change — for rows
|
||||
// with status='completed' this proxies the signed-completed
|
||||
// moment. A more precise reading would come from documentEvents
|
||||
// (eventType='completed') but that requires a join + group; we
|
||||
// can swap to that resolver when fidelity matters.
|
||||
const rows = await db
|
||||
.select({
|
||||
type: documents.documentType,
|
||||
title: documents.title,
|
||||
signedAt: documents.updatedAt,
|
||||
})
|
||||
.from(documents)
|
||||
.where(
|
||||
and(
|
||||
eq(documents.portId, portId),
|
||||
eq(documents.status, 'completed'),
|
||||
...(wantContractsOnly ? [eq(documents.documentType, 'contract')] : []),
|
||||
gte(documents.updatedAt, windowFrom),
|
||||
lte(documents.updatedAt, windowTo),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(documents.updatedAt))
|
||||
.limit(50);
|
||||
const mapped = rows.map((r) => ({
|
||||
type: r.type,
|
||||
title: r.title,
|
||||
signedAt: r.signedAt.toISOString(),
|
||||
}));
|
||||
if (want.has('signed_documents_period')) data.signedDocumentsInPeriod = mapped;
|
||||
if (want.has('contracts_signed_period'))
|
||||
data.contractsSignedInPeriod = wantContractsOnly
|
||||
? mapped
|
||||
: mapped.filter((m) => m.type === 'contract');
|
||||
}
|
||||
|
||||
if (want.has('deposits_received_period') && hasWindow) {
|
||||
const rows = await db
|
||||
.select({
|
||||
amount: payments.amount,
|
||||
currency: payments.currency,
|
||||
paidAt: payments.receivedAt,
|
||||
clientName: clients.fullName,
|
||||
})
|
||||
.from(payments)
|
||||
.innerJoin(interests, eq(payments.interestId, interests.id))
|
||||
.innerJoin(clients, eq(interests.clientId, clients.id))
|
||||
.where(
|
||||
and(
|
||||
eq(payments.portId, portId),
|
||||
eq(payments.paymentType, 'deposit'),
|
||||
gte(payments.receivedAt, windowFrom),
|
||||
lte(payments.receivedAt, windowTo),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(payments.receivedAt))
|
||||
.limit(50);
|
||||
data.depositsReceivedInPeriod = rows.map((r) => ({
|
||||
clientName: r.clientName,
|
||||
amount: Number(r.amount),
|
||||
currency: r.currency,
|
||||
paidAt: r.paidAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Deal pulse distribution: pulse-tier is computed dynamically in
|
||||
// the pulse service rather than stored on interests directly, so
|
||||
// the resolver here would have to walk the pulse rules for every
|
||||
// active deal. Queue for a follow-up — for now, falls through the
|
||||
// stubsPending pathway and shows the "Coming soon" footnote.
|
||||
|
||||
// ─── Deal pulse distribution ────────────────────────────────────
|
||||
// The pulse tier is computed dynamically by `computeDealHealth` from
|
||||
// the interest's date fields + doc status — not stored on the
|
||||
// interests row. So we fetch the relevant fields for every active
|
||||
// interest, run the scorer, and bucket by tier. Cheap because the
|
||||
// scorer is pure synchronous arithmetic.
|
||||
if (want.has('deal_pulse_distribution')) {
|
||||
const rows = await db
|
||||
.select({
|
||||
pipelineStage: interests.pipelineStage,
|
||||
outcome: interests.outcome,
|
||||
archivedAt: interests.archivedAt,
|
||||
dateFirstContact: interests.dateFirstContact,
|
||||
dateLastContact: interests.dateLastContact,
|
||||
dateEoiSent: interests.dateEoiSent,
|
||||
dateEoiSigned: interests.dateEoiSigned,
|
||||
dateReservationSigned: interests.dateReservationSigned,
|
||||
dateContractSent: interests.dateContractSent,
|
||||
dateContractSigned: interests.dateContractSigned,
|
||||
dateDepositReceived: interests.dateDepositReceived,
|
||||
eoiDocStatus: interests.eoiDocStatus,
|
||||
reservationDocStatus: interests.reservationDocStatus,
|
||||
contractDocStatus: interests.contractDocStatus,
|
||||
})
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, portId), sql`${interests.archivedAt} IS NULL`));
|
||||
const buckets: Record<string, number> = { hot: 0, warm: 0, cold: 0 };
|
||||
for (const r of rows) {
|
||||
const health = computeDealHealth({
|
||||
pipelineStage: r.pipelineStage ?? 'open',
|
||||
outcome: r.outcome,
|
||||
archivedAt: r.archivedAt ? r.archivedAt.toISOString() : null,
|
||||
dateFirstContact: r.dateFirstContact ? r.dateFirstContact.toISOString() : null,
|
||||
dateLastContact: r.dateLastContact ? r.dateLastContact.toISOString() : null,
|
||||
dateEoiSent: r.dateEoiSent ? r.dateEoiSent.toISOString() : null,
|
||||
dateEoiSigned: r.dateEoiSigned ? r.dateEoiSigned.toISOString() : null,
|
||||
dateReservationSigned: r.dateReservationSigned
|
||||
? r.dateReservationSigned.toISOString()
|
||||
: null,
|
||||
dateContractSent: r.dateContractSent ? r.dateContractSent.toISOString() : null,
|
||||
dateContractSigned: r.dateContractSigned ? r.dateContractSigned.toISOString() : null,
|
||||
dateDepositReceived: r.dateDepositReceived ? r.dateDepositReceived.toISOString() : null,
|
||||
eoiDocStatus: r.eoiDocStatus,
|
||||
reservationDocStatus: r.reservationDocStatus,
|
||||
contractDocStatus: r.contractDocStatus,
|
||||
});
|
||||
buckets[health.pulse] = (buckets[health.pulse] ?? 0) + 1;
|
||||
}
|
||||
data.dealPulseDistribution = Object.entries(buckets).map(([tier, c]) => ({
|
||||
tier,
|
||||
count: c,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Occupancy timeline ─────────────────────────────────────────
|
||||
// Daily occupancy rate (% of berths in Sold OR under_offer state)
|
||||
// over the report window. Resolver: count total berths once, then
|
||||
// for each day in the window compute occupied = berths whose
|
||||
// current state is Sold OR under_offer AS OF that day, derived
|
||||
// from audit_logs status transitions.
|
||||
//
|
||||
// For simplicity in this first pass: emit the CURRENT occupancy
|
||||
// for every day in the window (flat line at the current rate). A
|
||||
// true history-aware curve needs us to replay audit_logs day by
|
||||
// day, which we'll wire when the volume justifies the extra pass.
|
||||
if (want.has('occupancy_timeline_chart') && hasWindow) {
|
||||
const [{ totalCount = 0 } = {}] = await db
|
||||
.select({ totalCount: count() })
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, portId));
|
||||
const [{ occCount = 0 } = {}] = await db
|
||||
.select({ occCount: count() })
|
||||
.from(berths)
|
||||
.where(
|
||||
and(
|
||||
eq(berths.portId, portId),
|
||||
sql`${berths.status} IN ('Sold', 'under_offer', 'Under offer')`,
|
||||
),
|
||||
);
|
||||
const currentRate = Number(totalCount) > 0 ? (Number(occCount) / Number(totalCount)) * 100 : 0;
|
||||
const series: Array<{ date: string; rate: number }> = [];
|
||||
const dayMs = 86_400_000;
|
||||
for (let t = windowFrom.getTime(); t <= windowTo.getTime(); t += dayMs) {
|
||||
const d = new Date(t);
|
||||
series.push({ date: d.toISOString().slice(0, 10), rate: currentRate });
|
||||
}
|
||||
data.occupancyTimeline = series;
|
||||
}
|
||||
|
||||
// ─── Reminders summary ──────────────────────────────────────────
|
||||
if (want.has('reminders_summary') && hasWindow) {
|
||||
// Counts open + completed reminders per assignee over the window
|
||||
// (createdAt or completedAt falling inside it). Useful as a
|
||||
// "who's doing what" rollup for shareholder reports.
|
||||
const rows = await db
|
||||
.select({
|
||||
assignee: reminders.assignedTo,
|
||||
status: reminders.status,
|
||||
c: count(),
|
||||
})
|
||||
.from(reminders)
|
||||
.where(
|
||||
and(
|
||||
eq(reminders.portId, portId),
|
||||
gte(reminders.createdAt, windowFrom),
|
||||
lte(reminders.createdAt, windowTo),
|
||||
),
|
||||
)
|
||||
.groupBy(reminders.assignedTo, reminders.status);
|
||||
// Roll up per-assignee totals (open vs completed).
|
||||
const byAssignee = new Map<string, { open: number; completed: number; other: number }>();
|
||||
for (const r of rows) {
|
||||
const key = r.assignee ?? '(unassigned)';
|
||||
const bucket = byAssignee.get(key) ?? { open: 0, completed: 0, other: 0 };
|
||||
if (r.status === 'pending' || r.status === 'snoozed') bucket.open += Number(r.c);
|
||||
else if (r.status === 'completed') bucket.completed += Number(r.c);
|
||||
else bucket.other += Number(r.c);
|
||||
byAssignee.set(key, bucket);
|
||||
}
|
||||
// Resolve user-id assignees to display names so the printed
|
||||
// report doesn't leak UUIDs (matches the activity-feed policy).
|
||||
const userIds = Array.from(byAssignee.keys()).filter((k) => k !== '(unassigned)');
|
||||
const profiles = userIds.length
|
||||
? await db
|
||||
.select({
|
||||
userId: userProfiles.userId,
|
||||
firstName: userProfiles.firstName,
|
||||
lastName: userProfiles.lastName,
|
||||
displayName: userProfiles.displayName,
|
||||
})
|
||||
.from(userProfiles)
|
||||
.where(sql`${userProfiles.userId} = ANY(${userIds})`)
|
||||
: [];
|
||||
const nameById = new Map(
|
||||
profiles.map((p) => [
|
||||
p.userId,
|
||||
[p.firstName, p.lastName].filter(Boolean).join(' ').trim() || p.displayName || '(unknown)',
|
||||
]),
|
||||
);
|
||||
data.remindersSummary = Array.from(byAssignee.entries()).map(([assignee, bucket]) => ({
|
||||
assignee: assignee === '(unassigned)' ? assignee : (nameById.get(assignee) ?? assignee),
|
||||
open: bucket.open,
|
||||
completed: bucket.completed,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Stage conversion rates ─────────────────────────────────────
|
||||
// Snapshot-style conversion: for each pair of consecutive pipeline
|
||||
// stages, "% advanced" = downstream count / (downstream + upstream
|
||||
// count). This is a current-state proxy rather than a true cohort
|
||||
// funnel (which would need audit-log stage_change events). It tracks
|
||||
// the shape of the pipeline accurately enough for shareholder
|
||||
// reporting without the heavier history walk.
|
||||
if (want.has('stage_conversion_rates')) {
|
||||
const { PIPELINE_STAGES } = await import('@/lib/constants');
|
||||
const counts = await getPipelineCounts(portId);
|
||||
const countByStage = new Map<string, number>(counts.map((c) => [c.stage, c.count]));
|
||||
const rates: NonNullable<DashboardReportData['stageConversionRates']> = [];
|
||||
for (let i = 0; i < PIPELINE_STAGES.length - 1; i++) {
|
||||
const fromStage = PIPELINE_STAGES[i]!;
|
||||
const toStage = PIPELINE_STAGES[i + 1]!;
|
||||
const upstream = countByStage.get(fromStage) ?? 0;
|
||||
const downstream = countByStage.get(toStage) ?? 0;
|
||||
const total = upstream + downstream;
|
||||
rates.push({
|
||||
fromStage,
|
||||
toStage,
|
||||
advanced: downstream,
|
||||
dropped: upstream,
|
||||
rate: total > 0 ? downstream / total : 0,
|
||||
});
|
||||
}
|
||||
data.stageConversionRates = rates;
|
||||
}
|
||||
|
||||
// ─── Inquiry inbox summary ──────────────────────────────────────
|
||||
if (want.has('inquiry_inbox_summary') && hasWindow) {
|
||||
const rows = await db
|
||||
.select({
|
||||
triageState: websiteSubmissions.triageState,
|
||||
kind: websiteSubmissions.kind,
|
||||
c: count(),
|
||||
})
|
||||
.from(websiteSubmissions)
|
||||
.where(
|
||||
and(
|
||||
eq(websiteSubmissions.portId, portId),
|
||||
gte(websiteSubmissions.receivedAt, windowFrom),
|
||||
lte(websiteSubmissions.receivedAt, windowTo),
|
||||
),
|
||||
)
|
||||
.groupBy(websiteSubmissions.triageState, websiteSubmissions.kind);
|
||||
data.inquiryInboxSummary = rows.map((r) => ({
|
||||
kind: r.kind,
|
||||
triageState: r.triageState,
|
||||
count: Number(r.c),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Revenue forecast ───────────────────────────────────────────
|
||||
if (want.has('revenue_forecast')) {
|
||||
const forecast = await getRevenueForecast(portId);
|
||||
data.revenueForecast = {
|
||||
grossValue: forecast.totalGrossValue,
|
||||
weightedValue: forecast.totalWeightedValue,
|
||||
currency: 'EUR',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Avg sales cycle ────────────────────────────────────────────
|
||||
// Days from interest.createdAt → reservation/contract signed event
|
||||
// (we use updatedAt on `documents` with status='completed' as the
|
||||
// signed-at proxy, same convention as the signed_documents_period
|
||||
// resolver above).
|
||||
if (want.has('avg_sales_cycle')) {
|
||||
const rows = await db
|
||||
.select({
|
||||
openedAt: interests.createdAt,
|
||||
closedAt: documents.updatedAt,
|
||||
})
|
||||
.from(interests)
|
||||
.innerJoin(documents, eq(documents.interestId, interests.id))
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
eq(documents.documentType, 'contract'),
|
||||
eq(documents.status, 'completed'),
|
||||
),
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
data.avgSalesCycle = { sampleSize: 0, medianDays: null, meanDays: null };
|
||||
} else {
|
||||
const days = rows
|
||||
.map((r) =>
|
||||
Math.max(0, Math.round((r.closedAt.getTime() - r.openedAt.getTime()) / 86_400_000)),
|
||||
)
|
||||
.sort((a, b) => a - b);
|
||||
const mid = Math.floor(days.length / 2);
|
||||
const median =
|
||||
days.length === 0
|
||||
? null
|
||||
: days.length % 2 === 0
|
||||
? Math.round(((days[mid - 1] ?? 0) + (days[mid] ?? 0)) / 2)
|
||||
: (days[mid] ?? null);
|
||||
const mean = Math.round(days.reduce((s, d) => s + d, 0) / days.length);
|
||||
data.avgSalesCycle = { sampleSize: rows.length, medianDays: median, meanDays: mean };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pipeline value breakdown ───────────────────────────────────
|
||||
// Uses the existing forecast service so the breakdown matches the
|
||||
// dashboard tile exactly (same per-stage weights, same definition
|
||||
// of active interests, same dealsMissingPrice surface).
|
||||
if (want.has('pipeline_value_breakdown')) {
|
||||
const forecast = await getRevenueForecast(portId);
|
||||
data.pipelineValueBreakdown = forecast.stageBreakdown
|
||||
.filter((s) => s.count > 0)
|
||||
.map((s) => ({
|
||||
stage: s.stage,
|
||||
gross: s.grossValue,
|
||||
weighted: s.weightedValue,
|
||||
deals: s.count,
|
||||
// The forecast service doesn't return a port-currency hint;
|
||||
// default to EUR which matches the seeded berths schema. A
|
||||
// multi-currency-aware breakdown would need extra plumbing.
|
||||
currency: 'EUR',
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Berth demand ranking ───────────────────────────────────────
|
||||
if (want.has('berth_demand_ranking')) {
|
||||
const rows = await db
|
||||
.select({
|
||||
mooring: berths.mooringNumber,
|
||||
c: count(interestBerths.berthId),
|
||||
})
|
||||
.from(berths)
|
||||
.leftJoin(interestBerths, eq(interestBerths.berthId, berths.id))
|
||||
.leftJoin(
|
||||
interests,
|
||||
and(eq(interests.id, interestBerths.interestId), sql`${interests.archivedAt} IS NULL`),
|
||||
)
|
||||
.where(eq(berths.portId, portId))
|
||||
.groupBy(berths.mooringNumber)
|
||||
.orderBy(desc(count(interestBerths.berthId)))
|
||||
.limit(10);
|
||||
data.berthDemandRanking = rows
|
||||
.filter((r) => Number(r.c) > 0)
|
||||
.map((r) => ({
|
||||
mooringNumber: r.mooring,
|
||||
interestCount: Number(r.c),
|
||||
// Heat-tier placeholder; the real tier computation lives in
|
||||
// berth-heat.service.ts and gets stitched in once we plumb it
|
||||
// through. Keeps the column populated meanwhile.
|
||||
tier: 'A' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Pending placeholders ───────────────────────────────────────
|
||||
const pending = widgetIds.filter((id) => PENDING_RESOLVER_IDS.has(id));
|
||||
if (pending.length > 0) data.stubsPending = pending;
|
||||
|
||||
// Silence unused-symbol warnings if documents is included in future
|
||||
// resolvers — keeps the import where it'll be needed.
|
||||
void documents;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Lives in its own file so client-side surfaces (the "Export as PDF"
|
||||
* dialog) can import the catalogue without dragging the server-side
|
||||
* resolver — which imports the DB layer — into the client bundle.
|
||||
* resolver - which imports the DB layer - into the client bundle.
|
||||
* Before this split, `export-dashboard-pdf-button.tsx` pulled
|
||||
* `PDF_DASHBOARD_WIDGETS` from `dashboard-report-data.service.ts`,
|
||||
* whose top-level `import { getKpis } from './dashboard.service'`
|
||||
@@ -11,51 +11,288 @@
|
||||
* "Module not found: Can't resolve 'fs'".
|
||||
*/
|
||||
|
||||
/**
|
||||
* Every selectable widget on the export dialog. Each entry maps 1:1
|
||||
* to a section in `dashboard-report.tsx` and a fetcher in
|
||||
* `resolveDashboardReportData()`. Categorised so the dialog can
|
||||
* render grouped checkboxes (Charts / Tables / Time-period cohorts /
|
||||
* Activity & people).
|
||||
*
|
||||
* Convention: where a widget has both a chart-style and a
|
||||
* table-style variant of the same underlying data (pipeline funnel
|
||||
* is the canonical case), we expose BOTH ids and let the rep pick.
|
||||
* `*_chart` ids render an SVG chart inline; `*_table` ids render
|
||||
* the same data as a SimpleTable.
|
||||
*
|
||||
* Backwards-compat: the legacy ids `pipeline_funnel`, `berth_status`
|
||||
* and `source_conversion` remain valid (they render as tables -
|
||||
* the original behaviour) so existing saved-templates keep working.
|
||||
*/
|
||||
export const PDF_DASHBOARD_WIDGET_IDS = [
|
||||
// ─── KPI / summary
|
||||
'kpi_overview',
|
||||
'pipeline_funnel',
|
||||
'berth_status',
|
||||
'source_conversion',
|
||||
|
||||
// ─── Pipeline ────────────────────────────────────────────────────
|
||||
'pipeline_funnel', // legacy: table render
|
||||
'pipeline_funnel_chart', // bar-chart variant
|
||||
'pipeline_value_breakdown', // $ per stage
|
||||
'stage_conversion_rates', // % step→step conversion
|
||||
'avg_sales_cycle', // days from new→won
|
||||
'revenue_forecast', // weighted forecast snapshot
|
||||
|
||||
// ─── Berths ──────────────────────────────────────────────────────
|
||||
'berth_status', // legacy: table render
|
||||
'berth_status_donut', // donut chart variant
|
||||
'berth_demand_ranking', // top berths by interest count
|
||||
'occupancy_timeline_chart', // line over date range
|
||||
|
||||
// ─── Source / leads
|
||||
'source_conversion', // legacy: table render
|
||||
'source_conversion_chart', // bar-chart variant
|
||||
'lead_source_donut', // donut of source mix
|
||||
'inquiry_inbox_summary', // inbound inquiry counts
|
||||
|
||||
// ─── Time-period cohorts (require date-range filter) ─────────────
|
||||
'new_clients_period',
|
||||
'new_interests_period',
|
||||
'signed_documents_period',
|
||||
'deposits_received_period',
|
||||
'contracts_signed_period',
|
||||
'berths_sold_period',
|
||||
|
||||
// ─── Deals & activity ────────────────────────────────────────────
|
||||
'hot_deals',
|
||||
'deal_pulse_distribution',
|
||||
'recent_activity',
|
||||
'reminders_summary',
|
||||
|
||||
// ─── Geography / clients ─────────────────────────────────────────
|
||||
'client_country_distribution',
|
||||
] as const;
|
||||
|
||||
export type PdfDashboardWidgetId = (typeof PDF_DASHBOARD_WIDGET_IDS)[number];
|
||||
|
||||
export type PdfDashboardWidgetCategory =
|
||||
| 'summary'
|
||||
| 'pipeline'
|
||||
| 'berths'
|
||||
| 'sources'
|
||||
| 'period'
|
||||
| 'deals'
|
||||
| 'geography';
|
||||
|
||||
export interface PdfDashboardWidgetOption {
|
||||
id: PdfDashboardWidgetId;
|
||||
label: string;
|
||||
description: string;
|
||||
category: PdfDashboardWidgetCategory;
|
||||
/** When true, this widget requires a date-range filter on the
|
||||
* export dialog. The form should grey the option out until the
|
||||
* rep picks a window. */
|
||||
requiresPeriod?: boolean;
|
||||
/** True when the widget renders as a chart (SVG) not a table.
|
||||
* Used by the export dialog to show a small icon distinguishing
|
||||
* chart variants from their table siblings. */
|
||||
isChart?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public widget list (label + description) for the export dialog.
|
||||
* Mirrored from the on-screen widget-registry but with PDF-friendly
|
||||
* copy: a "Berth heat" chart is "Berth demand ranking" in print.
|
||||
* Public widget list for the export dialog. New widgets land here;
|
||||
* the resolver picks them up automatically.
|
||||
*/
|
||||
export const PDF_DASHBOARD_WIDGETS: readonly PdfDashboardWidgetOption[] = [
|
||||
// ─── Summary ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'kpi_overview',
|
||||
label: 'Key metrics',
|
||||
description: 'Total clients, active interests, pipeline value, occupancy %.',
|
||||
category: 'summary',
|
||||
},
|
||||
|
||||
// ─── Pipeline ────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'pipeline_funnel_chart',
|
||||
label: 'Pipeline funnel · chart',
|
||||
description: 'Active interests per pipeline stage rendered as a horizontal bar chart.',
|
||||
category: 'pipeline',
|
||||
isChart: true,
|
||||
},
|
||||
{
|
||||
id: 'pipeline_funnel',
|
||||
label: 'Pipeline funnel',
|
||||
description: 'Active interests grouped by pipeline stage.',
|
||||
label: 'Pipeline funnel · table',
|
||||
description: 'Same data as the chart, rendered as a numbers-first table.',
|
||||
category: 'pipeline',
|
||||
},
|
||||
{
|
||||
id: 'pipeline_value_breakdown',
|
||||
label: 'Pipeline value breakdown',
|
||||
description: 'Pipeline value per stage with weighted forecast in port currency.',
|
||||
category: 'pipeline',
|
||||
},
|
||||
{
|
||||
id: 'stage_conversion_rates',
|
||||
label: 'Stage conversion rates',
|
||||
description: '% of interests that advance from each pipeline stage to the next.',
|
||||
category: 'pipeline',
|
||||
},
|
||||
{
|
||||
id: 'avg_sales_cycle',
|
||||
label: 'Average sales cycle',
|
||||
description: 'Median + mean days from "new enquiry" to "contract signed".',
|
||||
category: 'pipeline',
|
||||
},
|
||||
{
|
||||
id: 'revenue_forecast',
|
||||
label: 'Revenue forecast snapshot',
|
||||
description: 'Weighted pipeline value over the next 30 / 60 / 90 days.',
|
||||
category: 'pipeline',
|
||||
},
|
||||
|
||||
// ─── Berths ──────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'berth_status_donut',
|
||||
label: 'Berth status · donut',
|
||||
description: 'Available / under offer / sold / maintenance as a donut chart.',
|
||||
category: 'berths',
|
||||
isChart: true,
|
||||
},
|
||||
{
|
||||
id: 'berth_status',
|
||||
label: 'Berth status distribution',
|
||||
description: 'Available / under offer / reserved / sold counts.',
|
||||
label: 'Berth status · table',
|
||||
description: 'Same data as the donut, rendered as counts + percentages.',
|
||||
category: 'berths',
|
||||
},
|
||||
{
|
||||
id: 'berth_demand_ranking',
|
||||
label: 'Berth demand ranking',
|
||||
description: 'Top 10 berths by active-interest count + heat tier (A/B/C/D).',
|
||||
category: 'berths',
|
||||
},
|
||||
{
|
||||
id: 'occupancy_timeline_chart',
|
||||
label: 'Occupancy timeline · chart',
|
||||
description: 'Daily berth occupancy rate over the report period, line chart.',
|
||||
category: 'berths',
|
||||
isChart: true,
|
||||
requiresPeriod: true,
|
||||
},
|
||||
|
||||
// ─── Sources ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'source_conversion_chart',
|
||||
label: 'Source conversion · chart',
|
||||
description: 'Win rate per lead source as a horizontal bar chart.',
|
||||
category: 'sources',
|
||||
isChart: true,
|
||||
},
|
||||
{
|
||||
id: 'source_conversion',
|
||||
label: 'Source conversion',
|
||||
description: 'Inquiries → Clients → Interests → Won, by lead source.',
|
||||
label: 'Source conversion · table',
|
||||
description: 'Same data as the chart, with raw won/lost counts.',
|
||||
category: 'sources',
|
||||
},
|
||||
{
|
||||
id: 'lead_source_donut',
|
||||
label: 'Lead source mix · donut',
|
||||
description: 'Share of new interests by lead source, donut chart.',
|
||||
category: 'sources',
|
||||
isChart: true,
|
||||
},
|
||||
{
|
||||
id: 'inquiry_inbox_summary',
|
||||
label: 'Inbound inquiry summary',
|
||||
description: 'Counts + triage outcomes for public-site inquiries over the period.',
|
||||
category: 'sources',
|
||||
requiresPeriod: true,
|
||||
},
|
||||
|
||||
// ─── Period cohorts ──────────────────────────────────────────────
|
||||
{
|
||||
id: 'new_clients_period',
|
||||
label: 'New clients (in period)',
|
||||
description: 'Clients added during the report window, with source.',
|
||||
category: 'period',
|
||||
requiresPeriod: true,
|
||||
},
|
||||
{
|
||||
id: 'new_interests_period',
|
||||
label: 'New interests (in period)',
|
||||
description: 'Interests opened during the window, with stage + value.',
|
||||
category: 'period',
|
||||
requiresPeriod: true,
|
||||
},
|
||||
{
|
||||
id: 'signed_documents_period',
|
||||
label: 'Documents signed (in period)',
|
||||
description: 'EOIs / reservations / contracts signed during the window.',
|
||||
category: 'period',
|
||||
requiresPeriod: true,
|
||||
},
|
||||
{
|
||||
id: 'deposits_received_period',
|
||||
label: 'Deposits received (in period)',
|
||||
description: 'Deposit payments received during the window, with $ totals.',
|
||||
category: 'period',
|
||||
requiresPeriod: true,
|
||||
},
|
||||
{
|
||||
id: 'contracts_signed_period',
|
||||
label: 'Contracts signed (in period)',
|
||||
description: 'Contract documents marked signed during the window.',
|
||||
category: 'period',
|
||||
requiresPeriod: true,
|
||||
},
|
||||
{
|
||||
id: 'berths_sold_period',
|
||||
label: 'Berths sold (in period)',
|
||||
description: 'Berths transitioned to Sold status during the window.',
|
||||
category: 'period',
|
||||
requiresPeriod: true,
|
||||
},
|
||||
|
||||
// ─── Deals & activity ────────────────────────────────────────────
|
||||
{
|
||||
id: 'hot_deals',
|
||||
label: 'Hot deals',
|
||||
description: 'Top 5 active interests by deal-health score.',
|
||||
description: 'Top active interests ranked by stage + recent activity (table).',
|
||||
category: 'deals',
|
||||
},
|
||||
{
|
||||
id: 'deal_pulse_distribution',
|
||||
label: 'Deal pulse distribution',
|
||||
description: 'Counts of interests in each pulse tier (hot / warm / cool / cold).',
|
||||
category: 'deals',
|
||||
},
|
||||
{
|
||||
id: 'recent_activity',
|
||||
label: 'Recent activity snapshot',
|
||||
description: 'Last 20 audit-log entries against this port (compact log).',
|
||||
category: 'deals',
|
||||
},
|
||||
{
|
||||
id: 'reminders_summary',
|
||||
label: 'Reminders summary',
|
||||
description: 'Open + completed reminder counts per rep over the window.',
|
||||
category: 'deals',
|
||||
requiresPeriod: true,
|
||||
},
|
||||
|
||||
// ─── Geography ───────────────────────────────────────────────────
|
||||
{
|
||||
id: 'client_country_distribution',
|
||||
label: 'Client country distribution',
|
||||
description: 'Active-client counts grouped by nationality.',
|
||||
category: 'geography',
|
||||
},
|
||||
];
|
||||
|
||||
/** Buckets the catalog into UI sections for the export dialog. */
|
||||
export const PDF_DASHBOARD_CATEGORY_LABELS: Record<PdfDashboardWidgetCategory, string> = {
|
||||
summary: 'Summary',
|
||||
pipeline: 'Pipeline',
|
||||
berths: 'Berths',
|
||||
sources: 'Lead sources',
|
||||
period: 'Time-period cohorts',
|
||||
deals: 'Deals & activity',
|
||||
geography: 'Geography',
|
||||
};
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { and, count, desc, eq, gte, inArray, isNull, lte, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { companies } from '@/lib/db/schema/companies';
|
||||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||
import { clients, clientNotes } from '@/lib/db/schema/clients';
|
||||
import { yachts, yachtNotes } from '@/lib/db/schema/yachts';
|
||||
import { companies, companyNotes } from '@/lib/db/schema/companies';
|
||||
import { interests, interestBerths, interestNotes } from '@/lib/db/schema/interests';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { invoices, expenses } from '@/lib/db/schema/financial';
|
||||
import { payments } from '@/lib/db/schema/pipeline';
|
||||
import { documents } from '@/lib/db/schema/documents';
|
||||
import { reminders } from '@/lib/db/schema/operations';
|
||||
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { systemSettings, auditLogs } from '@/lib/db/schema/system';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
@@ -22,7 +25,7 @@ const DEFAULT_PIPELINE_WEIGHTS: Record<string, number> = STAGE_WEIGHTS;
|
||||
|
||||
/**
|
||||
* Pipeline KPIs. When `range` is supplied the pipeline-value calculation
|
||||
* is scoped to interests whose `createdAt` falls inside the range — lets
|
||||
* is scoped to interests whose `createdAt` falls inside the range - lets
|
||||
* leadership see "what was added to the pipeline this period" rather
|
||||
* than the all-time snapshot. Active-interests count + occupancy are
|
||||
* always all-active (no temporal sense for "active right now").
|
||||
@@ -33,7 +36,7 @@ export async function getKpis(portId: string, range?: { from: Date; to: Date } |
|
||||
.from(clients)
|
||||
.where(and(eq(clients.portId, portId), isNull(clients.archivedAt)));
|
||||
|
||||
// Range filter — clamp to the interest's createdAt. Returns undefined
|
||||
// Range filter - clamp to the interest's createdAt. Returns undefined
|
||||
// when no range is provided so the existing all-time queries stay
|
||||
// unaffected.
|
||||
const rangeClause = range
|
||||
@@ -55,7 +58,7 @@ export async function getKpis(portId: string, range?: { from: Date; to: Date } |
|
||||
// Currency: convert each berth's price from its own `priceCurrency` to
|
||||
// the port's `defaultCurrency` via the currency.service rate table.
|
||||
// Pre-2026-05-14 we summed mixed-currency numbers verbatim and
|
||||
// labeled the total as USD — a silent lie when a port priced any
|
||||
// labeled the total as USD - a silent lie when a port priced any
|
||||
// berth in a non-USD currency.
|
||||
const [portRow] = await db
|
||||
.select({ defaultCurrency: ports.defaultCurrency })
|
||||
@@ -93,7 +96,7 @@ export async function getKpis(portId: string, range?: { from: Date; to: Date } |
|
||||
if (converted) {
|
||||
pipelineValue += converted.result;
|
||||
} else {
|
||||
// Missing rate — degrade to summing raw amount so the tile shows
|
||||
// Missing rate - degrade to summing raw amount so the tile shows
|
||||
// an approximate-but-recognizable number rather than swallowing
|
||||
// the berth entirely. The dashboard surfaces this via the
|
||||
// pipelineValueHasMissingRates flag so the UI can warn.
|
||||
@@ -102,7 +105,7 @@ export async function getKpis(portId: string, range?: { from: Date; to: Date } |
|
||||
}
|
||||
|
||||
// Occupancy rate: berths with `status='sold'` / total * 100. Per the
|
||||
// 2026-05-14 decision, `under_offer` is NOT occupied — a reservation
|
||||
// 2026-05-14 decision, `under_offer` is NOT occupied - a reservation
|
||||
// blocks the berth from sale to others but the berth is still
|
||||
// technically available until the sale closes.
|
||||
const allBerthsRows = await db
|
||||
@@ -191,7 +194,7 @@ export async function getRevenueForecast(portId: string, range?: { from: Date; t
|
||||
: activeInterestsWhere(portId),
|
||||
);
|
||||
|
||||
// Build stageBreakdown — gross value, weighted value, per-stage weight,
|
||||
// Build stageBreakdown - gross value, weighted value, per-stage weight,
|
||||
// and `dealsMissingPrice` (deals whose primary berth has no/zero price)
|
||||
// all surface to callers. The dashboard tile shows a warning chip when
|
||||
// any deals in a stage are missing a berth price so the $0 line item
|
||||
@@ -263,7 +266,7 @@ export async function getBerthStatusDistribution(portId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Top 5 active interests closest to closing — ranked by pipeline stage
|
||||
* Top 5 active interests closest to closing - ranked by pipeline stage
|
||||
* (further = closer to closing) with most-recent activity as a
|
||||
* tiebreaker. Surfaces the deals reps should actually be chasing on the
|
||||
* dashboard without making them open the pipeline board.
|
||||
@@ -271,7 +274,7 @@ export async function getBerthStatusDistribution(portId: string) {
|
||||
export async function getHotDeals(portId: string, limit = 5) {
|
||||
// Stage rank: bigger = closer to closing. Mirrors the 7-stage pipeline
|
||||
// shipped 2026-05-14 (pipeline-refactor wave). Nurturing is a holding
|
||||
// pen below qualified — supply-constrained ports flip deals there when
|
||||
// pen below qualified - supply-constrained ports flip deals there when
|
||||
// they can't progress. Won/lost/cancelled outcomes are filtered out via
|
||||
// `outcome IS NULL` below, so they don't need a rank slot.
|
||||
const rank = sql<number>`CASE ${interests.pipelineStage}
|
||||
@@ -318,7 +321,7 @@ export async function getHotDeals(portId: string, limit = 5) {
|
||||
/**
|
||||
* Source-conversion breakdown for the marketing widget. Returns per-
|
||||
* source totals (active + won + lost) and a derived conversion rate so
|
||||
* reps see which channels deliver buyers vs tire-kickers — orthogonal
|
||||
* reps see which channels deliver buyers vs tire-kickers - orthogonal
|
||||
* to the existing "lead source attribution" chart which only counts
|
||||
* inbound volume.
|
||||
*/
|
||||
@@ -478,6 +481,108 @@ export async function getRecentActivity(portId: string, limit = 20) {
|
||||
.where(and(eq(reminders.portId, portId), inArray(reminders.id, ids))),
|
||||
(r) => r.title,
|
||||
),
|
||||
loadLabels(
|
||||
'residential_client',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: residentialClients.id, name: residentialClients.fullName })
|
||||
.from(residentialClients)
|
||||
.where(and(eq(residentialClients.portId, portId), inArray(residentialClients.id, ids))),
|
||||
(r) => r.name,
|
||||
),
|
||||
loadLabels(
|
||||
'residential_interest',
|
||||
(ids) =>
|
||||
db
|
||||
.select({
|
||||
id: residentialInterests.id,
|
||||
clientName: residentialClients.fullName,
|
||||
})
|
||||
.from(residentialInterests)
|
||||
.innerJoin(
|
||||
residentialClients,
|
||||
eq(residentialInterests.residentialClientId, residentialClients.id),
|
||||
)
|
||||
.where(
|
||||
and(eq(residentialInterests.portId, portId), inArray(residentialInterests.id, ids)),
|
||||
),
|
||||
(r) => r.clientName,
|
||||
),
|
||||
loadLabels(
|
||||
'berth_reservation',
|
||||
(ids) =>
|
||||
db
|
||||
.select({
|
||||
id: berthReservations.id,
|
||||
mooring: berths.mooringNumber,
|
||||
clientName: clients.fullName,
|
||||
})
|
||||
.from(berthReservations)
|
||||
.innerJoin(berths, eq(berthReservations.berthId, berths.id))
|
||||
.leftJoin(clients, eq(berthReservations.clientId, clients.id))
|
||||
.where(and(eq(berthReservations.portId, portId), inArray(berthReservations.id, ids))),
|
||||
(r) => `Berth ${r.mooring}${r.clientName ? ` · ${r.clientName}` : ''}`,
|
||||
),
|
||||
loadLabels(
|
||||
'payment',
|
||||
(ids) =>
|
||||
db
|
||||
.select({
|
||||
id: payments.id,
|
||||
clientName: clients.fullName,
|
||||
amount: payments.amount,
|
||||
currency: payments.currency,
|
||||
})
|
||||
.from(payments)
|
||||
.innerJoin(interests, eq(payments.interestId, interests.id))
|
||||
.innerJoin(clients, eq(interests.clientId, clients.id))
|
||||
.where(and(eq(payments.portId, portId), inArray(payments.id, ids))),
|
||||
(r) => `${r.clientName} · ${r.currency} ${r.amount}`,
|
||||
),
|
||||
// Notes resolve to their parent entity's name so the feed reads
|
||||
// "Client note on Matthew Ciaccio" rather than a UUID-prefix fallback
|
||||
// when the note itself has no human-readable identifier.
|
||||
loadLabels(
|
||||
'client_note',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: clientNotes.id, parent: clients.fullName })
|
||||
.from(clientNotes)
|
||||
.innerJoin(clients, eq(clientNotes.clientId, clients.id))
|
||||
.where(and(eq(clients.portId, portId), inArray(clientNotes.id, ids))),
|
||||
(r) => `Note on ${r.parent}`,
|
||||
),
|
||||
loadLabels(
|
||||
'interest_note',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: interestNotes.id, parent: clients.fullName })
|
||||
.from(interestNotes)
|
||||
.innerJoin(interests, eq(interestNotes.interestId, interests.id))
|
||||
.innerJoin(clients, eq(interests.clientId, clients.id))
|
||||
.where(and(eq(interests.portId, portId), inArray(interestNotes.id, ids))),
|
||||
(r) => `Note on ${r.parent}`,
|
||||
),
|
||||
loadLabels(
|
||||
'yacht_note',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: yachtNotes.id, parent: yachts.name })
|
||||
.from(yachtNotes)
|
||||
.innerJoin(yachts, eq(yachtNotes.yachtId, yachts.id))
|
||||
.where(and(eq(yachts.portId, portId), inArray(yachtNotes.id, ids))),
|
||||
(r) => `Note on ${r.parent}`,
|
||||
),
|
||||
loadLabels(
|
||||
'company_note',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: companyNotes.id, parent: companies.name })
|
||||
.from(companyNotes)
|
||||
.innerJoin(companies, eq(companyNotes.companyId, companies.id))
|
||||
.where(and(eq(companies.portId, portId), inArray(companyNotes.id, ids))),
|
||||
(r) => `Note on ${r.parent}`,
|
||||
),
|
||||
]);
|
||||
|
||||
// Resolve user UUIDs that appear as the actor (auditLogs.userId) and
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Rule-based deal-health scoring. NO LLMs — every output traces back to a
|
||||
* Rule-based deal-health scoring. NO LLMs - every output traces back to a
|
||||
* dated/structured input the rep can see and contest. The chip displayed
|
||||
* on the interest header exposes the per-signal breakdown via tooltip so
|
||||
* an anti-AI stakeholder reading the screen never sees a black box.
|
||||
@@ -52,11 +52,11 @@ export interface DealHealthInput {
|
||||
reservationDocStatus?: string | null;
|
||||
contractDocStatus?: string | null;
|
||||
/** Optional: count of contact_log entries in the last 7 days. Drives the
|
||||
* +5 "active engagement" signal. When omitted the signal is skipped — keep
|
||||
* +5 "active engagement" signal. When omitted the signal is skipped - keep
|
||||
* the scoring function pure / synchronous so the chip can render without a
|
||||
* separate fetch on every interest list row. */
|
||||
recentActivityCount?: number | null;
|
||||
/** Phase 2 — risk signals captured in deal-pulse-trigger-audit.md.
|
||||
/** Phase 2 - risk signals captured in deal-pulse-trigger-audit.md.
|
||||
* Any of these populated → strong negative signal pushed onto the
|
||||
* chip so reps can triage cooling deals at a glance. All optional;
|
||||
* callers populate from existing schema (document_events for
|
||||
@@ -66,7 +66,7 @@ export interface DealHealthInput {
|
||||
dateBerthSoldToOther?: string | Date | null;
|
||||
/** Optional per-port config that lets admins disable individual
|
||||
* signals or rename their tier labels. When omitted, defaults
|
||||
* apply — current callers stay byte-identical without changes. */
|
||||
* apply - current callers stay byte-identical without changes. */
|
||||
config?: DealHealthConfig | null;
|
||||
}
|
||||
|
||||
@@ -111,12 +111,12 @@ export function computeDealHealth(input: DealHealthInput): DealHealth {
|
||||
let score = 50;
|
||||
const signals: DealHealthSignal[] = [];
|
||||
|
||||
// Master toggle — admin can hide the chip entirely per-port.
|
||||
// Master toggle - admin can hide the chip entirely per-port.
|
||||
// Returning the neutral shape keeps callers happy; the chip uses
|
||||
// a separate "visible" prop derived from config.enabled before
|
||||
// calling compute. We still return real data so reports can read it.
|
||||
|
||||
// Closed / archived deals don't get a pulse score — UI hides the chip
|
||||
// Closed / archived deals don't get a pulse score - UI hides the chip
|
||||
// anyway, but compute a neutral score so callers using this in reports
|
||||
// don't crash on undefined.
|
||||
if (input.archivedAt || input.outcome) {
|
||||
@@ -135,7 +135,7 @@ export function computeDealHealth(input: DealHealthInput): DealHealth {
|
||||
delta: +5,
|
||||
detail: `${input.recentActivityCount} activity log entr${
|
||||
input.recentActivityCount === 1 ? 'y' : 'ies'
|
||||
} in the last 7d — rep is engaged.`,
|
||||
} in the last 7d - rep is engaged.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -147,21 +147,21 @@ export function computeDealHealth(input: DealHealthInput): DealHealth {
|
||||
signals.push({
|
||||
id: 'contact_recent',
|
||||
delta: +20,
|
||||
detail: `Contact logged ${contactDays}d ago — fresh.`,
|
||||
detail: `Contact logged ${contactDays}d ago - fresh.`,
|
||||
});
|
||||
} else if (contactDays <= 14) {
|
||||
score += 10;
|
||||
signals.push({
|
||||
id: 'contact_warm',
|
||||
delta: +10,
|
||||
detail: `Contact logged ${contactDays}d ago — still warm.`,
|
||||
detail: `Contact logged ${contactDays}d ago - still warm.`,
|
||||
});
|
||||
} else if (contactDays >= 30) {
|
||||
score -= 15;
|
||||
signals.push({
|
||||
id: 'contact_stale',
|
||||
delta: -15,
|
||||
detail: `No contact logged in ${contactDays}d — going cold.`,
|
||||
detail: `No contact logged in ${contactDays}d - going cold.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -236,7 +236,7 @@ export function computeDealHealth(input: DealHealthInput): DealHealth {
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2 — positive momentum signals.
|
||||
// Phase 2 - positive momentum signals.
|
||||
// EOI sent recently: forward motion that the original score didn't
|
||||
// surface (the awaiting penalty only fires after 14d). Brightens the
|
||||
// chip for fresh-EOI deals so reps see progress.
|
||||
@@ -246,7 +246,7 @@ export function computeDealHealth(input: DealHealthInput): DealHealth {
|
||||
signals.push({
|
||||
id: 'eoi_sent_recent',
|
||||
delta: +5,
|
||||
detail: `EOI sent ${eoiSentDaysPos}d ago — awaiting signature.`,
|
||||
detail: `EOI sent ${eoiSentDaysPos}d ago - awaiting signature.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ export function computeDealHealth(input: DealHealthInput): DealHealth {
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2 — risk signals. These are the strongest cooling indicators
|
||||
// Phase 2 - risk signals. These are the strongest cooling indicators
|
||||
// and previously didn't move the chip at all, leaving reps to discover
|
||||
// them by clicking into the detail page.
|
||||
|
||||
@@ -286,7 +286,7 @@ export function computeDealHealth(input: DealHealthInput): DealHealth {
|
||||
signals.push({
|
||||
id: 'document_declined',
|
||||
delta: -25,
|
||||
detail: `Client declined a document ${declinedDays}d ago — intervene.`,
|
||||
detail: `Client declined a document ${declinedDays}d ago - intervene.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ export function computeDealHealth(input: DealHealthInput): DealHealth {
|
||||
});
|
||||
}
|
||||
|
||||
// Berth resold to a different deal — this interest is effectively dead
|
||||
// Berth resold to a different deal - this interest is effectively dead
|
||||
// (the asset they wanted is gone). Sharp drop so the chip turns cold.
|
||||
const berthSoldDays = daysSince(input.dateBerthSoldToOther);
|
||||
if (berthSoldDays !== null && signalEnabled(input, 'berth_sold_to_other')) {
|
||||
|
||||
@@ -13,7 +13,7 @@ interface DocumensoCreds {
|
||||
}
|
||||
|
||||
interface ResolvedCreds extends DocumensoCreds {
|
||||
/** Provenance of the API key — surfaces in error messages so an
|
||||
/** Provenance of the API key - surfaces in error messages so an
|
||||
* operator can tell at a glance whether a 401 is the env fallback's
|
||||
* stale key vs. a per-port admin entry. */
|
||||
apiKeySource: 'port' | 'global' | 'env' | 'default' | 'none';
|
||||
@@ -21,7 +21,7 @@ interface ResolvedCreds extends DocumensoCreds {
|
||||
}
|
||||
|
||||
async function resolveCreds(portId?: string): Promise<ResolvedCreds> {
|
||||
// env.DOCUMENSO_API_URL / env.DOCUMENSO_API_KEY are now optional — the
|
||||
// env.DOCUMENSO_API_URL / env.DOCUMENSO_API_KEY are now optional - the
|
||||
// canonical config lives in admin settings. Empty fallbacks let the call
|
||||
// proceed; if both env + admin are blank, the downstream fetch hits an
|
||||
// empty URL and errors with a clear "Documenso not configured" upstream
|
||||
@@ -63,7 +63,7 @@ async function documensoFetchOnce(
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof FetchTimeoutError) {
|
||||
// Retry timeouts — transient network issue.
|
||||
// Retry timeouts - transient network issue.
|
||||
throw new CodedError('DOCUMENSO_TIMEOUT', {
|
||||
internalMessage: `${path} timed out after ${err.timeoutMs}ms`,
|
||||
});
|
||||
@@ -75,7 +75,7 @@ async function documensoFetchOnce(
|
||||
const err = await res.text();
|
||||
logger.error({ path, status: res.status, err, portId }, 'Documenso API error');
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
// Auth failures are not retryable — wrong key won't fix itself.
|
||||
// Auth failures are not retryable - wrong key won't fix itself.
|
||||
// Surface the resolver source in the error message so the operator
|
||||
// sees "key resolved from env fallback" vs "per-port override" and
|
||||
// knows whether to edit the deploy env or the port admin row.
|
||||
@@ -87,7 +87,7 @@ async function documensoFetchOnce(
|
||||
);
|
||||
}
|
||||
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
|
||||
// 4xx (other than 429) means we sent something Documenso rejected —
|
||||
// 4xx (other than 429) means we sent something Documenso rejected -
|
||||
// retrying won't help. 429 (rate-limit) goes through the retry path
|
||||
// with backoff so we politely re-attempt after delay.
|
||||
throw new AbortError(
|
||||
@@ -108,7 +108,7 @@ async function documensoFetchOnce(
|
||||
/**
|
||||
* Wraps every Documenso call in p-retry: 3 attempts total (1 + 2 retries)
|
||||
* with exponential backoff (1s, 4s) + jitter. AbortError short-circuits
|
||||
* for auth failures and 4xx-not-429 — those will never succeed on retry.
|
||||
* for auth failures and 4xx-not-429 - those will never succeed on retry.
|
||||
*
|
||||
* This recovers the "single connection blip drops the whole signing flow"
|
||||
* scenario the audit's services pass flagged.
|
||||
@@ -140,11 +140,11 @@ async function documensoFetch(
|
||||
|
||||
// Documenso 2.x has THREE potential ID fields on responses depending on the
|
||||
// endpoint:
|
||||
// - `envelopeId: string` — the public 'envelope_xxx' identifier. This is
|
||||
// - `envelopeId: string` - the public 'envelope_xxx' identifier. This is
|
||||
// what every downstream endpoint expects (/envelope/update,
|
||||
// /envelope/distribute, /envelope/{id}, DELETE etc).
|
||||
// - `documentId: number|string` — an alias on some responses.
|
||||
// - `id` — on /template/use this is the INTERNAL numeric
|
||||
// - `documentId: number|string` - an alias on some responses.
|
||||
// - `id` - on /template/use this is the INTERNAL numeric
|
||||
// pk (e.g. 17). On other endpoints `id` is sometimes the envelope_xxx
|
||||
// string. On v1.13 `id` is the only field and represents the document.
|
||||
//
|
||||
@@ -158,7 +158,7 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
const id = String(r.envelopeId ?? r.documentId ?? r.id ?? '');
|
||||
// Documenso v2 also exposes a numeric internal pk (`id`) alongside the
|
||||
// envelope_xxx string — webhooks ONLY carry the numeric id, so we
|
||||
// envelope_xxx string - webhooks ONLY carry the numeric id, so we
|
||||
// surface it separately so the webhook resolver can match by either.
|
||||
// For v1 responses `id` IS the (numeric) document id, so this is the
|
||||
// same value as `id` above. For v2 with envelopeId set, this captures
|
||||
@@ -172,7 +172,7 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
|
||||
: null;
|
||||
const status = String(r.status ?? 'PENDING');
|
||||
// v1.32+ payloads carry a `Recipient` (capital R) array as a legacy
|
||||
// duplicate of `recipients` — fall through to it so we still resolve
|
||||
// duplicate of `recipients` - fall through to it so we still resolve
|
||||
// tokens / URLs when only the legacy field is populated.
|
||||
const recipientsRaw =
|
||||
(r.recipients as Array<Record<string, unknown>> | undefined) ??
|
||||
@@ -187,7 +187,7 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
|
||||
status: String(rec.signingStatus ?? rec.status ?? 'PENDING'),
|
||||
signingUrl: typeof rec.signingUrl === 'string' ? rec.signingUrl : undefined,
|
||||
embeddedUrl: typeof rec.embeddedUrl === 'string' ? rec.embeddedUrl : undefined,
|
||||
// Per-recipient signing token — required on the v1 Recipient model,
|
||||
// Per-recipient signing token - required on the v1 Recipient model,
|
||||
// present on every v2 envelope-distribute response. Documenso uses
|
||||
// it as the URL tail (`/sign/<token>`) so it also matches what we
|
||||
// see on subsequent webhook deliveries.
|
||||
@@ -206,7 +206,7 @@ export interface DocumensoRecipient {
|
||||
export interface DocumensoDocument {
|
||||
id: string;
|
||||
/** Documenso v2 numeric internal pk. Populated alongside the
|
||||
* envelope_xxx `id` so callers can persist both — webhooks use this
|
||||
* envelope_xxx `id` so callers can persist both - webhooks use this
|
||||
* one. Null when the response didn't include a numeric id. */
|
||||
numericId: string | null;
|
||||
status: string;
|
||||
@@ -308,7 +308,7 @@ export async function createDocument(
|
||||
if (apiVersion === 'v2') {
|
||||
// v2: multipart /envelope/create with payload + files. Convert the
|
||||
// base64 PDF to a Buffer and ship it under `files`. Returns
|
||||
// `{ id: envelopeId }` only — caller distributes separately via
|
||||
// `{ id: envelopeId }` only - caller distributes separately via
|
||||
// sendDocument(envelopeId).
|
||||
const { baseUrl, apiKey } = await resolveCreds(portId);
|
||||
const pdfBuffer = Buffer.from(pdfBase64, 'base64');
|
||||
@@ -372,7 +372,7 @@ export async function createDocument(
|
||||
}
|
||||
const created = (await res.json()) as Record<string, unknown>;
|
||||
// v2 returns just `{ id }`. Re-fetch the full envelope so the
|
||||
// caller gets recipients (without signing URLs — those come after
|
||||
// caller gets recipients (without signing URLs - those come after
|
||||
// distribute). Keeps shape identical to v1's createDocument response.
|
||||
const envelopeId = String(created.id ?? created.documentId ?? '');
|
||||
return getDocument(envelopeId, portId);
|
||||
@@ -418,7 +418,7 @@ export async function generateDocumentFromTemplate(
|
||||
|
||||
// v2 uses POST /api/v2/template/use with `prefillFields` keyed by field ID.
|
||||
// The payload builder emits `prefillFields` when a cached field name→ID map
|
||||
// exists for the port — see `buildDocumensoPayload` + `documenso-template-
|
||||
// exists for the port - see `buildDocumensoPayload` + `documenso-template-
|
||||
// sync.service.ts`. When no map is cached we still hit /template/use but
|
||||
// skip prefillFields (recipients-only); v2 instances ignore the legacy
|
||||
// `formValues` field, so emit it only on v1 paths.
|
||||
@@ -433,7 +433,7 @@ export async function generateDocumentFromTemplate(
|
||||
// flow is: 1) /template/use without distribute → DRAFT envelope, 2)
|
||||
// /envelope/update with the title, 3) /envelope/distribute → PENDING
|
||||
// envelope with signingUrls populated. Step 3 is REQUIRED because
|
||||
// v2 doesn't return signingUrls from /template/use — without it
|
||||
// v2 doesn't return signingUrls from /template/use - without it
|
||||
// `document_signers.signing_url` stays null and the manual
|
||||
// "Send invitation" button errors with "Signer has no Documenso URL".
|
||||
const created = await documensoFetch(
|
||||
@@ -448,7 +448,7 @@ export async function generateDocumentFromTemplate(
|
||||
|
||||
const desiredTitle =
|
||||
typeof v2Payload.title === 'string' && v2Payload.title.length > 0 ? v2Payload.title : null;
|
||||
// `/template/use` silently drops the `meta` field on the request body —
|
||||
// `/template/use` silently drops the `meta` field on the request body -
|
||||
// signingOrder, subject, message, redirectUrl all inherit from the
|
||||
// template's stored defaults. To enforce the per-port `documenso_signing_
|
||||
// order` (PARALLEL vs SEQUENTIAL) and per-port subject/message, replay
|
||||
@@ -483,12 +483,12 @@ export async function generateDocumentFromTemplate(
|
||||
// anything else hints that the title field wasn't accepted.
|
||||
logger.info(
|
||||
{ docId: created.id, desiredTitle, updateMeta, updateResponse },
|
||||
'Documenso envelope update — response',
|
||||
'Documenso envelope update - response',
|
||||
);
|
||||
// Belt-and-braces verify: re-read the envelope and confirm the
|
||||
// title persisted. Documenso v2's listing surface has been known
|
||||
// to render the underlying PDF filename rather than envelope.title
|
||||
// — surfacing the actual returned `title` here lets us tell
|
||||
// - surfacing the actual returned `title` here lets us tell
|
||||
// whether the API accepted our value (and the UI is the issue)
|
||||
// vs the update silently no-op'd.
|
||||
try {
|
||||
@@ -505,7 +505,7 @@ export async function generateDocumentFromTemplate(
|
||||
titleMatches: verify?.title === desiredTitle,
|
||||
actualMeta: verify?.documentMeta ?? verify?.envelopeMeta ?? verify?.meta,
|
||||
},
|
||||
'Documenso envelope update — verification',
|
||||
'Documenso envelope update - verification',
|
||||
);
|
||||
} catch {
|
||||
// GET verify is best-effort; don't fail generate on it.
|
||||
@@ -513,7 +513,7 @@ export async function generateDocumentFromTemplate(
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ docId: created.id, updateMeta, err: err instanceof Error ? err.message : err },
|
||||
'Documenso envelope update failed — created envelope keeps template default title/meta',
|
||||
'Documenso envelope update failed - created envelope keeps template default title/meta',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -525,7 +525,7 @@ export async function generateDocumentFromTemplate(
|
||||
//
|
||||
// Documenso v2's distribute fires its own emails by default, but
|
||||
// our payload sets `meta.distributionMethod: 'NONE'` so it just
|
||||
// mints the URLs without emailing — our branded
|
||||
// mints the URLs without emailing - our branded
|
||||
// `sendSigningInvitation` is the dispatcher.
|
||||
//
|
||||
// We replace `created` with the distribute response because that's
|
||||
@@ -533,12 +533,12 @@ export async function generateDocumentFromTemplate(
|
||||
// populated; downstream code (the document_signers insert in
|
||||
// generateAndSignViaDocumensoTemplate) reads from this object.
|
||||
// CRITICAL: pass `meta.distributionMethod: 'NONE'` in the distribute
|
||||
// body. `/template/use` doesn't accept a `meta` field at all — our
|
||||
// body. `/template/use` doesn't accept a `meta` field at all - our
|
||||
// payload's `meta.distributionMethod: 'NONE'` is silently dropped at
|
||||
// template-use time, so the envelope inherits the TEMPLATE's
|
||||
// distributionMethod (which defaults to EMAIL). Without overriding
|
||||
// it on the distribute call, Documenso fires its own emails the
|
||||
// moment distribute runs — which clashes with our branded
|
||||
// moment distribute runs - which clashes with our branded
|
||||
// `sendSigningInvitation` flow and ignores the per-port
|
||||
// `eoi_send_mode: 'manual'` setting. The override here is the
|
||||
// authoritative one for v2 envelopes.
|
||||
@@ -559,7 +559,7 @@ export async function generateDocumentFromTemplate(
|
||||
envelopeId: distributed.id ?? created.id,
|
||||
// Distribute doesn't return the numeric id, so we synthesize it
|
||||
// from the original /template/use response by passing the numeric
|
||||
// id as Documenso's `id` field — normalizeDocument picks it up
|
||||
// id as Documenso's `id` field - normalizeDocument picks it up
|
||||
// as numericId. Without this, the row would lose its numeric id
|
||||
// on distribute and webhooks couldn't resolve back to it.
|
||||
id: created.numericId,
|
||||
@@ -570,7 +570,7 @@ export async function generateDocumentFromTemplate(
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ docId: created.id, err: err instanceof Error ? err.message : err },
|
||||
'Documenso envelope distribute failed — signingUrl will be null. Send-invitation will fail until the envelope is distributed.',
|
||||
'Documenso envelope distribute failed - signingUrl will be null. Send-invitation will fail until the envelope is distributed.',
|
||||
);
|
||||
return created;
|
||||
}
|
||||
@@ -599,12 +599,12 @@ export async function generateDocumentFromTemplate(
|
||||
/**
|
||||
* v2-only: distribute an envelope. Moves it from DRAFT → PENDING and
|
||||
* mints per-recipient signing URLs. Does NOT email recipients when the
|
||||
* envelope's `meta.distributionMethod` is `NONE` (our default — branded
|
||||
* envelope's `meta.distributionMethod` is `NONE` (our default - branded
|
||||
* emails are dispatched by `sendSigningInvitation`).
|
||||
*
|
||||
* Direct call bypassing `sendDocument`'s dev-mode short-circuit. The
|
||||
* self-heal path for envelopes created before the auto-distribute fix
|
||||
* shipped uses this so the URLs actually get minted in dev too —
|
||||
* shipped uses this so the URLs actually get minted in dev too -
|
||||
* `EMAIL_REDIRECT_TO` already rewrites recipient emails to a safe
|
||||
* address at envelope-creation time, so distribute can't accidentally
|
||||
* email a real client.
|
||||
@@ -614,7 +614,7 @@ export async function distributeEnvelopeV2(
|
||||
portId?: string,
|
||||
): Promise<DocumensoDocument> {
|
||||
// Architectural rule (Matt 2026-05-15): ALL outbound emails go through
|
||||
// our branded `sendSigningInvitation` path — Documenso never fires its
|
||||
// our branded `sendSigningInvitation` path - Documenso never fires its
|
||||
// own emails for our envelopes. `meta.distributionMethod: 'NONE'`
|
||||
// here is the ONLY place where this contract is actually enforced
|
||||
// for v2 envelopes (the corresponding flag in /template/use is
|
||||
@@ -653,10 +653,10 @@ export async function sendDocument(docId: string, portId?: string): Promise<Docu
|
||||
|
||||
if (apiVersion === 'v2') {
|
||||
// v2: POST /api/v2/envelope/distribute with body { envelopeId }.
|
||||
// Returns the envelope with per-recipient signingUrl fields populated —
|
||||
// Returns the envelope with per-recipient signingUrl fields populated -
|
||||
// this is one of the genuine v2 wins (saves a separate GET round-trip).
|
||||
// `meta.distributionMethod: 'NONE'` is the authoritative way to suppress
|
||||
// Documenso's own emails for v2 envelopes — see distributeEnvelopeV2
|
||||
// Documenso's own emails for v2 envelopes - see distributeEnvelopeV2
|
||||
// for the full rationale. Branded sends are routed through
|
||||
// `sendSigningInvitation` separately.
|
||||
const distributed = (await documensoFetch(
|
||||
@@ -676,7 +676,7 @@ export async function sendDocument(docId: string, portId?: string): Promise<Docu
|
||||
// callers already consume.
|
||||
return normalizeDocument({
|
||||
id: distributed.id,
|
||||
// v2 doesn't return `status` on the distribute response — the call
|
||||
// v2 doesn't return `status` on the distribute response - the call
|
||||
// itself moves the envelope from DRAFT to PENDING, so PENDING is
|
||||
// the correct authoritative state.
|
||||
status: 'PENDING',
|
||||
@@ -696,7 +696,7 @@ export async function sendDocument(docId: string, portId?: string): Promise<Docu
|
||||
export async function getDocument(docId: string, portId?: string): Promise<DocumensoDocument> {
|
||||
const { apiVersion } = await resolveCreds(portId);
|
||||
// v1: GET /api/v1/documents/{id}
|
||||
// v2: GET /api/v2/envelope/{id} — same response normalizer (id ↔ documentId,
|
||||
// v2: GET /api/v2/envelope/{id} - same response normalizer (id ↔ documentId,
|
||||
// recipientId ↔ id handled by normalizeDocument).
|
||||
const path = apiVersion === 'v2' ? `/api/v2/envelope/${docId}` : `/api/v1/documents/${docId}`;
|
||||
return documensoFetch(path, undefined, portId).then(normalizeDocument);
|
||||
@@ -716,7 +716,7 @@ export interface DocumensoTemplateField {
|
||||
id: number;
|
||||
type: string;
|
||||
/**
|
||||
* The human label assigned in the template editor — for v2 templates this
|
||||
* The human label assigned in the template editor - for v2 templates this
|
||||
* comes from `field.fieldMeta.label`; for v1 templates it's available as
|
||||
* `field.fieldMeta.label` too (the shape was preserved). Used as the key
|
||||
* for the cached field-name → ID map that drives v2's `prefillFields`.
|
||||
@@ -731,13 +731,13 @@ export interface DocumensoTemplate {
|
||||
fields: DocumensoTemplateField[];
|
||||
/**
|
||||
* v2 only. Each entry corresponds to one underlying PDF file on the
|
||||
* template — usually a single envelope item per template, but Documenso
|
||||
* template - usually a single envelope item per template, but Documenso
|
||||
* 2.x supports stitching multiple PDFs together. Used by the sync flow
|
||||
* to download each PDF and inspect its native AcroForm fields.
|
||||
*/
|
||||
envelopeItems: Array<{ id: string }>;
|
||||
/**
|
||||
* v2 only. The template's stored meta — signing order, distribution
|
||||
* v2 only. The template's stored meta - signing order, distribution
|
||||
* method, redirect URL. Surfaced in the sync report so the admin can
|
||||
* confirm what the template itself is configured to do at envelope
|
||||
* creation time. /template/use does NOT accept a signingOrder override,
|
||||
@@ -804,7 +804,7 @@ function normalizeTemplate(raw: unknown): DocumensoTemplate {
|
||||
* uses this to inspect the PDF's AcroForm field names, surfacing whether
|
||||
* the operator's fillable PDF matches the CRM's expected field-label set.
|
||||
*
|
||||
* v1 templates aren't supported here — the v1 download endpoint requires
|
||||
* v1 templates aren't supported here - the v1 download endpoint requires
|
||||
* a documentId, not a templateId, and v1 doesn't expose envelope items.
|
||||
*/
|
||||
export async function downloadEnvelopeItemPdf(
|
||||
@@ -836,7 +836,7 @@ export async function downloadEnvelopeItemPdf(
|
||||
* comfortably covers every observed Documenso instance).
|
||||
*
|
||||
* v1 path: `GET /api/v1/templates`. Same pagination via
|
||||
* `?page=N&perPage=100`. v1 returns the legacy shape — we normalize to
|
||||
* `?page=N&perPage=100`. v1 returns the legacy shape - we normalize to
|
||||
* the same `{ id, name }` summary the UI consumes.
|
||||
*/
|
||||
export async function listTemplates(
|
||||
@@ -891,7 +891,7 @@ export async function getTemplate(templateId: number, portId?: string): Promise<
|
||||
*
|
||||
* Documenso 2.x's template editor URL is
|
||||
* `https://.../templates/envelope_xxxxxxxx`, but the numeric `id` is not
|
||||
* surfaced anywhere in the UI — so admins have no way to enter the
|
||||
* surfaced anywhere in the UI - so admins have no way to enter the
|
||||
* numeric id by hand. This resolver bridges the gap.
|
||||
*/
|
||||
export async function findTemplateIdByEnvelopeId(
|
||||
@@ -973,7 +973,7 @@ export async function sendReminder(
|
||||
export async function downloadSignedPdf(docId: string, portId?: string): Promise<Buffer> {
|
||||
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
|
||||
// v2 download is a two-step lookup: there's no /envelope/{id}/download path
|
||||
// (that 404s — see audit-2026-05-15). The canonical flow is:
|
||||
// (that 404s - see audit-2026-05-15). The canonical flow is:
|
||||
// 1. GET /envelope/{id} → read envelopeItems[0].id
|
||||
// 2. GET /envelope/item/{itemId}/download?version=signed
|
||||
// v1 keeps the direct /documents/{id}/download single-call path.
|
||||
@@ -986,7 +986,7 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise
|
||||
const itemId = envelope.envelopeItems?.[0]?.id;
|
||||
if (!itemId) {
|
||||
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
|
||||
internalMessage: `v2 envelope ${docId} has no envelopeItems — cannot download signed PDF`,
|
||||
internalMessage: `v2 envelope ${docId} has no envelopeItems - cannot download signed PDF`,
|
||||
});
|
||||
}
|
||||
return downloadEnvelopeItemPdf(itemId, portId, 'signed');
|
||||
@@ -1025,7 +1025,7 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise
|
||||
|
||||
/** Convenience health-check used by the admin "Test connection" button.
|
||||
*
|
||||
* v2 cloud (Documenso 2.x) doesn't expose `/api/v1/health` — the old v1
|
||||
* v2 cloud (Documenso 2.x) doesn't expose `/api/v1/health` - the old v1
|
||||
* path is gone. So we probe the appropriate cheap list endpoint per
|
||||
* version (`GET /api/v2/document` for v2, `GET /api/v1/health` for v1)
|
||||
* and treat a 401 as "creds rejected" and a 200 as "all good". A 404
|
||||
@@ -1040,7 +1040,7 @@ export async function checkDocumensoHealth(
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
});
|
||||
// 2xx = full success; 401/403 = creds wrong but URL right (still a
|
||||
// partial-success signal — the admin should know it's an auth issue,
|
||||
// partial-success signal - the admin should know it's an auth issue,
|
||||
// not a typoed URL). 404 = wrong URL.
|
||||
return { ok: res.ok, status: res.status, apiVersion };
|
||||
} catch (err) {
|
||||
@@ -1072,12 +1072,12 @@ export async function checkDocumensoHealth(
|
||||
* 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 }> }
|
||||
* - 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
|
||||
@@ -1098,7 +1098,7 @@ export type DocumensoFieldType =
|
||||
| 'RADIO';
|
||||
|
||||
/**
|
||||
* Typed metadata shapes per field type — surfaces what fieldMeta
|
||||
* 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
|
||||
@@ -1199,26 +1199,36 @@ export async function placeFields(
|
||||
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
|
||||
|
||||
if (apiVersion === 'v2') {
|
||||
// Documenso v2 field schema (confirmed against live trpc error
|
||||
// responses, 2026-05-22):
|
||||
// - `recipientId` must be a NUMBER (the v1 client returns it
|
||||
// stringified; we coerce here so callers can pass either form)
|
||||
// - the page index field is named `page`, NOT `pageNumber` (v1's
|
||||
// key) - wrong key surfaces as Zod "Required at data[i].page"
|
||||
// - `positionX/Y` + `width/height` carry percent values (0-100)
|
||||
const v2Fields = fields.map((f) => ({
|
||||
recipientId: String(f.recipientId),
|
||||
recipientId: Number(f.recipientId),
|
||||
type: f.type,
|
||||
pageNumber: f.pageNumber,
|
||||
page: f.pageNumber,
|
||||
positionX: f.pageX,
|
||||
positionY: f.pageY,
|
||||
width: f.pageWidth,
|
||||
height: f.pageHeight,
|
||||
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
|
||||
}));
|
||||
// Note: v2 endpoint shape (envelopeId/recipientId types) must be
|
||||
// confirmed against a live Documenso 2.x instance - see PR11 realapi
|
||||
// suite. Spec risk register flags this drift as the top v2 risk.
|
||||
// Documenso v2 expects the field array under `data`, not `fields`
|
||||
// (the endpoint is a trpc-style createMany whose input schema wraps
|
||||
// the bulk payload in `{ envelopeId, data: [...] }`). The previous
|
||||
// shape returned 400: "Input validation failed - Required at data"
|
||||
// and surfaced as DOCUMENSO_UPSTREAM_ERROR on every custom-upload
|
||||
// send. Confirmed against the live Documenso 2.x error response.
|
||||
const res = await fetchWithTimeout(`${baseUrl}/api/v2/envelope/field/create-many`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ envelopeId: docId, fields: v2Fields }),
|
||||
body: JSON.stringify({ envelopeId: docId, data: v2Fields }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
@@ -1265,7 +1275,7 @@ export async function placeFields(
|
||||
}
|
||||
const errBody = await res.text().catch(() => '');
|
||||
lastError = { status: res.status, body: errBody };
|
||||
// Don't retry on 4xx — that's a validation error, won't change.
|
||||
// Don't retry on 4xx - that's a validation error, won't change.
|
||||
if (res.status >= 400 && res.status < 500) break;
|
||||
// Backoff: 250ms, 500ms (skipped on the 3rd iteration because we exit).
|
||||
if (attempt < 2) {
|
||||
@@ -1364,7 +1374,7 @@ export async function voidDocument(docId: string, portId?: string): Promise<void
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an envelope's metadata while it's still in DRAFT or PENDING — title,
|
||||
* Update an envelope's metadata while it's still in DRAFT or PENDING - title,
|
||||
* subject, message, redirect URL, signing order, language. v2-only feature
|
||||
* (Documenso 1.13.x has no equivalent; admins on v1 need to void + recreate).
|
||||
*
|
||||
@@ -1391,11 +1401,11 @@ export async function updateEnvelope(
|
||||
if (apiVersion !== 'v2') {
|
||||
throw new CodedError('DOCUMENSO_V1_NOT_SUPPORTED', {
|
||||
internalMessage:
|
||||
'updateEnvelope requires Documenso 2.x — the v1.13.x API has no envelope/update endpoint',
|
||||
'updateEnvelope requires Documenso 2.x - the v1.13.x API has no envelope/update endpoint',
|
||||
});
|
||||
}
|
||||
// v2 update is POST /api/v2/envelope/update with the envelopeId in the
|
||||
// body — NOT a PATCH against a per-id path. The body splits document
|
||||
// body - NOT a PATCH against a per-id path. The body splits document
|
||||
// properties (title, externalId, visibility, email) under `data` from
|
||||
// email/signing settings under `meta`. Restricted to DRAFT envelopes.
|
||||
const body: Record<string, unknown> = { envelopeId: docId };
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface DocumensoTemplatePayload {
|
||||
* (e.g. "A1-A3, B5") produced by `formatBerthRange()`. Single-
|
||||
* berth output is byte-identical to the legacy primary-only path.
|
||||
* 2026-05-14: collapsed the prior separate `Berth Range` form field
|
||||
* into this one — the Documenso template has only `Berth Number`,
|
||||
* into this one - the Documenso template has only `Berth Number`,
|
||||
* and Documenso silently dropped unknown formValues.
|
||||
*/
|
||||
'Berth Number': string;
|
||||
@@ -95,7 +95,7 @@ export interface DocumensoPayloadOptions {
|
||||
/** Redirect URL after signing. Defaults to the app URL. */
|
||||
redirectUrl?: string;
|
||||
/**
|
||||
* PARALLEL (default) or SEQUENTIAL — v2-only enforcement (v1 ignores).
|
||||
* PARALLEL (default) or SEQUENTIAL - v2-only enforcement (v1 ignores).
|
||||
* Set via per-port `documenso_signing_order` system_settings key.
|
||||
*/
|
||||
signingOrder?: 'PARALLEL' | 'SEQUENTIAL';
|
||||
@@ -123,7 +123,7 @@ export interface DocumensoPayloadOptions {
|
||||
|
||||
// Empty string lets Documenso fall back to its own default post-sign
|
||||
// landing page when the port admin hasn't configured a redirect URL.
|
||||
// Never hardcode a tenant's marketing-site URL here — that would route
|
||||
// Never hardcode a tenant's marketing-site URL here - that would route
|
||||
// every other port's signers to the wrong host.
|
||||
const DEFAULT_REDIRECT_URL = '';
|
||||
|
||||
@@ -171,16 +171,16 @@ async function resolveCrmUser(
|
||||
* Resolve the developer + approver name/email for the EOI signing trio.
|
||||
*
|
||||
* Priority chain per slot (highest → lowest):
|
||||
* 1. Linked CRM user (`documenso_<role>_user_id`) — recommended path
|
||||
* 1. Linked CRM user (`documenso_<role>_user_id`) - recommended path
|
||||
* because "the person on this slot" changes via a CRM admin re-link,
|
||||
* not a Documenso template edit. The display name comes from
|
||||
* `userProfiles.displayName`, the email from `user.email`.
|
||||
* 2. Free-text overrides (`documenso_<role>_name` +
|
||||
* `documenso_<role>_email`) — for ports where the signer isn't a
|
||||
* `documenso_<role>_email`) - for ports where the signer isn't a
|
||||
* CRM-platform user (e.g. external counsel).
|
||||
* 3. Legacy `eoi_signers` JSON blob — kept for backward compat with
|
||||
* 3. Legacy `eoi_signers` JSON blob - kept for backward compat with
|
||||
* ports that haven't migrated to the registry-driven settings yet.
|
||||
* 4. Empty strings — let the Documenso template's stored values win.
|
||||
* 4. Empty strings - let the Documenso template's stored values win.
|
||||
*
|
||||
* Either slot can resolve via a different tier than the other.
|
||||
*/
|
||||
@@ -250,7 +250,7 @@ export function buildDocumensoPayload(
|
||||
/**
|
||||
* Cached field name → ID map from the per-port `documenso_eoi_field_map`
|
||||
* setting (populated by the admin "Sync from Documenso" button). When
|
||||
* provided, the payload also emits `prefillFields` keyed by ID — required
|
||||
* provided, the payload also emits `prefillFields` keyed by ID - required
|
||||
* by v2's /template/use. v1 instances ignore this field; v2 instances
|
||||
* accept either prefillFields OR the legacy formValues shape.
|
||||
*/
|
||||
@@ -260,7 +260,7 @@ export function buildDocumensoPayload(
|
||||
// 'ft' for legacy call sites that don't pass `dimensionUnit`; new code
|
||||
// paths (generateAndSign + the drawer) always set it explicitly.
|
||||
// Append the unit suffix to every dimension value so the rendered EOI
|
||||
// reads "45 ft" / "13.7 m" rather than the bare number — the original
|
||||
// reads "45 ft" / "13.7 m" rather than the bare number - the original
|
||||
// form field doesn't tell signers which unit they're looking at.
|
||||
const dimUnit: 'ft' | 'm' = options.dimensionUnit ?? 'ft';
|
||||
const yachtLength = dimUnit === 'ft' ? context.yacht?.lengthFt : context.yacht?.lengthM;
|
||||
@@ -279,7 +279,7 @@ export function buildDocumensoPayload(
|
||||
Length: withUnit(yachtLength),
|
||||
Width: withUnit(yachtWidth),
|
||||
Draft: withUnit(yachtDraft),
|
||||
// formatBerthRange(['A1']) === 'A1' — so single-berth EOIs render
|
||||
// formatBerthRange(['A1']) === 'A1' - so single-berth EOIs render
|
||||
// identically to the legacy primary-only flow; multi-berth EOIs
|
||||
// now actually show the full range instead of just the primary
|
||||
// mooring.
|
||||
@@ -290,7 +290,7 @@ export function buildDocumensoPayload(
|
||||
|
||||
// v2's prefillFields-by-ID emission. Map every formValue entry through the
|
||||
// cached field map; skip entries that aren't in the map (template doesn't
|
||||
// have that field, which is fine — Documenso silently drops unknown ones
|
||||
// have that field, which is fine - Documenso silently drops unknown ones
|
||||
// in v1 too).
|
||||
const prefillFields = fieldMap
|
||||
? Object.entries(formValues)
|
||||
@@ -328,7 +328,7 @@ export function buildDocumensoPayload(
|
||||
// Per Documenso v2's /template/use schema, `email` and `name` accept "" as
|
||||
// a sentinel meaning "use the value baked into the template recipient".
|
||||
// So when an admin leaves the developer/approver name/email blank in our
|
||||
// admin settings, we pass "" rather than a hardcoded fallback — Documenso
|
||||
// admin settings, we pass "" rather than a hardcoded fallback - Documenso
|
||||
// then takes the email/name set on the template itself. A non-empty
|
||||
// admin value still wins (overrides the template at send time).
|
||||
recipients: [
|
||||
|
||||
@@ -30,7 +30,7 @@ export function extractSigningToken(url: string | null | undefined): string | nu
|
||||
const segments = parsed.pathname.split('/').filter(Boolean);
|
||||
const last = segments[segments.length - 1];
|
||||
if (!last) return null;
|
||||
// A token must be at least 8 chars and contain only URL-safe chars —
|
||||
// A token must be at least 8 chars and contain only URL-safe chars -
|
||||
// discriminates real tokens from generic words like "sign" or "embed"
|
||||
// that some Documenso 2.x deployments append.
|
||||
if (last.length < 8) return null;
|
||||
|
||||
@@ -54,7 +54,7 @@ export interface TemplateSyncResult {
|
||||
fieldCount: number;
|
||||
/**
|
||||
* Template fields the CRM knows how to fill at send time. The admin doesn't
|
||||
* need to do anything for these — they'll flow through prefillFields on
|
||||
* need to do anything for these - they'll flow through prefillFields on
|
||||
* the next EOI send.
|
||||
*/
|
||||
matchedFields: Array<{ label: string; fieldId: number }>;
|
||||
@@ -71,7 +71,7 @@ export interface TemplateSyncResult {
|
||||
*/
|
||||
missingFromTemplate: string[];
|
||||
/**
|
||||
* The template's stored meta — what every envelope generated from this
|
||||
* The template's stored meta - what every envelope generated from this
|
||||
* template inherits at creation time. signingOrder is bound to the
|
||||
* template (v2's /template/use does NOT accept an override), so this
|
||||
* is the authoritative value the admin sees.
|
||||
@@ -87,7 +87,7 @@ export interface TemplateSyncResult {
|
||||
* EOI field labels. The admin uses this to verify their fillable PDF
|
||||
* actually has the named fields the CRM will fill via `formValues`.
|
||||
*
|
||||
* Empty array on v1 or when the PDF download / parse fails — diagnostic
|
||||
* Empty array on v1 or when the PDF download / parse fails - diagnostic
|
||||
* messages go to pino so the admin still sees the rest of the sync result.
|
||||
*/
|
||||
acroForm: Array<{
|
||||
@@ -107,7 +107,7 @@ export interface TemplateSyncResult {
|
||||
* (signature blocks, etc.).
|
||||
*/
|
||||
extraFieldNames: string[];
|
||||
/** Set when download or parse failed — string surfaces in the UI. */
|
||||
/** Set when download or parse failed - string surfaces in the UI. */
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export interface TemplateSyncResult {
|
||||
* signingOrder 2, role=SIGNER → developer recipient slot
|
||||
* signingOrder 3, role=APPROVER → approver recipient slot
|
||||
*
|
||||
* Returns null if the role/order combination doesn't match any known slot —
|
||||
* Returns null if the role/order combination doesn't match any known slot -
|
||||
* useful future-proofing for templates that add CC / VIEWER recipients.
|
||||
*/
|
||||
function mapRecipientToSettingKey(role: string, signingOrder: number): string | null {
|
||||
@@ -141,7 +141,7 @@ function mapRecipientToSettingKey(role: string, signingOrder: number): string |
|
||||
* matching CRM settings, and cache the field name→ID map for v2 prefillFields.
|
||||
*
|
||||
* Throws when the template fetch fails (bad credentials, wrong template ID,
|
||||
* network) — caller surfaces the error to the admin via the form's mutation
|
||||
* network) - caller surfaces the error to the admin via the form's mutation
|
||||
* onError + toastError.
|
||||
*/
|
||||
export async function syncDocumensoTemplate(
|
||||
@@ -218,7 +218,7 @@ export async function syncDocumensoTemplate(
|
||||
});
|
||||
} catch (err) {
|
||||
// Surface the failure in the UI rather than failing the whole
|
||||
// sync — the recipient + field-map writes already succeeded
|
||||
// sync - the recipient + field-map writes already succeeded
|
||||
// and an admin can still progress without the AcroForm diff.
|
||||
logger.warn(
|
||||
{ err, envelopeItemId: item.id, templateId },
|
||||
@@ -261,7 +261,7 @@ export async function syncDocumensoTemplate(
|
||||
|
||||
/**
|
||||
* Read the cached sync report written on the most recent successful sync.
|
||||
* Drives the post-reload status panel — returns null when no sync has
|
||||
* Drives the post-reload status panel - returns null when no sync has
|
||||
* ever run for this port.
|
||||
*/
|
||||
export async function getEoiTemplateSyncReport(portId: string): Promise<TemplateSyncResult | null> {
|
||||
@@ -274,12 +274,12 @@ export async function getEoiTemplateSyncReport(portId: string): Promise<Template
|
||||
/**
|
||||
* Read the cached field-name → field-id map for this port. Used by
|
||||
* `buildDocumensoPayload` when emitting v2's `prefillFields` array.
|
||||
* Returns null when no sync has run yet — caller falls back to v1's
|
||||
* Returns null when no sync has run yet - caller falls back to v1's
|
||||
* `formValues`-by-name shape.
|
||||
*/
|
||||
export async function getEoiFieldMap(portId: string): Promise<TemplateFieldMap | null> {
|
||||
// The field map is a free-form JSON blob that doesn't fit the registry's
|
||||
// scalar-credential model — read directly via the legacy settings.service
|
||||
// scalar-credential model - read directly via the legacy settings.service
|
||||
// path so the registry-aware resolver doesn't reject the unknown key.
|
||||
const row = await getSettingLegacy('documenso_eoi_field_map', portId);
|
||||
const stored = row?.value;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { timingSafeEqual } from 'crypto';
|
||||
// configured secret in plaintext via the `X-Documenso-Secret` header.
|
||||
// There is no HMAC. Compare the provided value timing-safely to the env secret.
|
||||
//
|
||||
// An empty `expected` MUST always reject — without this guard,
|
||||
// An empty `expected` MUST always reject - without this guard,
|
||||
// timingSafeEqual(0-bytes, 0-bytes) returns true, so a dev environment
|
||||
// with a blank DOCUMENSO_WEBHOOK_SECRET would accept any request whose
|
||||
// `X-Documenso-Secret` was also empty/missing. Same for blank per-port
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Phase 4c — Auto-detect anchor scanner.
|
||||
* Phase 4c - Auto-detect anchor scanner.
|
||||
*
|
||||
* Scans a PDF for common signing-block keywords ("Signature:", "Date:",
|
||||
* "Initials", a long run of underscores, etc.) and proposes Documenso
|
||||
@@ -12,7 +12,7 @@
|
||||
* regexes are tried in priority order so a `"Date of Signature:"`
|
||||
* anchor doesn't double-place as both DATE and SIGNATURE.
|
||||
*
|
||||
* This is intentionally pdf-content driven (text-extraction based) —
|
||||
* This is intentionally pdf-content driven (text-extraction based) -
|
||||
* the alternative (image-of-PDF + OCR) is the bigger berth-PDF parser
|
||||
* tier-3 path; we keep this lightweight so it runs in <500ms on a
|
||||
* 10-page contract.
|
||||
@@ -30,7 +30,7 @@ export interface DetectedField {
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
/** 0..1 — how sure the scanner is. */
|
||||
/** 0..1 - how sure the scanner is. */
|
||||
confidence: number;
|
||||
/** Verbatim anchor that triggered the detection (display + debug). */
|
||||
anchorText: string;
|
||||
@@ -42,7 +42,7 @@ export interface DetectedField {
|
||||
|
||||
/** Anchor → field-type pattern table. Order matters: earlier patterns
|
||||
* win when two anchors overlap on the same text item (e.g. "Date of
|
||||
* Signature" matches both DATE and SIGNATURE — DATE goes first because
|
||||
* Signature" matches both DATE and SIGNATURE - DATE goes first because
|
||||
* it's the more specific pattern). */
|
||||
interface AnchorPattern {
|
||||
type: DocumensoFieldType;
|
||||
@@ -58,7 +58,7 @@ interface AnchorPattern {
|
||||
}
|
||||
|
||||
const ANCHOR_PATTERNS: AnchorPattern[] = [
|
||||
// DATE — more specific than SIGNATURE for the common "Date of
|
||||
// DATE - more specific than SIGNATURE for the common "Date of
|
||||
// Signature:" case, so listed first.
|
||||
{
|
||||
type: 'DATE',
|
||||
@@ -67,7 +67,7 @@ const ANCHOR_PATTERNS: AnchorPattern[] = [
|
||||
heightPt: 20,
|
||||
confidenceBoost: 0.2,
|
||||
},
|
||||
// INITIALS — pre-empts NAME because "Initial:" is short and unique.
|
||||
// INITIALS - pre-empts NAME because "Initial:" is short and unique.
|
||||
{
|
||||
type: 'INITIALS',
|
||||
match: /(?:^|\b)(?:initials?)[:\s_-]+/i,
|
||||
@@ -75,7 +75,7 @@ const ANCHOR_PATTERNS: AnchorPattern[] = [
|
||||
heightPt: 30,
|
||||
confidenceBoost: 0.2,
|
||||
},
|
||||
// EMAIL — explicit email anchor.
|
||||
// EMAIL - explicit email anchor.
|
||||
{
|
||||
type: 'EMAIL',
|
||||
match: /(?:^|\b)e-?mail[:\s_-]+/i,
|
||||
@@ -83,7 +83,7 @@ const ANCHOR_PATTERNS: AnchorPattern[] = [
|
||||
heightPt: 20,
|
||||
confidenceBoost: 0.2,
|
||||
},
|
||||
// NAME — printed/full name labels.
|
||||
// NAME - printed/full name labels.
|
||||
{
|
||||
type: 'NAME',
|
||||
match: /(?:^|\b)(?:printed\s*)?(?:full\s+)?name[:\s_-]+/i,
|
||||
@@ -91,7 +91,7 @@ const ANCHOR_PATTERNS: AnchorPattern[] = [
|
||||
heightPt: 20,
|
||||
confidenceBoost: 0.15,
|
||||
},
|
||||
// SIGNATURE — broadest of the signing-block patterns.
|
||||
// SIGNATURE - broadest of the signing-block patterns.
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
match: /(?:^|\b)(?:signature|sign\s*here|signed\s*by|signed\s*at)[:\s_-]+/i,
|
||||
@@ -99,7 +99,7 @@ const ANCHOR_PATTERNS: AnchorPattern[] = [
|
||||
heightPt: 30,
|
||||
confidenceBoost: 0.2,
|
||||
},
|
||||
// SIGNATURE — explicit "X" mark followed by a blank line.
|
||||
// SIGNATURE - explicit "X" mark followed by a blank line.
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
match: /X\s*_{4,}/,
|
||||
@@ -141,7 +141,7 @@ interface PdfTextItem {
|
||||
transform: number[];
|
||||
/** Item width in PDF user-space units. */
|
||||
width?: number;
|
||||
/** Item height — usually equals scaleY. */
|
||||
/** Item height - usually equals scaleY. */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export async function detectFields(pdfBuffer: Buffer): Promise<DetectedField[]>
|
||||
for (const page of pages) {
|
||||
for (const item of page.items) {
|
||||
const lower = item.str.toLowerCase();
|
||||
// Skip if the item has no positional data — defensive against
|
||||
// Skip if the item has no positional data - defensive against
|
||||
// exotic PDF encodings.
|
||||
if (!Array.isArray(item.transform) || item.transform.length < 6) continue;
|
||||
const translateX = Number(item.transform[4]);
|
||||
@@ -187,7 +187,7 @@ export async function detectFields(pdfBuffer: Buffer): Promise<DetectedField[]>
|
||||
const fieldXPt = translateX + anchorWidthPt + 5;
|
||||
// PDF user-space origin is the lower-left; transform[5] is the
|
||||
// baseline of the text so the field's lower-left also lives
|
||||
// there. CSS/web origin is top-left — we keep the percent in
|
||||
// there. CSS/web origin is top-left - we keep the percent in
|
||||
// PDF coordinates here because Documenso accepts both (the
|
||||
// existing placeFields helper handles the conversion).
|
||||
const fieldYPt = translateY;
|
||||
@@ -197,7 +197,7 @@ export async function detectFields(pdfBuffer: Buffer): Promise<DetectedField[]>
|
||||
const pageWidth = (pattern.widthPt / page.widthPt) * 100;
|
||||
const pageHeight = (pattern.heightPt / page.heightPt) * 100;
|
||||
|
||||
// Hard-skip fields that would land off-page (defensive — a
|
||||
// Hard-skip fields that would land off-page (defensive - a
|
||||
// misparsed transform can blow up the coordinate space).
|
||||
if (pageX < 0 || pageX > 95 || pageY < 0 || pageY > 95) continue;
|
||||
if (pageWidth <= 0 || pageHeight <= 0) continue;
|
||||
@@ -215,7 +215,7 @@ export async function detectFields(pdfBuffer: Buffer): Promise<DetectedField[]>
|
||||
anchorText: item.str.trim(),
|
||||
inferredRecipientLabel: recipientLabel,
|
||||
});
|
||||
// First matching pattern wins for this item — earlier
|
||||
// First matching pattern wins for this item - earlier
|
||||
// (more-specific) patterns shadow later ones.
|
||||
break;
|
||||
}
|
||||
@@ -258,7 +258,7 @@ function inferRecipient(
|
||||
* import it dynamically so the heavy native-bindings dep only loads
|
||||
* when the detector actually runs.
|
||||
*
|
||||
* Returns an empty array if pdfjs fails to parse — the rep gets the
|
||||
* Returns an empty array if pdfjs fails to parse - the rep gets the
|
||||
* manual placement flow without an error toast.
|
||||
*/
|
||||
export async function extractPdfPages(pdfBuffer: Buffer): Promise<PdfPageView[]> {
|
||||
@@ -285,7 +285,7 @@ export async function extractPdfPages(pdfBuffer: Buffer): Promise<PdfPageView[]>
|
||||
return pages;
|
||||
} catch {
|
||||
// Image-only scans or corrupt PDFs land here. The dialog falls
|
||||
// back to manual placement — no rep-facing error needed.
|
||||
// back to manual placement - no rep-facing error needed.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface FolderNode extends DocumentFolder {
|
||||
/**
|
||||
* Returns the entire folder tree for a port, nested under their
|
||||
* parents. Roots come back at the top level. Order is alphabetical
|
||||
* (case-insensitive) within each parent — matches the sibling-uniqueness
|
||||
* (case-insensitive) within each parent - matches the sibling-uniqueness
|
||||
* index ordering and gives reps a stable browsing experience.
|
||||
*
|
||||
* Uses a single SELECT + JS nesting rather than a recursive CTE; the
|
||||
@@ -241,14 +241,14 @@ export async function moveFolder(
|
||||
|
||||
// Cycle check: walk the destination's ancestor chain. If we hit
|
||||
// folderId, the destination is a descendant of the folder being
|
||||
// moved — moving would create a cycle.
|
||||
// moved - moving would create a cycle.
|
||||
let cursor: string | null = newParent.parentId;
|
||||
const seen = new Set<string>([newParent.id]);
|
||||
while (cursor) {
|
||||
if (cursor === folderId) {
|
||||
throw new ValidationError('Cannot move a folder under one of its descendants (cycle)');
|
||||
}
|
||||
if (seen.has(cursor)) break; // defensive — pre-existing cycle, bail
|
||||
if (seen.has(cursor)) break; // defensive - pre-existing cycle, bail
|
||||
seen.add(cursor);
|
||||
const next = await tx.query.documentFolders.findFirst({
|
||||
where: and(eq(documentFolders.id, cursor), eq(documentFolders.portId, portId)),
|
||||
@@ -312,7 +312,7 @@ export async function deleteFolderSoftRescue(
|
||||
.set({ folderId: newParent })
|
||||
.where(and(eq(documents.folderId, folderId), eq(documents.portId, portId)));
|
||||
|
||||
// G-C1: files.folder_id is ON DELETE SET NULL — without this UPDATE,
|
||||
// G-C1: files.folder_id is ON DELETE SET NULL - without this UPDATE,
|
||||
// files in the deleted folder would scatter to root while documents
|
||||
// in the same folder land at the deleted folder's parent. Re-parent
|
||||
// files explicitly so the soft-rescue is symmetric across both.
|
||||
@@ -387,7 +387,7 @@ export async function ensureSystemRoots(portId: string, userId: string): Promise
|
||||
// inserts can only collide on `uniq_document_folders_sibling_name`
|
||||
// (entityId is null on roots, so the partial index
|
||||
// `uniq_document_folders_entity` is excluded). Do not copy this
|
||||
// pattern into helpers that insert per-entity subfolders — they
|
||||
// pattern into helpers that insert per-entity subfolders - they
|
||||
// need an explicit target to avoid masking real conflicts.
|
||||
await db.insert(documentFolders).values(values).onConflictDoNothing();
|
||||
|
||||
@@ -401,7 +401,7 @@ export async function ensureSystemRoots(portId: string, userId: string): Promise
|
||||
if (!row) {
|
||||
logger.error(
|
||||
{ portId, missingRoot: name, foundNames: rows.map((r) => r.name) },
|
||||
'ensureSystemRoots: invariant violated — system root missing after upsert',
|
||||
'ensureSystemRoots: invariant violated - system root missing after upsert',
|
||||
);
|
||||
throw new Error(`ensureSystemRoots: missing root ${name} after upsert`);
|
||||
}
|
||||
@@ -451,11 +451,11 @@ async function resolveEntityDisplayName(
|
||||
});
|
||||
if (!i) throw new NotFoundError('Interest');
|
||||
// Defer to the interest-berths service for the primary berth label
|
||||
// — circular-dep avoidance via dynamic import. Falls back to the
|
||||
// - circular-dep avoidance via dynamic import. Falls back to the
|
||||
// ISO date slice ("Deal 2026-05-12") when no berth is linked yet.
|
||||
const { getPrimaryBerth } = await import('@/lib/services/interest-berths.service');
|
||||
const primary = await getPrimaryBerth(entityId).catch(() => null);
|
||||
if (primary?.mooringNumber) return `Deal — ${primary.mooringNumber}`;
|
||||
if (primary?.mooringNumber) return `Deal - ${primary.mooringNumber}`;
|
||||
const dateSlice = i.createdAt.toISOString().slice(0, 10);
|
||||
return `Deal ${dateSlice}`;
|
||||
}
|
||||
@@ -495,7 +495,7 @@ function isEntityFolderConflict(err: unknown): boolean {
|
||||
* regardless of whether it was newly created or already existed.
|
||||
*
|
||||
* Concurrent callers race safely via the partial unique index
|
||||
* `uniq_document_folders_entity` — the loser INSERT errors and the
|
||||
* `uniq_document_folders_entity` - the loser INSERT errors and the
|
||||
* re-SELECT returns the winner's row.
|
||||
*
|
||||
* On sibling-name collision (two entities want the same display name),
|
||||
@@ -540,7 +540,7 @@ export async function ensureEntityFolder(
|
||||
columns: { clientId: true },
|
||||
});
|
||||
if (!interestRow) throw new NotFoundError('Interest');
|
||||
// Recursively ensure the parent client's folder first — guarantees
|
||||
// Recursively ensure the parent client's folder first - guarantees
|
||||
// we always land inside the existing Clients/<Name>/ subfolder even
|
||||
// when the deal's first artifact predates any client-level upload.
|
||||
parent = await ensureEntityFolder(portId, 'client', interestRow.clientId, userId);
|
||||
@@ -610,12 +610,12 @@ export async function ensureEntityFolder(
|
||||
* Rename the per-entity subfolder to match the entity's current display
|
||||
* name. Called from the entity rename services (`updateClient`,
|
||||
* `updateCompany`, `updateYacht`). No-op when the folder does not exist
|
||||
* (lazy creation — entities without a folder skip the sync entirely).
|
||||
* (lazy creation - entities without a folder skip the sync entirely).
|
||||
*
|
||||
* Sibling-name collision is resolved by suffix bump (matches
|
||||
* `ensureEntityFolder` semantics).
|
||||
*
|
||||
* Intentionally does NOT call `assertNotSystemManaged` — this helper
|
||||
* Intentionally does NOT call `assertNotSystemManaged` - this helper
|
||||
* is the legitimate path for renaming a system folder.
|
||||
*/
|
||||
export async function syncEntityFolderName(
|
||||
@@ -633,7 +633,7 @@ export async function syncEntityFolderName(
|
||||
eq(documentFolders.entityId, entityId),
|
||||
),
|
||||
});
|
||||
if (!folder) return; // Lazy creation — nothing to sync yet.
|
||||
if (!folder) return; // Lazy creation - nothing to sync yet.
|
||||
|
||||
// Preserve archived suffix if present.
|
||||
const isArchived = folder.name.endsWith(' (archived)');
|
||||
@@ -689,7 +689,7 @@ const DELETED_SUFFIX = ' (deleted)';
|
||||
|
||||
/**
|
||||
* Stamp an entity's subfolder as archived: append " (archived)" to the
|
||||
* name (idempotent — won't double-append) and set archived_at. No-op
|
||||
* name (idempotent - won't double-append) and set archived_at. No-op
|
||||
* when the folder does not exist (lazy creation). Used by the entity
|
||||
* archive paths in clients / companies / yachts services.
|
||||
*/
|
||||
@@ -730,7 +730,7 @@ export async function applyEntityArchivedSuffix(
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `applyEntityArchivedSuffix` — strip " (archived)" from
|
||||
* Inverse of `applyEntityArchivedSuffix` - strip " (archived)" from
|
||||
* the name and clear archived_at. No-op when the folder does not
|
||||
* exist or wasn't archived.
|
||||
*/
|
||||
@@ -775,7 +775,7 @@ export async function applyEntityRestoredSuffix(
|
||||
* folder by clearing `system_managed`, appending " (deleted)" to the
|
||||
* name, and dropping the entity FK so the partial unique index no
|
||||
* longer constrains it. Files still inside the folder retain their
|
||||
* snapshotted entity FKs (orphaned — they appear in the root-view
|
||||
* snapshotted entity FKs (orphaned - they appear in the root-view
|
||||
* Files section once the rep cleans up).
|
||||
*
|
||||
* Idempotent: re-demoting an already-demoted folder is a no-op because
|
||||
@@ -814,7 +814,7 @@ export async function demoteSystemFolderOnEntityDelete(
|
||||
|
||||
/**
|
||||
* Phase 2 nested-subfolders lifecycle hook. Re-renames an interest's
|
||||
* document folder when its outcome changes — e.g. `Deal A1-A3` becomes
|
||||
* document folder when its outcome changes - e.g. `Deal A1-A3` becomes
|
||||
* `Deal A1-A3 (Won)`, `(Lost)`, or `(Cancelled)`. No-op when the
|
||||
* folder doesn't exist yet (uploads happen later) or when the outcome
|
||||
* is null (still in flight).
|
||||
|
||||
@@ -24,10 +24,10 @@ export interface ParsedImportPath {
|
||||
* Edge cases:
|
||||
* - Trailing slashes on prefix are tolerated (`legacy/` ≡ `legacy`).
|
||||
* - Empty intermediate segments (`a//b`) collapse to `[a, b]`.
|
||||
* - Leading-prefix mismatch throws — the caller should never feed in keys
|
||||
* - Leading-prefix mismatch throws - the caller should never feed in keys
|
||||
* outside the prefix it asked the backend to list.
|
||||
* - A key that ends in `/` (directory placeholder) yields an empty
|
||||
* filename — the caller must filter those out before invoking.
|
||||
* filename - the caller must filter those out before invoking.
|
||||
*/
|
||||
export function parseImportPath(prefix: string, key: string): ParsedImportPath {
|
||||
const normalizedPrefix = prefix.replace(/\/+$/, '');
|
||||
|
||||
@@ -189,7 +189,7 @@ export async function sendReminderIfAllowed(
|
||||
* Performance: the pre-bulk version called `sendReminderIfAllowed` per
|
||||
* doc, which re-fetched the port row (invariant), the template-by-type
|
||||
* map (repeats heavily), the last reminder event, and the pending
|
||||
* signers — 5×N round trips per cron tick. This implementation hoists
|
||||
* signers - 5×N round trips per cron tick. This implementation hoists
|
||||
* the invariants out of the loop and turns the per-row queries into
|
||||
* grouped scans (one per dimension), so a port with 500 in-flight docs
|
||||
* is now ~7 round trips total instead of ~2500.
|
||||
@@ -206,7 +206,7 @@ export async function processReminderQueue(portId: string): Promise<void> {
|
||||
fileId: documents.fileId,
|
||||
})
|
||||
.from(documents)
|
||||
// CRITICAL: scope the join to the same port — `documentTemplates.templateType`
|
||||
// CRITICAL: scope the join to the same port - `documentTemplates.templateType`
|
||||
// is not unique across ports, so a leftJoin without `portId` produces a
|
||||
// cartesian explosion (one output row per template-type match across
|
||||
// every port). The downstream loop fires `documensoRemind` per row,
|
||||
@@ -234,16 +234,16 @@ export async function processReminderQueue(portId: string): Promise<void> {
|
||||
|
||||
// Hoist invariants out of the per-doc loop ────────────────────────────────
|
||||
|
||||
// (1) Port row (timezone) — invariant across the whole batch.
|
||||
// (1) Port row (timezone) - invariant across the whole batch.
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||
const timezone = port?.timezone ?? 'UTC';
|
||||
const currentHour = getCurrentHourInTimezone(timezone);
|
||||
if (currentHour < 9 || currentHour >= 16) {
|
||||
// Outside the 9-16 window — nothing to do this tick.
|
||||
// Outside the 9-16 window - nothing to do this tick.
|
||||
return;
|
||||
}
|
||||
|
||||
// (2) Per-type template cadence map — repeats per documentType.
|
||||
// (2) Per-type template cadence map - repeats per documentType.
|
||||
const distinctTypes = Array.from(new Set(activeDocs.map((d) => d.documentType)));
|
||||
const templateRows = await db
|
||||
.select({
|
||||
@@ -261,7 +261,7 @@ export async function processReminderQueue(portId: string): Promise<void> {
|
||||
templateRows.map((r) => [r.templateType, r.reminderCadenceDays ?? null]),
|
||||
);
|
||||
|
||||
// (3) Latest reminder_sent event per doc — one grouped query.
|
||||
// (3) Latest reminder_sent event per doc - one grouped query.
|
||||
const docIds = activeDocs.map((d) => d.id);
|
||||
const lastReminderRows = await db
|
||||
.select({
|
||||
@@ -278,7 +278,7 @@ export async function processReminderQueue(portId: string): Promise<void> {
|
||||
.groupBy(documentEvents.documentId);
|
||||
const lastReminderByDoc = new Map(lastReminderRows.map((r) => [r.documentId, r.lastAt]));
|
||||
|
||||
// (4) Pending signers per doc — one inArray scan.
|
||||
// (4) Pending signers per doc - one inArray scan.
|
||||
const pendingSignerRows = await db
|
||||
.select()
|
||||
.from(documentSigners)
|
||||
@@ -291,7 +291,7 @@ export async function processReminderQueue(portId: string): Promise<void> {
|
||||
pendingByDoc.set(row.documentId, arr);
|
||||
}
|
||||
|
||||
// Per-doc fire — at this point every per-row query is a Map.get.
|
||||
// Per-doc fire - at this point every per-row query is a Map.get.
|
||||
for (const doc of activeDocs) {
|
||||
try {
|
||||
const due = isReminderDue({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Sales send-out flow (Phase 7 — see plan §4.8 / §11.1 / §14.7).
|
||||
* Sales send-out flow (Phase 7 - see plan §4.8 / §11.1 / §14.7).
|
||||
*
|
||||
* Sends per-berth PDFs and brochures to a client recipient, attaching the
|
||||
* file when it's at-or-below the configured threshold or falling back to a
|
||||
@@ -9,17 +9,17 @@
|
||||
*
|
||||
* §14.7 critical mitigations implemented here:
|
||||
*
|
||||
* - **Body XSS** — bodies go through `renderEmailBody()` (HTML-escape +
|
||||
* - **Body XSS** - bodies go through `renderEmailBody()` (HTML-escape +
|
||||
* allowlist of markdown rules) before reaching nodemailer.
|
||||
* - **Recipient typo** — recipient email validated against a strict regex
|
||||
* - **Recipient typo** - recipient email validated against a strict regex
|
||||
* before the SMTP transaction.
|
||||
* - **Unresolved merge fields** — `findUnresolvedTokens()` is exported
|
||||
* - **Unresolved merge fields** - `findUnresolvedTokens()` is exported
|
||||
* for the dry-run UI; the service blocks sends with unresolved tokens
|
||||
* unless `allowUnresolved: true` is explicitly passed (test-only).
|
||||
* - **SMTP failure** — every transport rejection writes a `failedAt` row
|
||||
* - **SMTP failure** - every transport rejection writes a `failedAt` row
|
||||
* with `errorReason` and surfaces a typed error to the API.
|
||||
* - **Hourly rate limit** — 50 sends/user/hour individual.
|
||||
* - **Size threshold fallback** — files larger than the per-port
|
||||
* - **Hourly rate limit** - 50 sends/user/hour individual.
|
||||
* - **Size threshold fallback** - files larger than the per-port
|
||||
* `email_attach_threshold_mb` go as a signed-URL link in the body
|
||||
* instead of an attachment (§11.1).
|
||||
*/
|
||||
@@ -105,7 +105,7 @@ export interface SendResult {
|
||||
send: DocumentSend;
|
||||
/** True when the file was attached; false when a signed-URL link was used. */
|
||||
deliveredAsAttachment: boolean;
|
||||
/** Set when the transport rejected — the row carries `failedAt`. */
|
||||
/** Set when the transport rejected - the row carries `failedAt`. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ export async function buildMergeValues(
|
||||
// Custom-field tokens (`{{custom.<fieldName>}}`). The validator allows
|
||||
// any matching shape; the resolver here looks up real values per-port,
|
||||
// per-entity and substitutes them. Unknown field names stay
|
||||
// unresolved — `findUnresolvedTokens` flags them at preview time so
|
||||
// unresolved - `findUnresolvedTokens` flags them at preview time so
|
||||
// the rep can edit the template before sending.
|
||||
await mergeCustomFieldValues(values, portId, recipient, context);
|
||||
|
||||
@@ -335,7 +335,7 @@ async function resolveRecipientEmail(
|
||||
* before it lands on the `document_sends` audit row. Without this, an
|
||||
* attacker who knows a foreign-port interest UUID can pollute another
|
||||
* tenant's audit history (the surrounding `clientId` lookup is already
|
||||
* port-scoped, so data isn't exposed — but the audit trail would be).
|
||||
* port-scoped, so data isn't exposed - but the audit trail would be).
|
||||
*/
|
||||
async function assertInterestInPort(portId: string, interestId: string): Promise<void> {
|
||||
const row = await db.query.interests.findFirst({
|
||||
@@ -347,7 +347,7 @@ async function assertInterestInPort(portId: string, interestId: string): Promise
|
||||
|
||||
async function checkSendRateLimit(portId: string, userId: string): Promise<void> {
|
||||
// Per-(port, user) so a multi-port rep can't be DoS'd by another tenant
|
||||
// burning their global cap. Audit caught this — the original
|
||||
// burning their global cap. Audit caught this - the original
|
||||
// single-key version locked a user out across every port they touched.
|
||||
const result = await checkRateLimit(`${portId}:${userId}`, {
|
||||
windowMs: 60 * 60 * 1000,
|
||||
@@ -386,7 +386,7 @@ async function streamAttachmentOrLink(
|
||||
const storage = await getStorageBackend();
|
||||
const stream = await storage.get(attachment.storageKey);
|
||||
// The storage abstraction returns NodeJS.ReadableStream; nodemailer's
|
||||
// Attachment.content type wants `Readable`. The two are compatible —
|
||||
// Attachment.content type wants `Readable`. The two are compatible -
|
||||
// both stream backends expose a Readable. Cast to keep types tight.
|
||||
const readable = stream as unknown as Readable;
|
||||
return {
|
||||
@@ -400,7 +400,7 @@ async function streamAttachmentOrLink(
|
||||
// so we never produce duplicate sends.
|
||||
const storage = await getStorageBackend();
|
||||
// Bind the proxy token to the issuing port slug. The storage key is
|
||||
// already structured `${portSlug}/...` via generateStorageKey() — this
|
||||
// already structured `${portSlug}/...` via generateStorageKey() - this
|
||||
// closes the loop so a buggy future call site that hands us a key from
|
||||
// a different port can't mint a valid 24h URL for it.
|
||||
const portRow = await db.query.ports.findFirst({
|
||||
@@ -440,7 +440,7 @@ async function performSend(args: {
|
||||
? `${args.bodyHtml}\n${delivery.bodySuffixHtml}`
|
||||
: args.bodyHtml;
|
||||
|
||||
// 1b. Phase 4b — open tracking. Pre-allocate the send-row UUID so we
|
||||
// 1b. Phase 4b - open tracking. Pre-allocate the send-row UUID so we
|
||||
// can embed a per-send tracking pixel before we know whether the SMTP
|
||||
// call will succeed. The pixel endpoint itself gates on
|
||||
// `track_opens=true`, so a failed send with the pixel still embedded
|
||||
@@ -497,7 +497,7 @@ async function performSend(args: {
|
||||
fallbackToLinkReason: delivery.deliveredAsAttachment ? null : 'size_above_threshold',
|
||||
})
|
||||
.returning();
|
||||
// Phase 7 — Umami attribution. Send completion is the "email sent"
|
||||
// Phase 7 - Umami attribution. Send completion is the "email sent"
|
||||
// half of the email funnel; opens (Phase 4b) and click-throughs
|
||||
// (Phase 4c) follow as separate events keyed by sendId.
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
@@ -566,7 +566,7 @@ export async function sendBerthPdf(input: SendBerthPdfInput): Promise<SendResult
|
||||
const bodyHtml = renderEmailBody(expanded);
|
||||
|
||||
// Subject pulls in the mooring number for inbox triage.
|
||||
const subject = `Berth ${berth.mooringNumber} — spec sheet`;
|
||||
const subject = `Berth ${berth.mooringNumber} - spec sheet`;
|
||||
|
||||
await checkSendRateLimit(input.portId, input.sentBy);
|
||||
|
||||
@@ -624,7 +624,7 @@ export async function sendBrochure(input: SendBrochureInput): Promise<SendResult
|
||||
);
|
||||
}
|
||||
// The partial unique index on `is_default` only enforces uniqueness when
|
||||
// archived_at IS NULL — an archived row can still carry is_default=true
|
||||
// archived_at IS NULL - an archived row can still carry is_default=true
|
||||
// and would silently be returned here without this guard.
|
||||
if (def.archivedAt) {
|
||||
throw new ValidationError(
|
||||
@@ -661,7 +661,7 @@ export async function sendBrochure(input: SendBrochureInput): Promise<SendResult
|
||||
}
|
||||
const expanded = expandMergeTokens(template, values);
|
||||
const bodyHtml = renderEmailBody(expanded);
|
||||
const subject = `${brochureRow.label} — brochure`;
|
||||
const subject = `${brochureRow.label} - brochure`;
|
||||
|
||||
await checkSendRateLimit(input.portId, input.sentBy);
|
||||
|
||||
@@ -716,7 +716,7 @@ export async function listSends(filters: ListSendsFilters): Promise<DocumentSend
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Phase 4b — per-port kill switch for email open tracking. Stored in
|
||||
// Phase 4b - per-port kill switch for email open tracking. Stored in
|
||||
// `system_settings` under `email_open_tracking_enabled` (boolean). Default
|
||||
// FALSE so the feature is explicit-opt-in by an admin. Cached per-port
|
||||
// for 60 s to avoid hitting `system_settings` on every send.
|
||||
@@ -727,7 +727,7 @@ async function isOpenTrackingEnabled(portId: string): Promise<boolean> {
|
||||
const cached = trackingEnabledCache.get(portId);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
const row = await getSetting('email_open_tracking_enabled', portId);
|
||||
// value is stored as a JSON-encoded primitive — accept boolean true OR
|
||||
// value is stored as a JSON-encoded primitive - accept boolean true OR
|
||||
// the strings "true" / "1" for resilience against admin UIs that
|
||||
// serialize booleans as strings.
|
||||
const raw = row?.value as unknown;
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
/**
|
||||
* Sends Documenso-related signing emails:
|
||||
*
|
||||
* - `sendSigningInvitation` — initial "your turn to sign" email
|
||||
* - `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.
|
||||
* - `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
|
||||
* - `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
|
||||
* 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
|
||||
* 2. **Per-port branding lookup** - fetches the port's branding
|
||||
* config (logo, primary color, header/footer HTML) and threads
|
||||
* it into the email shell.
|
||||
*
|
||||
@@ -54,9 +54,9 @@ export interface SigningInvitationArgs {
|
||||
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. */
|
||||
/** Document type - drives subject line and body copy. */
|
||||
documentLabel: DocumentLabel;
|
||||
/** Signer role — drives copy variant + the embedded URL's role segment. */
|
||||
/** Signer role - drives copy variant + the embedded URL's role segment. */
|
||||
signerRole: SignerRole;
|
||||
/** Optional rep-authored note inserted above the CTA. */
|
||||
customMessage?: string | null;
|
||||
@@ -75,7 +75,7 @@ export interface SigningReminderArgs extends Omit<SigningInvitationArgs, 'signer
|
||||
export interface SigningCancelledArgs {
|
||||
portId: string;
|
||||
portName: string;
|
||||
/** Recipients to notify of the cancellation. Caller decides who —
|
||||
/** Recipients to notify of the cancellation. Caller decides who -
|
||||
* the rep typically picks a subset of the original signers via the
|
||||
* cancel-with-notify modal. Empty list = no emails fire (the
|
||||
* Regenerate flow path). */
|
||||
@@ -88,7 +88,7 @@ export interface SigningCancelledArgs {
|
||||
export interface SigningCompletedArgs {
|
||||
portId: string;
|
||||
portName: string;
|
||||
/** All signers — each gets the same email + attached signed PDF. */
|
||||
/** 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;
|
||||
@@ -126,7 +126,7 @@ export interface SigningCompletedArgs {
|
||||
* 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
|
||||
* 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'> = {
|
||||
@@ -285,7 +285,7 @@ export async function sendSigningCompleted(args: SigningCompletedArgs): Promise<
|
||||
{ err, portId: args.portId, recipient: recipient.email },
|
||||
'Signing-completed email send failed',
|
||||
);
|
||||
// Don't throw — sending to one recipient shouldn't block the others.
|
||||
// Don't throw - sending to one recipient shouldn't block the others.
|
||||
}
|
||||
}),
|
||||
),
|
||||
@@ -294,7 +294,7 @@ export async function sendSigningCompleted(args: SigningCompletedArgs): Promise<
|
||||
|
||||
/**
|
||||
* Notify a subset of signers that an EOI / contract has been cancelled.
|
||||
* Called by the cancel-with-notify modal — empty `recipients` is a
|
||||
* Called by the cancel-with-notify modal - empty `recipients` is a
|
||||
* no-op (the Regenerate path, where the rep wants to silently void).
|
||||
*/
|
||||
export async function sendSigningCancelled(args: SigningCancelledArgs): Promise<void> {
|
||||
|
||||
@@ -599,7 +599,7 @@ export async function generateAndSign(
|
||||
meta: AuditMeta,
|
||||
options?: { dimensionUnit?: 'ft' | 'm'; overrides?: EoiOverridesInput },
|
||||
) {
|
||||
// Phase 3b — apply per-field overrides BEFORE either pathway resolves the
|
||||
// Phase 3b - apply per-field overrides BEFORE either pathway resolves the
|
||||
// EOI context, so any setAsDefault contact promotion is visible to the
|
||||
// buildEoiContext read. The returned `applied.resolved` is layered onto
|
||||
// the in-memory context for useOnlyForThisEoi / fresh-value cases where
|
||||
@@ -669,8 +669,8 @@ async function generateAndSignViaInApp(
|
||||
// EOI templates fill the same source PDF as the Documenso template (so both
|
||||
// pathways yield the same document). The HTML→pdfme rendering path for
|
||||
// non-EOI templates was removed in the PDF stack overhaul (see the design
|
||||
// spec). Send non-EOI documents via the Documenso pathway, OR — once it
|
||||
// ships — the admin-uploaded AcroForm-fill template feature.
|
||||
// spec). Send non-EOI documents via the Documenso pathway, OR - once it
|
||||
// ships - the admin-uploaded AcroForm-fill template feature.
|
||||
if (template.templateType !== 'eoi') {
|
||||
throw new ValidationError(
|
||||
`In-app PDF rendering for templates of type "${template.templateType}" is not supported. ` +
|
||||
@@ -686,7 +686,7 @@ async function generateAndSignViaInApp(
|
||||
applied,
|
||||
);
|
||||
|
||||
// Phase 3b — record per-document override columns + backfill the
|
||||
// Phase 3b - record per-document override columns + backfill the
|
||||
// source_document_id on any client_contacts rows inserted during the
|
||||
// override side-effects.
|
||||
await persistDocumentOverrides(documentRecord.id, applied, meta);
|
||||
@@ -831,7 +831,7 @@ async function generateAndSignViaDocumensoTemplate(
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Phase 3b — record any per-document override columns + backfill
|
||||
// Phase 3b - record any per-document override columns + backfill
|
||||
// source_document_id on freshly inserted contact rows.
|
||||
await persistDocumentOverrides(documentRecord!.id, applied, meta);
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ function buildHubTabFilters(
|
||||
|
||||
switch (tab) {
|
||||
case 'in_progress':
|
||||
// All document types currently in-flight — the everyday "what's in flight" view.
|
||||
// All document types currently in-flight - the everyday "what's in flight" view.
|
||||
filters.push(
|
||||
inArray(documents.status, ['draft', 'sent', 'partially_signed']),
|
||||
sql`${documents.status} != 'expired'`,
|
||||
@@ -204,7 +204,7 @@ export async function listDocuments(
|
||||
} else {
|
||||
filters.push(eq(documents.folderId, query.folderId));
|
||||
}
|
||||
// When viewing a specific folder, hide completed workflows — they surface
|
||||
// When viewing a specific folder, hide completed workflows - they surface
|
||||
// via their resulting signed-PDF file row in the Files section, not the
|
||||
// Signing section.
|
||||
filters.push(ne(documents.status, 'completed'));
|
||||
@@ -272,7 +272,7 @@ export async function listDocuments(
|
||||
* Resolve the rep-facing download URL for a document. The URL embeds the
|
||||
* folder path + filename for browser-tab / shared-link readability, but the
|
||||
* route handler keys lookup off the doc id and validates the slug for truth
|
||||
* — a hand-edited URL with the wrong path 404s instead of silently serving
|
||||
* - a hand-edited URL with the wrong path 404s instead of silently serving
|
||||
* the wrong file.
|
||||
*
|
||||
* Pass the resolved folder tree once per request and call this for each doc
|
||||
@@ -297,7 +297,7 @@ export function buildDocumentDownloadUrl(
|
||||
/**
|
||||
* Walk the folder tree to materialize the ancestor chain that ends at
|
||||
* `id`. Returns roots-first; empty when the id is missing (orphan
|
||||
* `folder_id` pointer — see listTree's intentional silent drop).
|
||||
* `folder_id` pointer - see listTree's intentional silent drop).
|
||||
*/
|
||||
export function findFolderPath(tree: readonly FolderNode[], id: string): FolderNode[] {
|
||||
for (const node of tree) {
|
||||
@@ -623,7 +623,7 @@ export async function updateDocument(
|
||||
* accepting signatures and outstanding signing URLs invalidate).
|
||||
*
|
||||
* Refuses to delete a document in the middle of signing (`sent` /
|
||||
* `partially_signed`) — reps must cancel first, then delete the
|
||||
* `partially_signed`) - reps must cancel first, then delete the
|
||||
* cancelled record.
|
||||
*/
|
||||
export async function deleteDocument(id: string, portId: string, meta: AuditMeta) {
|
||||
@@ -631,7 +631,7 @@ export async function deleteDocument(id: string, portId: string, meta: AuditMeta
|
||||
|
||||
if (['sent', 'partially_signed'].includes(existing.status)) {
|
||||
throw new ConflictError(
|
||||
'Cannot delete a document while signing is in progress — cancel it first, then delete the cancelled record.',
|
||||
'Cannot delete a document while signing is in progress - cancel it first, then delete the cancelled record.',
|
||||
);
|
||||
}
|
||||
if (existing.status === 'deleted') {
|
||||
@@ -640,7 +640,7 @@ export async function deleteDocument(id: string, portId: string, meta: AuditMeta
|
||||
}
|
||||
|
||||
// Best-effort upstream void. A transient Documenso failure shouldn't
|
||||
// block the CRM-side delete — the document_events row + audit log
|
||||
// block the CRM-side delete - the document_events row + audit log
|
||||
// capture what happened, and `voidDocument` treats 404 (already gone)
|
||||
// as success so a Documenso UI re-delete remains safe.
|
||||
if (existing.documensoId) {
|
||||
@@ -765,7 +765,7 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
||||
const pdfBase64 = pdfBuffer.toString('base64');
|
||||
|
||||
// Read per-port v2 signing settings (PARALLEL/SEQUENTIAL + redirect URL).
|
||||
// Both are optional — passing undefined yields v1's legacy behavior.
|
||||
// Both are optional - passing undefined yields v1's legacy behavior.
|
||||
const docCfg = await getPortDocumensoConfig(portId);
|
||||
|
||||
// Create document in Documenso + send. portId is required for the v2
|
||||
@@ -800,7 +800,7 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
||||
|
||||
// Update signer records with signing URLs + tokens from the Documenso
|
||||
// response. The signingToken column powers the webhook recipient-match
|
||||
// path (more robust than email match — same email can serve multiple
|
||||
// path (more robust than email match - same email can serve multiple
|
||||
// roles on a contract). Documenso's recipient response carries `token`
|
||||
// explicitly per the OpenAPI spec; we keep the URL-extraction fallback
|
||||
// for any v2 deployment whose distribute response omits the field.
|
||||
@@ -969,7 +969,7 @@ export async function uploadSignedManually(
|
||||
if (interest) {
|
||||
void evaluateRule('eoi_signed', doc.interestId, portId, meta);
|
||||
|
||||
// Stage stays at 'eoi' — sub-status badge flips to "signed".
|
||||
// Stage stays at 'eoi' - sub-status badge flips to "signed".
|
||||
void advanceStageIfBehind(
|
||||
doc.interestId,
|
||||
portId,
|
||||
@@ -1043,8 +1043,8 @@ export async function listDocumentEvents(documentId: string, portId: string) {
|
||||
|
||||
/**
|
||||
* Shared port-scoped lookup for inbound Documenso webhooks. Two ports
|
||||
* sharing a Documenso instance — or migrating between instances with
|
||||
* documentId reuse — would otherwise let `findFirst` return whichever
|
||||
* sharing a Documenso instance - or migrating between instances with
|
||||
* documentId reuse - would otherwise let `findFirst` return whichever
|
||||
* row sorts first across tenants. When the route resolves a portId from
|
||||
* the matched per-port webhook secret it threads it here; otherwise we
|
||||
* fall back to a port-agnostic `findMany` and refuse to mutate when the
|
||||
@@ -1089,7 +1089,7 @@ async function resolveWebhookDocument(
|
||||
if (matches.length > 1) {
|
||||
logger.error(
|
||||
{ documensoId, matchCount: matches.length, ports: matches.map((m) => m.portId) },
|
||||
'Documenso webhook ambiguous across multiple ports — refusing to mutate',
|
||||
'Documenso webhook ambiguous across multiple ports - refusing to mutate',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -1099,7 +1099,7 @@ async function resolveWebhookDocument(
|
||||
export async function handleRecipientSigned(eventData: {
|
||||
documentId: string;
|
||||
recipientEmail: string;
|
||||
/** Optional Documenso recipient token — when supplied (webhook
|
||||
/** Optional Documenso recipient token - when supplied (webhook
|
||||
* payload exposes it on v1.13 + 2.x), preferred over the email
|
||||
* match because a single email can serve multiple roles on one
|
||||
* document. Falls back to email match when null. */
|
||||
@@ -1112,7 +1112,7 @@ export async function handleRecipientSigned(eventData: {
|
||||
|
||||
// Token-match first, fall back to email match. Phase 2: webhook
|
||||
// payloads carry `recipients[].token` which we captured at send-time
|
||||
// via extractSigningToken — that's the authoritative identifier.
|
||||
// via extractSigningToken - that's the authoritative identifier.
|
||||
const signerWhere = eventData.recipientToken
|
||||
? and(
|
||||
eq(documentSigners.documentId, doc.id),
|
||||
@@ -1126,7 +1126,7 @@ export async function handleRecipientSigned(eventData: {
|
||||
// Read prior status so we know whether this delivery is the first
|
||||
// signing transition. Documenso v2 retries deliver the same
|
||||
// DOCUMENT_RECIPIENT_COMPLETED multiple times with slightly different
|
||||
// rawBody hashes — without this gate the cascade fires on every
|
||||
// rawBody hashes - without this gate the cascade fires on every
|
||||
// delivery, the "your turn" email goes out twice, and downstream side
|
||||
// effects (rule engine, audit, notifications) duplicate.
|
||||
const [priorSigner] = await db.select().from(documentSigners).where(signerWhere);
|
||||
@@ -1137,7 +1137,7 @@ export async function handleRecipientSigned(eventData: {
|
||||
.set({
|
||||
status: 'signed',
|
||||
// Preserve the original signedAt timestamp on duplicate webhook
|
||||
// deliveries — overwriting it makes every signer card show the
|
||||
// deliveries - overwriting it makes every signer card show the
|
||||
// most-recent webhook timestamp instead of the actual sign time.
|
||||
...(wasAlreadySigned ? {} : { signedAt: new Date() }),
|
||||
})
|
||||
@@ -1195,12 +1195,12 @@ export async function handleRecipientSigned(eventData: {
|
||||
|
||||
// Phase 2 cascade: now that this signer is done, fire the branded
|
||||
// "your turn" invitation to the next pending signer in signing order.
|
||||
// Skip the cascade entirely on duplicate deliveries — only fire on
|
||||
// Skip the cascade entirely on duplicate deliveries - only fire on
|
||||
// the first pending→signed transition. The `invitedAt` guard inside
|
||||
// sendCascadingInviteForNextSigner is a second safety net.
|
||||
if (signer && !wasAlreadySigned) {
|
||||
await sendCascadingInviteForNextSigner(doc).catch((err) => {
|
||||
// Cascading-invite failure is non-fatal — the webhook itself
|
||||
// Cascading-invite failure is non-fatal - the webhook itself
|
||||
// succeeded. The rep can manually click "Send invitation" if the
|
||||
// email worker is down.
|
||||
logger.error(
|
||||
@@ -1212,7 +1212,7 @@ export async function handleRecipientSigned(eventData: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 — cascading invite logic extracted so the
|
||||
* Phase 2 - cascading invite logic extracted so the
|
||||
* `handleRecipientSigned` handler stays readable and so the same path
|
||||
* can be exercised by a dedicated unit test. Finds the next pending
|
||||
* signer (lowest signing order), sends them a branded invitation, and
|
||||
@@ -1238,7 +1238,7 @@ async function sendCascadingInviteForNextSigner(doc: {
|
||||
const next = nextPendingSigner(signers);
|
||||
if (!next) return;
|
||||
if (next.invitedAt) {
|
||||
// We've already invited them — either via the auto-send wiring at
|
||||
// We've already invited them - either via the auto-send wiring at
|
||||
// document creation (first signer) or via an earlier cascade. Do
|
||||
// nothing rather than spam them with a second copy.
|
||||
return;
|
||||
@@ -1270,7 +1270,7 @@ async function sendCascadingInviteForNextSigner(doc: {
|
||||
.set({ invitedAt: new Date() })
|
||||
.where(eq(documentSigners.id, next.id));
|
||||
|
||||
// Phase 7 — Project Director RBAC binding: when the per-port settings
|
||||
// Phase 7 - Project Director RBAC binding: when the per-port settings
|
||||
// map the developer / approver slot to a CRM user (developerUserId /
|
||||
// approverUserId), fire an in-CRM notification so the user sees their
|
||||
// pending signing turn alongside the branded email. The email is the
|
||||
@@ -1313,7 +1313,7 @@ interface ResolvedOwner {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-wins owner resolution chain — see spec §"Routing on workflow
|
||||
* Owner-wins owner resolution chain - see spec §"Routing on workflow
|
||||
* completion" §3a. Returns the first non-null candidate in priority
|
||||
* order: direct client/company/yacht FK on the document, then via the
|
||||
* linked interest's client / yacht FK. The interests table has no
|
||||
@@ -1334,7 +1334,7 @@ async function resolveDocumentOwner(
|
||||
if (doc.yachtId) return { entityType: 'yacht', entityId: doc.yachtId };
|
||||
|
||||
if (doc.interestId) {
|
||||
// interests.clientId is NOT NULL — if the interest row exists, the
|
||||
// interests.clientId is NOT NULL - if the interest row exists, the
|
||||
// client owner is always resolvable through it. The yacht-only path
|
||||
// would require relaxing the schema constraint first.
|
||||
const interest = await db.query.interests.findFirst({
|
||||
@@ -1368,7 +1368,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
// A1: Idempotency gate. Documenso retries DOCUMENT_COMPLETED on receiver
|
||||
// 5xx (and the poll worker also reconciles). Without this guard, a second
|
||||
// delivery re-runs downloadSignedPdf + storage.put + db.insert(files) and
|
||||
// then clobbers the previous signedFileId on the UPDATE — leaking the
|
||||
// then clobbers the previous signedFileId on the UPDATE - leaking the
|
||||
// first file as an orphan blob with no DB pointer. Once we have a signed
|
||||
// file id we are done.
|
||||
if (doc.status === 'completed' && doc.signedFileId) return;
|
||||
@@ -1388,7 +1388,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
|
||||
try {
|
||||
// Download by the stored Documenso ID (envelope_xxx on v2, numeric on
|
||||
// v1) rather than `eventData.documentId` — webhooks deliver the v2
|
||||
// v1) rather than `eventData.documentId` - webhooks deliver the v2
|
||||
// numeric internal pk, but the download endpoint expects the public
|
||||
// envelope_xxx string. Falls back to the webhook's value when the
|
||||
// stored ID is somehow missing (e.g. legacy pre-#69 rows).
|
||||
@@ -1430,7 +1430,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
);
|
||||
entityFolderId = folder.id;
|
||||
} catch (err) {
|
||||
// Folder creation is best-effort — signed file still lands at root.
|
||||
// Folder creation is best-effort - signed file still lands at root.
|
||||
// Logged at warn level: missing entity folder is recoverable via
|
||||
// the backfill script.
|
||||
logger.warn(
|
||||
@@ -1459,7 +1459,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
.for('update');
|
||||
|
||||
if (locked && locked.status === 'completed' && locked.signedFileId) {
|
||||
// Concurrent webhook beat us — abort so the outer catch deletes
|
||||
// Concurrent webhook beat us - abort so the outer catch deletes
|
||||
// the duplicate blob we just put into storage. Throw a sentinel
|
||||
// we recognize so we don't log it as an error.
|
||||
throw new DocumentAlreadyCompletedError();
|
||||
@@ -1531,13 +1531,13 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
userAgent: 'webhook',
|
||||
});
|
||||
} catch (err) {
|
||||
// Distinguish "we lost the concurrent race" from a real failure —
|
||||
// Distinguish "we lost the concurrent race" from a real failure -
|
||||
// the loser of the SELECT FOR UPDATE re-check should clean up its
|
||||
// blob silently, not log an error.
|
||||
if (err instanceof DocumentAlreadyCompletedError) {
|
||||
logger.info(
|
||||
{ documentId: doc.id, portId: doc.portId },
|
||||
'Webhook race lost — another worker already committed the signed PDF; deleting our duplicate blob',
|
||||
'Webhook race lost - another worker already committed the signed PDF; deleting our duplicate blob',
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
@@ -1556,16 +1556,16 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
'Compensating storage.delete after failed signed-PDF commit',
|
||||
);
|
||||
} catch (compErr) {
|
||||
// We tried — log so a human can clean up the orphan if needed.
|
||||
// We tried - log so a human can clean up the orphan if needed.
|
||||
logger.error(
|
||||
{ compErr, documentId: doc.id, storagePath: putStoragePath },
|
||||
'Compensating storage.delete also failed — manual cleanup required',
|
||||
'Compensating storage.delete also failed - manual cleanup required',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Critical: do NOT set documents.status = 'completed' on failure.
|
||||
// The previous catch block did — which created the "completed-with-
|
||||
// The previous catch block did - which created the "completed-with-
|
||||
// no-signedFileId" zombie state the audit flagged. Let the next
|
||||
// Documenso retry (or our poll-worker reconciliation) re-attempt;
|
||||
// the early-return idempotency gate at the top requires BOTH
|
||||
@@ -1597,7 +1597,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
};
|
||||
|
||||
// Guard against double-fire: DOCUMENT_COMPLETED may arrive multiple
|
||||
// times. evaluateRule has no idempotency — skip when the interest is
|
||||
// times. evaluateRule has no idempotency - skip when the interest is
|
||||
// already past the EOI stage so the berth-rule side effect runs once.
|
||||
const currentStageIdx = PIPELINE_STAGES.indexOf(
|
||||
interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
|
||||
@@ -1621,7 +1621,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
'eoi_signed',
|
||||
);
|
||||
|
||||
// Phase 7 — Umami attribution. EOI signed is the headline
|
||||
// Phase 7 - Umami attribution. EOI signed is the headline
|
||||
// conversion event so it gets its own Umami event for funnel
|
||||
// visibility (rather than rolling up into "interest-stage-changed").
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
@@ -1633,7 +1633,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
}
|
||||
}
|
||||
|
||||
// Update interest if reservation_agreement type — kept out of the
|
||||
// Update interest if reservation_agreement type - kept out of the
|
||||
// signed-PDF try/catch above so a Documenso PDF-download failure doesn't
|
||||
// also lose the sub-status stamp (which the rep can see immediately on
|
||||
// the interest detail page).
|
||||
@@ -1707,7 +1707,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
// Phase 2: distribute the fully-signed PDF to every recipient via a
|
||||
// branded "all signed" email. Re-read the document so we see the
|
||||
// signedFileId the transaction above just committed + the
|
||||
// completionCcEmails list (Phase 2 — sales mgr / accounts etc who get
|
||||
// completionCcEmails list (Phase 2 - sales mgr / accounts etc who get
|
||||
// a copy without being a signer).
|
||||
const completedDoc = await db.query.documents.findFirst({
|
||||
where: eq(documents.id, doc.id),
|
||||
@@ -1722,7 +1722,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
.from(documentSigners)
|
||||
.where(eq(documentSigners.documentId, doc.id));
|
||||
|
||||
// Phase 2 CC list — emails that weren't signers but get a copy of
|
||||
// Phase 2 CC list - emails that weren't signers but get a copy of
|
||||
// the finalized PDF on completion. Filter to addresses not already
|
||||
// in the signer set (case-insensitive) so a sales mgr who's also
|
||||
// a signer doesn't get two emails.
|
||||
@@ -1741,7 +1741,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
columns: { name: true },
|
||||
});
|
||||
|
||||
// Resolve the deal's primary client name for the salutation —
|
||||
// Resolve the deal's primary client name for the salutation -
|
||||
// falls back to the document title when the owner chain doesn't
|
||||
// surface a client.
|
||||
let clientName = doc.title;
|
||||
@@ -1764,7 +1764,7 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
signedPdfFileId: completedDoc.signedFileId,
|
||||
signedPdfFilename: `signed-${doc.id}.pdf`,
|
||||
}).catch((err) => {
|
||||
// Don't let a downstream email failure undo the completion —
|
||||
// Don't let a downstream email failure undo the completion -
|
||||
// the signed PDF is already stored and the document row is
|
||||
// marked completed. Log + emit so admins can re-trigger via
|
||||
// the manual "Send copy" flow.
|
||||
@@ -1822,7 +1822,7 @@ export async function handleDocumentExpired(eventData: { documentId: string; por
|
||||
export async function handleDocumentOpened(eventData: {
|
||||
documentId: string;
|
||||
recipientEmail: string;
|
||||
/** Optional Documenso recipient token — preferred over email match
|
||||
/** Optional Documenso recipient token - preferred over email match
|
||||
* (same email may serve multiple roles on one document). */
|
||||
recipientToken?: string | null;
|
||||
signatureHash?: string;
|
||||
@@ -1926,7 +1926,7 @@ export async function handleDocumentRejected(eventData: {
|
||||
// 1. Notify the interest's assigned rep in-CRM (drives the EOI tab
|
||||
// banner via the realtime invalidation + the bell).
|
||||
// 2. Audit-log so the timeline surfaces the rejection.
|
||||
// Email cascade to the other signers is intentionally NOT fired —
|
||||
// Email cascade to the other signers is intentionally NOT fired -
|
||||
// the legal flow is "this EOI is dead, regenerate"; messaging the
|
||||
// co-signers would create noise. The rep handles outreach manually.
|
||||
if (doc.interestId) {
|
||||
@@ -1943,8 +1943,8 @@ export async function handleDocumentRejected(eventData: {
|
||||
type: 'document_rejected',
|
||||
title: 'EOI declined',
|
||||
description: eventData.recipientEmail
|
||||
? `${eventData.recipientEmail} declined to sign — review and regenerate.`
|
||||
: 'A signer declined the EOI — review and regenerate.',
|
||||
? `${eventData.recipientEmail} declined to sign - review and regenerate.`
|
||||
: 'A signer declined the EOI - review and regenerate.',
|
||||
link: `/interests/${doc.interestId}?tab=eoi`,
|
||||
entityType: 'document',
|
||||
entityId: doc.id,
|
||||
@@ -2017,7 +2017,7 @@ export interface DocumentDetailWatcher {
|
||||
/**
|
||||
* #67 linked-entity resolution: resolve each polymorphic FK on the
|
||||
* document to a human-readable name so the doc-detail "Linked entity"
|
||||
* card can render "Interest — Matt Ciaccio" instead of "Interest →".
|
||||
* card can render "Interest - Matt Ciaccio" instead of "Interest →".
|
||||
* Each side is null when the FK is null or the row was deleted.
|
||||
*/
|
||||
export interface DocumentDetailLinkedEntities {
|
||||
@@ -2158,6 +2158,16 @@ export interface CancelDocumentOptions {
|
||||
* Empty list = silent void (Regenerate flow). Each id is validated to
|
||||
* belong to this document before any email fires. */
|
||||
notifyRecipients?: string[];
|
||||
/**
|
||||
* How to handle the upstream Documenso envelope. `'delete'` (the
|
||||
* default) fires `DELETE /api/v2/envelope/{id}` so the envelope is
|
||||
* removed from the Documenso instance - useful for keeping the
|
||||
* Documenso log clean when drafts get abandoned. `'keep_remote'`
|
||||
* leaves the envelope intact; the local CRM row still flips to
|
||||
* `status='cancelled'` and the cancelled-doc badge surfaces the
|
||||
* "Kept on Documenso" variant so audit-trail expectations are met.
|
||||
*/
|
||||
cancelMode?: 'delete' | 'keep_remote';
|
||||
}
|
||||
|
||||
export async function cancelDocument(
|
||||
@@ -2172,11 +2182,15 @@ export async function cancelDocument(
|
||||
throw new ConflictError(`Document is already ${existing.status}`);
|
||||
}
|
||||
|
||||
const cancelMode = options.cancelMode ?? 'delete';
|
||||
|
||||
// CRM is the system of record for cancellation status. A transient
|
||||
// Documenso failure shouldn't block the user from marking the doc cancelled
|
||||
// here - voidDocument already treats 404 as success, and the periodic
|
||||
// webhook receiver will reconcile if the remote void eventually lands.
|
||||
if (existing.documensoId) {
|
||||
// `cancelMode='keep_remote'` skips the upstream DELETE entirely so the
|
||||
// envelope stays available in Documenso for audit/forensics.
|
||||
if (existing.documensoId && cancelMode === 'delete') {
|
||||
try {
|
||||
await documensoVoid(existing.documensoId, portId);
|
||||
} catch (err) {
|
||||
@@ -2200,6 +2214,7 @@ export async function cancelDocument(
|
||||
initiatedBy: meta.userId,
|
||||
reason: options.reason ?? null,
|
||||
notifyCount: options.notifyRecipients?.length ?? 0,
|
||||
cancelMode,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2210,7 +2225,7 @@ export async function cancelDocument(
|
||||
entityType: 'document',
|
||||
entityId: documentId,
|
||||
oldValue: { status: existing.status },
|
||||
newValue: { status: 'cancelled', reason: options.reason ?? null },
|
||||
newValue: { status: 'cancelled', reason: options.reason ?? null, cancelMode },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
@@ -2220,7 +2235,7 @@ export async function cancelDocument(
|
||||
// Notify selected signers (rep-picked subset via the cancel-with-notify
|
||||
// modal). Pull the matching signer rows so we can render the recipient's
|
||||
// canonical name; skip silently when the rep passed no ids (Regenerate
|
||||
// flow). Failure to send is logged + non-fatal — the cancellation has
|
||||
// flow). Failure to send is logged + non-fatal - the cancellation has
|
||||
// already committed locally.
|
||||
const notifyIds = options.notifyRecipients ?? [];
|
||||
if (notifyIds.length > 0) {
|
||||
@@ -2586,7 +2601,7 @@ const INFLIGHT_STATUSES = ['draft', 'sent', 'partially_signed'] as const;
|
||||
|
||||
/**
|
||||
* Same projection shape as listFilesAggregatedByEntity but for in-flight
|
||||
* signing workflows. Completed/expired/cancelled workflows are hidden —
|
||||
* signing workflows. Completed/expired/cancelled workflows are hidden -
|
||||
* they surface via their signed-PDF file row.
|
||||
*/
|
||||
export async function listInflightWorkflowsAggregatedByEntity(
|
||||
@@ -2607,7 +2622,7 @@ export async function listInflightWorkflowsAggregatedByEntity(
|
||||
? documents.companyId
|
||||
: documents.yachtId;
|
||||
|
||||
// Batch the related-entity workflow lookups in parallel — the
|
||||
// Batch the related-entity workflow lookups in parallel - the
|
||||
// pre-2026-05-14 sequential loop fired ~50 queries on a busy client
|
||||
// (direct + each company + each yacht + each related client), each
|
||||
// round-trip blocking the next. Now every lookup runs concurrently
|
||||
|
||||
@@ -115,7 +115,7 @@ export async function toggleAccount(
|
||||
|
||||
// H-05: enable/disable used to land silently between connect/disconnect.
|
||||
// Audit-trail this so an admin can see the toggle history (silently
|
||||
// disabling an account suppresses bounce detection or reroutes replies —
|
||||
// disabling an account suppresses bounce detection or reroutes replies -
|
||||
// compliance-relevant change).
|
||||
if (audit) {
|
||||
void createAuditLog({
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function sendEmail(
|
||||
const creds = await getDecryptedCredentials(data.accountId);
|
||||
|
||||
// Build user-specific SMTP transporter. Same timeouts as the system
|
||||
// transporter in src/lib/email/index.ts — without these a hung SMTP
|
||||
// transporter in src/lib/email/index.ts - without these a hung SMTP
|
||||
// server holds the calling request for ~2min (Nodemailer's default
|
||||
// connectionTimeout) and starves the documents/email worker slot.
|
||||
const transporter = nodemailer.createTransport({
|
||||
@@ -115,7 +115,7 @@ export async function sendEmail(
|
||||
|
||||
// Resolve attachments for the user-path SMTP send. Cap concurrency so
|
||||
// a user attaching 20 large files doesn't fan out 20 simultaneous
|
||||
// S3/MinIO reads + 20 buffers in memory at once — bounded at 4 means
|
||||
// S3/MinIO reads + 20 buffers in memory at once - bounded at 4 means
|
||||
// peak memory tops out at ~4 × max-file-size irrespective of the
|
||||
// attachment count.
|
||||
const attachmentLimit = pLimit(4);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Per-category email send-from routing.
|
||||
*
|
||||
* Each outbound email category (account activation, EOI signing request,
|
||||
* brochure send, etc.) resolves to a `SenderAccount` — `noreply` for
|
||||
* brochure send, etc.) resolves to a `SenderAccount` - `noreply` for
|
||||
* automation-shaped traffic, `sales` for human-touch traffic. The
|
||||
* mapping is admin-configurable per port via the `email_routing`
|
||||
* system_setting (JSONB blob).
|
||||
|
||||
@@ -9,7 +9,7 @@ import { searchAuditLogs, type AuditSearchOptions } from '@/lib/services/audit-s
|
||||
* Shared loader for the per-entity Activity tab. Wraps `searchAuditLogs`
|
||||
* with actor-email resolution so each row can render `who did what`.
|
||||
*
|
||||
* Tenant gate happens at the API route — this service trusts the caller
|
||||
* Tenant gate happens at the API route - this service trusts the caller
|
||||
* to pass an entityId that belongs to `portId`.
|
||||
*/
|
||||
export async function loadEntityActivity(args: {
|
||||
@@ -43,14 +43,14 @@ export async function loadEntityActivity(args: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated activity for a client — includes audit logs for the
|
||||
* Aggregated activity for a client - includes audit logs for the
|
||||
* client itself + every interest belonging to that client. Used by
|
||||
* the Client overview's Activity tab so the rep sees the whole
|
||||
* timeline without clicking into each interest individually.
|
||||
*
|
||||
* Two queries (one per entityType) merged + sorted in JS rather than
|
||||
* a UNION because the auditLogs.entityType field would need to match
|
||||
* different values in the same SELECT — cleaner to keep the search
|
||||
* different values in the same SELECT - cleaner to keep the search
|
||||
* helper's per-entity-type semantics intact and merge here.
|
||||
*/
|
||||
export async function loadClientActivityAggregated(args: {
|
||||
|
||||
@@ -28,9 +28,9 @@ export type EoiContext = {
|
||||
* (e.g. 'NY' from 'US-NY'). Empty string when not set. */
|
||||
subdivision: string;
|
||||
postalCode: string;
|
||||
/** Localised country name — for the deprecated UI preview line only. */
|
||||
/** Localised country name - for the deprecated UI preview line only. */
|
||||
country: string;
|
||||
/** ISO-3166-1 alpha-2 country code — what the EOI Address field renders
|
||||
/** ISO-3166-1 alpha-2 country code - what the EOI Address field renders
|
||||
* (e.g. 'US'), not the long name. */
|
||||
countryIso: string;
|
||||
} | null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Phase 3b — EOI field-override side-effects + persistence.
|
||||
* Phase 3b - EOI field-override side-effects + persistence.
|
||||
*
|
||||
* The EOI dialog lets reps override pre-filled fields (email, phone,
|
||||
* yacht name) with one of three intents:
|
||||
@@ -15,7 +15,7 @@
|
||||
* primary inside the same transaction. `documents.override_*`
|
||||
* stays NULL because the canonical record now matches.
|
||||
*
|
||||
* 3. **Neither flag** (default — rep picked a secondary from the
|
||||
* 3. **Neither flag** (default - rep picked a secondary from the
|
||||
* combobox OR typed something fresh)
|
||||
* → if the value is fresh (no `contactId`), insert a non-primary
|
||||
* `client_contacts` row (`source='eoi-custom-input'`,
|
||||
@@ -28,7 +28,7 @@
|
||||
* canonical `yachts.name` column.
|
||||
*
|
||||
* The applied override values are returned so the caller can layer them
|
||||
* onto the in-memory EOI context before rendering — without a separate
|
||||
* onto the in-memory EOI context before rendering - without a separate
|
||||
* round-trip to re-read the freshly-mutated contact rows.
|
||||
*/
|
||||
|
||||
@@ -239,7 +239,7 @@ export async function applyEoiOverridesBeforeRender(
|
||||
if (!value) throw new ValidationError('yacht name override cannot be empty');
|
||||
if (!yacht) {
|
||||
// Yacht-name override without a linked yacht only makes sense
|
||||
// for the per-document path — otherwise there's no canonical
|
||||
// for the per-document path - otherwise there's no canonical
|
||||
// record to update.
|
||||
if (overrides.yachtName.setAsDefault) {
|
||||
throw new ValidationError('cannot setAsDefault for yacht name when no yacht is linked');
|
||||
@@ -269,7 +269,7 @@ export async function applyEoiOverridesBeforeRender(
|
||||
postalCode: (a.postalCode ?? '').trim(),
|
||||
countryIso: (a.countryIso ?? '').trim().toUpperCase(),
|
||||
};
|
||||
// Treat the address as one logical field — at least line1 + countryIso
|
||||
// Treat the address as one logical field - at least line1 + countryIso
|
||||
// must be present for an EOI to render legally.
|
||||
if (!resolvedAddr.line1 || !resolvedAddr.countryIso) {
|
||||
throw new ValidationError('address override requires line1 and countryIso');
|
||||
@@ -296,7 +296,7 @@ export async function applyEoiOverridesBeforeRender(
|
||||
await tx
|
||||
.update(clientAddresses)
|
||||
.set({
|
||||
// client_addresses has no addressLine2 column — concat line1+line2.
|
||||
// client_addresses has no addressLine2 column - concat line1+line2.
|
||||
streetAddress: resolvedAddr.line2
|
||||
? `${resolvedAddr.line1}\n${resolvedAddr.line2}`
|
||||
: resolvedAddr.line1,
|
||||
@@ -314,7 +314,7 @@ export async function applyEoiOverridesBeforeRender(
|
||||
await tx.insert(clientAddresses).values({
|
||||
clientId: client.id,
|
||||
portId: client.portId,
|
||||
// client_addresses has no addressLine2 column — concat line1+line2.
|
||||
// client_addresses has no addressLine2 column - concat line1+line2.
|
||||
streetAddress: resolvedAddr.line2
|
||||
? `${resolvedAddr.line1}\n${resolvedAddr.line2}`
|
||||
: resolvedAddr.line1,
|
||||
@@ -423,7 +423,7 @@ export async function applyEoiOverridesBeforeRender(
|
||||
*
|
||||
* `source_document_id` on any client_contacts rows inserted by the
|
||||
* preceding `applyEoiOverridesBeforeRender` call is left NULL until
|
||||
* this point — the document id doesn't exist yet during the contact
|
||||
* this point - the document id doesn't exist yet during the contact
|
||||
* insert. This function backfills it.
|
||||
*/
|
||||
export async function persistDocumentOverrides(
|
||||
@@ -467,7 +467,7 @@ export async function persistDocumentOverrides(
|
||||
sql`${clientAddresses.sourceDocumentId} IS NULL`,
|
||||
),
|
||||
);
|
||||
// Phase 3 follow-up — yacht spawn from EOI runs BEFORE generateAndSign
|
||||
// Phase 3 follow-up - yacht spawn from EOI runs BEFORE generateAndSign
|
||||
// so the yacht row's source_document_id is NULL at insert time. Same
|
||||
// bounded backfill pattern as contacts.
|
||||
await db
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* an unhandled (5xx) error fires inside a route handler. It pulls the
|
||||
* request context from AsyncLocalStorage, sanitizes the payload, and
|
||||
* inserts one row into `error_events`. Failure to write must NEVER
|
||||
* throw — the caller is already in the error path.
|
||||
* throw - the caller is already in the error path.
|
||||
*
|
||||
* `listErrorEvents` / `getErrorEventById` back the super-admin inspector.
|
||||
*/
|
||||
@@ -127,14 +127,14 @@ interface CaptureArgs {
|
||||
|
||||
/**
|
||||
* Persist an error_events row tied to the active request context.
|
||||
* Best-effort — silently swallows any DB failure (the caller is
|
||||
* Best-effort - silently swallows any DB failure (the caller is
|
||||
* already returning the user an error response; we do NOT want to
|
||||
* mask the original error with a logging-pipeline failure).
|
||||
*/
|
||||
export async function captureErrorEvent(args: CaptureArgs): Promise<void> {
|
||||
const ctx = getRequestContext();
|
||||
if (!ctx) {
|
||||
// Outside a request context (e.g. queue worker). Log but skip — the
|
||||
// Outside a request context (e.g. queue worker). Log but skip - the
|
||||
// queue has its own failure-capture in BullMQ.
|
||||
return;
|
||||
}
|
||||
@@ -146,7 +146,7 @@ export async function captureErrorEvent(args: CaptureArgs): Promise<void> {
|
||||
const durationMs = Date.now() - ctx.startedAt;
|
||||
|
||||
// Pull through any well-known fields the upstream library decorated
|
||||
// onto the error — Postgres driver uses `code` (SQLSTATE) and
|
||||
// onto the error - Postgres driver uses `code` (SQLSTATE) and
|
||||
// `severity`, fetch errors carry `cause.code`, etc. The classifier
|
||||
// reads from `metadata.code` to drive the "likely culprit" badge.
|
||||
const enriched: Record<string, unknown> = { ...(args.metadata ?? {}) };
|
||||
@@ -177,7 +177,7 @@ export async function captureErrorEvent(args: CaptureArgs): Promise<void> {
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
} catch (writeErr) {
|
||||
// Logged but never thrown — the caller is in the error path already.
|
||||
// Logged but never thrown - the caller is in the error path already.
|
||||
logger.error({ err: writeErr }, 'Failed to persist error_events row');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,23 +3,23 @@
|
||||
*
|
||||
* Replaces the legacy `client-portal/server/api/expenses/generate-pdf.ts`
|
||||
* (1009 lines, pdfkit + full-buffer-everything + base64-wrapped JSON
|
||||
* response — would OOM on hundreds of receipts).
|
||||
* response - would OOM on hundreds of receipts).
|
||||
*
|
||||
* Design constraints (per user requirement: "could be hundreds of
|
||||
* expenses and images, also compress files if they're stupidly large"):
|
||||
*
|
||||
* 1. **Stream the PDF output** — pdfkit.pipe(response) instead of
|
||||
* 1. **Stream the PDF output** - pdfkit.pipe(response) instead of
|
||||
* accumulating chunks. Bytes leave the process as they're written.
|
||||
* 2. **Serial receipt processing** — fetch one receipt at a time, embed,
|
||||
* 2. **Serial receipt processing** - fetch one receipt at a time, embed,
|
||||
* release. Peak heap = ~one image + the in-flight pdfkit page.
|
||||
* 3. **Sharp resize before embedding** — receipts above the size/dim
|
||||
* 3. **Sharp resize before embedding** - receipts above the size/dim
|
||||
* thresholds get downscaled to ≤1500px on the long edge at JPEG q80.
|
||||
* A typical 8 MB phone photo collapses to ~250 KB; the embedded PDF
|
||||
* ends up ~5–10x smaller than the legacy output.
|
||||
* 4. **Storage backend abstraction** — receipts come from
|
||||
* 4. **Storage backend abstraction** - receipts come from
|
||||
* `getStorageBackend().get(storageKey)`; works against MinIO/S3 in
|
||||
* production and the local filesystem in dev.
|
||||
* 5. **Heap budget** — for a 500-receipt export (avg 8 MB raw → 250 KB
|
||||
* 5. **Heap budget** - for a 500-receipt export (avg 8 MB raw → 250 KB
|
||||
* resized + a few MB pdfkit working set), peak RSS stays under 100 MB.
|
||||
* The legacy implementation needed >2 GB for the same input.
|
||||
*
|
||||
@@ -176,7 +176,7 @@ interface ExpenseRow {
|
||||
* the table + a footnote at the bottom of the summary box. */
|
||||
noReceiptAcknowledged: boolean;
|
||||
paymentStatus: string | null;
|
||||
/** Free-text trip / event label — drives `groupBy=trip` sectioning. */
|
||||
/** Free-text trip / event label - drives `groupBy=trip` sectioning. */
|
||||
tripLabel: string | null;
|
||||
}
|
||||
|
||||
@@ -198,14 +198,14 @@ interface Totals {
|
||||
processingFee: number;
|
||||
finalTotal: number;
|
||||
targetCurrency: TargetCurrency;
|
||||
/** Number of expenses with `noReceiptAcknowledged=true` — surfaces as a
|
||||
/** Number of expenses with `noReceiptAcknowledged=true` - surfaces as a
|
||||
* warning footer in the summary box. Reps need to know this count
|
||||
* before forwarding the export to a parent-company reimbursement queue. */
|
||||
noReceiptCount: number;
|
||||
/** Sum of the no-receipt expenses' targetTotal — the amount at risk
|
||||
/** Sum of the no-receipt expenses' targetTotal - the amount at risk
|
||||
* of being denied reimbursement. */
|
||||
noReceiptAmount: number;
|
||||
/** Number of rows whose conversion fell back to 1:1 — surfaces as an
|
||||
/** Number of rows whose conversion fell back to 1:1 - surfaces as an
|
||||
* amber footer so reps know the totals are approximate. Audit caught
|
||||
* the silent 1:1 fallback; users were getting EUR-labelled USD totals. */
|
||||
rateUnavailableCount: number;
|
||||
@@ -248,7 +248,7 @@ async function processExpenses(
|
||||
if (!ok) rateUnavailable = true;
|
||||
}
|
||||
|
||||
// Skip the USD->EUR chain when the source already matches the target —
|
||||
// Skip the USD->EUR chain when the source already matches the target -
|
||||
// every redundant rate lookup adds rounding noise on top of the network
|
||||
// round-trip. EUR-source + EUR-target should land back exactly at the
|
||||
// input amount, not raw * USD-rate * USD-rate-inverse.
|
||||
@@ -354,7 +354,7 @@ async function fetchExpenseRows(args: ExpensePdfArgs): Promise<ExpenseRow[]> {
|
||||
// Soft-delete filter applies regardless of which path produced the
|
||||
// expense list. The audit caught a regression where an `expenseIds`
|
||||
// selection would happily export archived rows because the
|
||||
// `isNull(archivedAt)` predicate sat inside the `else` branch — that
|
||||
// `isNull(archivedAt)` predicate sat inside the `else` branch - that
|
||||
// violates the soft-delete contract used everywhere else.
|
||||
if (!args.filter?.includeArchived) {
|
||||
conditions.push(isNull(expenses.archivedAt));
|
||||
@@ -490,7 +490,7 @@ export async function streamExpensePdf(
|
||||
const processed = await processExpenses(rawRows, opts.targetCurrency);
|
||||
const totals = computeTotals(processed, opts.targetCurrency, opts.includeProcessingFee);
|
||||
|
||||
// Brand assets for the header band — shared with the react-pdf surfaces.
|
||||
// Brand assets for the header band - shared with the react-pdf surfaces.
|
||||
const [port, logo] = await Promise.all([
|
||||
db.query.ports.findFirst({ where: eq(ports.id, args.portId) }),
|
||||
resolvePortLogo(args.portId),
|
||||
@@ -543,7 +543,7 @@ export async function streamExpensePdf(
|
||||
|
||||
// `\s` includes CR/LF; using it lets a malicious documentName forge
|
||||
// additional response headers via Content-Disposition. Restrict to
|
||||
// word/dot/dash/space (single-line space only — \s would let \n through).
|
||||
// word/dot/dash/space (single-line space only - \s would let \n through).
|
||||
const safeName = opts.documentName.replace(/[^\w. \-]+/g, '_').trim() || 'expenses';
|
||||
return {
|
||||
stream: webStream,
|
||||
@@ -562,7 +562,7 @@ function addHeader(
|
||||
logoBuffer?: Buffer | null;
|
||||
},
|
||||
) {
|
||||
// Brand band — shared visual language with the react-pdf surfaces.
|
||||
// Brand band - shared visual language with the react-pdf surfaces.
|
||||
// Dark slate matches PDF_TOKENS.colors.headerBand.
|
||||
const pageWidth = doc.page.width;
|
||||
const bandHeight = 56;
|
||||
@@ -804,7 +804,7 @@ function addExpenseTable(
|
||||
.fontSize(9)
|
||||
.font('Helvetica-Bold')
|
||||
.text(
|
||||
`${group.key} (${group.rows.length} expense${group.rows.length === 1 ? '' : 's'} — ${formatCurrency(groupTotal, opts.targetCurrency)})`,
|
||||
`${group.key} (${group.rows.length} expense${group.rows.length === 1 ? '' : 's'} - ${formatCurrency(groupTotal, opts.targetCurrency)})`,
|
||||
65,
|
||||
doc.y + 5,
|
||||
{ width: doc.page.width - 130 },
|
||||
@@ -845,7 +845,7 @@ async function addReceiptPages(
|
||||
// Bail out the moment the client disconnects. Without this, an
|
||||
// export aborted on the wire would keep grinding through the
|
||||
// remaining receipts and only stop when the doc.end() write
|
||||
// failed — minutes later for a 1000-row export.
|
||||
// failed - minutes later for a 1000-row export.
|
||||
if (opts.signal?.aborted) {
|
||||
logger.info(
|
||||
{ receiptCounter, totalReceipts },
|
||||
@@ -956,7 +956,7 @@ function renderReceiptHeader(
|
||||
) {
|
||||
const margin = 60;
|
||||
const headerH = 90;
|
||||
// Capture the header's top edge BEFORE drawing — every subsequent text
|
||||
// Capture the header's top edge BEFORE drawing - every subsequent text
|
||||
// call below uses pdfkit's auto-flow which advances `doc.y`. Using
|
||||
// `doc.y - headerH + 10` after the rect+stroke block computes against
|
||||
// the post-rect position and only happens to work because pdfkit's
|
||||
@@ -979,7 +979,7 @@ function renderReceiptHeader(
|
||||
.fontSize(11)
|
||||
.font('Helvetica-Bold')
|
||||
.text(
|
||||
`${expense.establishmentName ?? '—'} ${formatCurrency(expense.amountTarget, currency)}`,
|
||||
`${expense.establishmentName ?? '-'} ${formatCurrency(expense.amountTarget, currency)}`,
|
||||
margin + 10,
|
||||
baseY + 36,
|
||||
);
|
||||
@@ -988,7 +988,7 @@ function renderReceiptHeader(
|
||||
.font('Helvetica')
|
||||
.fillColor('#666666')
|
||||
.text(
|
||||
`Date: ${expense.expenseDate.toISOString().slice(0, 10)} · Payer: ${expense.payer ?? '—'} · Category: ${expense.category ?? '—'} · File: ${file.filename}`,
|
||||
`Date: ${expense.expenseDate.toISOString().slice(0, 10)} · Payer: ${expense.payer ?? '-'} · Category: ${expense.category ?? '-'} · File: ${file.filename}`,
|
||||
margin + 10,
|
||||
baseY + 56,
|
||||
{ width: doc.page.width - margin * 2 - 20 },
|
||||
@@ -1014,7 +1014,7 @@ function addReceiptErrorPage(
|
||||
.fontSize(11)
|
||||
.font('Helvetica')
|
||||
.text(
|
||||
`${expense.establishmentName ?? '—'} ${formatCurrency(expense.amountTarget, currency)}`,
|
||||
`${expense.establishmentName ?? '-'} ${formatCurrency(expense.amountTarget, currency)}`,
|
||||
{ align: 'center' },
|
||||
);
|
||||
doc.moveDown(2);
|
||||
|
||||
@@ -34,7 +34,7 @@ function normalizeTripLabel(input: string | null | undefined): string | null {
|
||||
* Distinct trip labels used in this port, ordered by most-recent first
|
||||
* so the autocomplete surfaces "what reps actually used lately" rather
|
||||
* than alphabetically. Powers the `tripLabel` <Combobox> on the expense
|
||||
* form. Read-only — no mutation hooks.
|
||||
* form. Read-only - no mutation hooks.
|
||||
*/
|
||||
export async function listTripLabels(portId: string, search?: string): Promise<string[]> {
|
||||
const conditions = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* External EOI upload — for EOIs signed outside Documenso (paper signing,
|
||||
* External EOI upload - for EOIs signed outside Documenso (paper signing,
|
||||
* different e-sign vendor, signed in person, etc).
|
||||
*
|
||||
* Creates BOTH the document row AND the signed-file record in one shot,
|
||||
@@ -40,7 +40,7 @@ export interface ExternalEoiInput {
|
||||
/** When the signing actually happened (the date on the paper / contract). */
|
||||
signedAt?: Date;
|
||||
/**
|
||||
* Structured signatory list — preferred over the legacy `signerNames`
|
||||
* Structured signatory list - preferred over the legacy `signerNames`
|
||||
* CSV. When present, the service inserts one `document_signers` row
|
||||
* per non-CC entry pre-stamped `status='signed'` so the
|
||||
* "X / Y signed" badge renders correctly downstream.
|
||||
@@ -82,7 +82,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
||||
// Upload to storage FIRST so we have a stable key for the DB rows,
|
||||
// then commit all four DB writes in one transaction. If the tx fails
|
||||
// the storage object becomes orphaned (S3 isn't transactional) but
|
||||
// the DB stays clean — orphan reaper handles those.
|
||||
// the DB stays clean - orphan reaper handles those.
|
||||
await (
|
||||
await getStorageBackend()
|
||||
).put(storagePath, fileData.buffer, {
|
||||
@@ -91,7 +91,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
||||
});
|
||||
|
||||
const title =
|
||||
input.title ?? `External EOI — ${(input.signedAt ?? new Date()).toISOString().slice(0, 10)}`;
|
||||
input.title ?? `External EOI - ${(input.signedAt ?? new Date()).toISOString().slice(0, 10)}`;
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [fileRecord] = await tx
|
||||
@@ -140,7 +140,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
||||
|
||||
// Backfill document_signers rows for the structured signatory list
|
||||
// so the document-detail "X / Y signed" badge counts correctly. CC
|
||||
// recipients aren't signatories — they're recipients of the email
|
||||
// recipients aren't signatories - they're recipients of the email
|
||||
// copy and don't show up in the signed-count denominator.
|
||||
const signedAtMoment = input.signedAt ?? new Date();
|
||||
const signerRows = (input.signatories ?? [])
|
||||
@@ -172,15 +172,15 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
||||
});
|
||||
|
||||
// Two concerns to keep separate:
|
||||
// 1. Document metadata — always write `dateEoiSigned` + `eoiStatus`
|
||||
// 1. Document metadata - always write `dateEoiSigned` + `eoiStatus`
|
||||
// from the upload. Even if the rep already advanced the stage
|
||||
// manually, the paper signing event needs a recorded date so
|
||||
// downstream surfaces (SkipAheadBanner, milestone strip, EOI
|
||||
// merge fields) reflect reality. Honour an existing
|
||||
// dateEoiSigned (don't overwrite if already set — covers the
|
||||
// dateEoiSigned (don't overwrite if already set - covers the
|
||||
// case where the rep is uploading evidence for an event whose
|
||||
// date was already backfilled).
|
||||
// 2. Stage advance — only when the deal hasn't reached eoi_signed
|
||||
// 2. Stage advance - only when the deal hasn't reached eoi_signed
|
||||
// yet. Bypasses canTransitionStage because the operator just
|
||||
// brought concrete proof.
|
||||
const shouldAdvanceStage =
|
||||
@@ -238,7 +238,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
||||
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
|
||||
await evaluateRule('eoi_signed', interestId, portId, meta);
|
||||
} catch {
|
||||
// Swallow — rules engine failures should never block the upload
|
||||
// Swallow - rules engine failures should never block the upload
|
||||
// that the rep has already completed end-to-end. The orphan-reaper
|
||||
// path doesn't apply; a missed rule evaluation is a soft failure.
|
||||
}
|
||||
@@ -280,7 +280,7 @@ export interface ExternalEoiMetadataPatch {
|
||||
/**
|
||||
* Update title / notes / signed-date / signatories on a previously-uploaded
|
||||
* external EOI. Refuses to touch Documenso-managed documents because their
|
||||
* signer rows are the vendor's source of truth — any edit would drift from
|
||||
* signer rows are the vendor's source of truth - any edit would drift from
|
||||
* the upstream envelope.
|
||||
*
|
||||
* Mirrors the upload service's invariants:
|
||||
@@ -290,7 +290,7 @@ export interface ExternalEoiMetadataPatch {
|
||||
* present (insert / update / delete by id-presence). Same shape as the
|
||||
* upload-time insert: CC entries persisted but not counted as signers.
|
||||
*
|
||||
* Stage advance is NOT re-evaluated — that fires once at upload and shouldn't
|
||||
* Stage advance is NOT re-evaluated - that fires once at upload and shouldn't
|
||||
* be reversed by a metadata edit. If the rep needs to roll a stage back,
|
||||
* they do it through the stage-change UI directly.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* "Mark as signed externally" service — records that a contract /
|
||||
* "Mark as signed externally" service - records that a contract /
|
||||
* reservation / EOI was signed outside the CRM without requiring the
|
||||
* rep to upload a file. The interest's doc-status flips to 'signed'
|
||||
* and an audit-log entry captures the optional reason ("paper signed
|
||||
@@ -11,7 +11,7 @@
|
||||
* duplicate-store it just to flip the CRM forward.
|
||||
*
|
||||
* The UI surfaces a warning banner on the relevant tab: "No file on
|
||||
* record — signed externally". Reps can later upload the file via
|
||||
* record - signed externally". Reps can later upload the file via
|
||||
* the existing external-upload dialog if a copy turns up.
|
||||
*/
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function uploadFile(
|
||||
throw new ValidationError('File exceeds maximum size of 50MB');
|
||||
}
|
||||
|
||||
// Magic-byte verification — without this, the browser-declared MIME is
|
||||
// Magic-byte verification - without this, the browser-declared MIME is
|
||||
// attacker-controlled and a malicious uploader could ship arbitrary
|
||||
// bytes through the ALLOWED_MIME_TYPES allow-list (auditor-E3 §27).
|
||||
// Berth-PDF and brochure paths already do this; the generic uploader
|
||||
@@ -97,7 +97,7 @@ export async function uploadFile(
|
||||
// Derive the entity FK from (entityType, entityId) when the caller
|
||||
// didn't pass it explicitly. Without this, an interest-tab upload that
|
||||
// sets `entityType='client'` + `entityId=<UUID>` lands with
|
||||
// `client_id=NULL` — the Attachments list filters on `clientId` and
|
||||
// `client_id=NULL` - the Attachments list filters on `clientId` and
|
||||
// the file vanishes from the interest's Documents tab.
|
||||
const derivedClientId =
|
||||
data.clientId ?? (data.entityType === 'client' ? (data.entityId ?? null) : null);
|
||||
@@ -321,7 +321,7 @@ export async function getFileById(id: string, portId: string) {
|
||||
|
||||
/**
|
||||
* Row shape returned by the aggregated projection. Note this intentionally
|
||||
* omits `storagePath` and `storageBucket` — those are internal storage
|
||||
* omits `storagePath` and `storageBucket` - those are internal storage
|
||||
* implementation details and must not leak out of the API to rep clients.
|
||||
* Callers that need to download a file must use the documents/file
|
||||
* download endpoint, which presigns from the bucket using the id, not the
|
||||
@@ -587,7 +587,7 @@ async function fetchGroupRows(
|
||||
originalName: files.originalName,
|
||||
mimeType: files.mimeType,
|
||||
sizeBytes: files.sizeBytes,
|
||||
// storagePath + storageBucket intentionally omitted — see AggregatedFileRow doc.
|
||||
// storagePath + storageBucket intentionally omitted - see AggregatedFileRow doc.
|
||||
category: files.category,
|
||||
uploadedBy: files.uploadedBy,
|
||||
createdAt: files.createdAt,
|
||||
|
||||
@@ -212,7 +212,7 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
|
||||
messages: messagesByThread.get(t.id) ?? [],
|
||||
}));
|
||||
|
||||
// Interest contact-log has no clientId — fetch via the client's interests.
|
||||
// Interest contact-log has no clientId - fetch via the client's interests.
|
||||
const interestIds = interestRows.map((i) => i.id);
|
||||
const contactLogRows = interestIds.length
|
||||
? await db.query.interestContactLog.findMany({
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function requestGdprExport(input: RequestExportInput): Promise<Requ
|
||||
userAgent: input.userAgent,
|
||||
});
|
||||
|
||||
// Stable jobId: exportId is unique per request — dedup is guaranteed
|
||||
// Stable jobId: exportId is unique per request - dedup is guaranteed
|
||||
// because a second enqueue with the same exportId would either be
|
||||
// rejected (in-flight) or no-op (completed). concurrency-auditor C-2.
|
||||
await getQueue('export').add(
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
* - **Cap dimensions** at MAX_DIMENSION so a 30000×30000 palette PNG
|
||||
* can't decompression-bomb a sharp decode further downstream. C2.
|
||||
* - **Re-encode via sharp** so polyglot trailing bytes (PDF+JPEG
|
||||
* sandwiches, HTML+PNG) are dropped — the output buffer is a clean
|
||||
* sandwiches, HTML+PNG) are dropped - the output buffer is a clean
|
||||
* single-format file regardless of input trickery. H1.
|
||||
* - **Freeze animated GIFs** to first frame so a 5000-frame phishing
|
||||
* GIF can't pin a worker on every list-view render. H3.
|
||||
*
|
||||
* Falls through to the original buffer (with a warning at the call
|
||||
* site) when sharp isn't available or the input isn't a recognised
|
||||
* image. The MIME type stays the same as declared — magic-byte
|
||||
* image. The MIME type stays the same as declared - magic-byte
|
||||
* verification has already run upstream.
|
||||
*/
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function sendInquiryNotifications(params: InquiryNotificationParams
|
||||
|
||||
// 2. Notify CRM users with interests.view permission on this port.
|
||||
// The previous implementation `await`ed createNotification per user,
|
||||
// burning ≥3 DB round trips + 2 socket emits per call serially — a
|
||||
// burning ≥3 DB round trips + 2 socket emits per call serially - a
|
||||
// port with 20 users meant ~80 round trips before this public POST
|
||||
// could even respond. Promise.all parallelises the DB writes; the
|
||||
// socket emit fan-out is the only thing that still scales linearly,
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface PrimaryBerthRef {
|
||||
/**
|
||||
* The primary berth for an interest, if any. Resolves the row marked
|
||||
* `is_primary=true`; falls back to the most recently added berth row
|
||||
* when no row is flagged primary (defensive — the unique partial index
|
||||
* when no row is flagged primary (defensive - the unique partial index
|
||||
* guarantees ≤1 primary, but reads should never throw on data drift).
|
||||
*/
|
||||
export async function getPrimaryBerth(interestId: string): Promise<PrimaryBerthRef | null> {
|
||||
@@ -224,7 +224,7 @@ export async function upsertInterestBerthTx(
|
||||
opts: AddOrUpdateOpts = {},
|
||||
): Promise<InterestBerth> {
|
||||
// Cross-port guard. The junction is silently multi-port-shaped (it has
|
||||
// no port_id of its own — it inherits via the FKs) so a caller wiring
|
||||
// no port_id of its own - it inherits via the FKs) so a caller wiring
|
||||
// an interest from one port to a berth from another would corrupt the
|
||||
// recommender + public-berth aggregates with phantom rows. We assert
|
||||
// both rows live in the same port BEFORE inserting; if either side is
|
||||
@@ -258,7 +258,7 @@ export async function upsertInterestBerthTx(
|
||||
if (opts.isInEoiBundle !== undefined) setForUpdate.isInEoiBundle = opts.isInEoiBundle;
|
||||
if (opts.addedBy !== undefined) setForUpdate.addedBy = opts.addedBy;
|
||||
if (opts.notes !== undefined) setForUpdate.notes = opts.notes;
|
||||
// Bypass fields move as a unit — either we set all three to record a bypass
|
||||
// Bypass fields move as a unit - either we set all three to record a bypass
|
||||
// or clear all three. Touching the reason field decides which.
|
||||
if (opts.eoiBypassReason !== undefined) {
|
||||
if (opts.eoiBypassReason && opts.eoiBypassReason.trim().length > 0) {
|
||||
@@ -305,7 +305,7 @@ export async function upsertInterestBerthTx(
|
||||
|
||||
// Auto-promote leadCategory: linking a specific berth means the interest
|
||||
// is now anchored to a real piece of inventory, which is the definition
|
||||
// of `specific_qualified`. Only bumps `general_interest` (or null) —
|
||||
// of `specific_qualified`. Only bumps `general_interest` (or null) -
|
||||
// never demotes `hot_lead` or anything else already past qualified.
|
||||
const isSpecific = row?.isSpecificInterest ?? opts.isSpecificInterest ?? true;
|
||||
if (isSpecific) {
|
||||
@@ -331,7 +331,7 @@ export async function setPrimaryBerth(interestId: string, berthId: string): Prom
|
||||
|
||||
/** Remove a berth from an interest.
|
||||
*
|
||||
* `portId` is required for cross-port defense — `upsertInterestBerth`
|
||||
* `portId` is required for cross-port defense - `upsertInterestBerth`
|
||||
* and `setPrimaryBerth` both verify the interest + berth share the
|
||||
* caller's port before mutation, but the original `removeInterestBerth`
|
||||
* issued a delete keyed only by (interestId, berthId), so a future
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Interest contact-log service — CRUD over `interest_contact_log` plus
|
||||
* Interest contact-log service - CRUD over `interest_contact_log` plus
|
||||
* the side-effects that make logging an interaction useful:
|
||||
*
|
||||
* 1. Bump `interests.dateLastContact` to the entry's `occurredAt` so
|
||||
@@ -130,7 +130,7 @@ export async function create(
|
||||
const [entry] = await tx.insert(interestContactLog).values(insertValues).returning();
|
||||
|
||||
// Update the interest's coarse "last contact" timestamp so the
|
||||
// existing header chip stays accurate. Only bump forward — if the
|
||||
// existing header chip stays accurate. Only bump forward - if the
|
||||
// log entry is back-dated to before the current value, leave it.
|
||||
await tx
|
||||
.update(interests)
|
||||
@@ -195,7 +195,7 @@ export async function update(
|
||||
.returning({ id: reminders.id });
|
||||
reminderId = rem!.id;
|
||||
} else if (!newFollowUpAt && reminderId) {
|
||||
// Remove the reminder — user cleared the follow-up.
|
||||
// Remove the reminder - user cleared the follow-up.
|
||||
await tx.delete(reminders).where(eq(reminders.id, reminderId));
|
||||
reminderId = null;
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ export async function calculateBulkScores(
|
||||
|
||||
// Four grouped aggregates against the port's interest set. Each is a
|
||||
// single index-friendly scan on `interest_id` (or `client_id` for the
|
||||
// email-threads case) — no per-row round trips.
|
||||
// email-threads case) - no per-row round trips.
|
||||
const [notesGrouped, remindersGrouped, emailsGrouped, berthLinksGrouped] = await Promise.all([
|
||||
db
|
||||
.select({ interestId: interestNotes.interestId, value: count() })
|
||||
@@ -354,7 +354,7 @@ export async function calculateBulkScores(
|
||||
|
||||
// Refresh the redis cache for each interest in a single pipeline so
|
||||
// single-interest reads downstream short-circuit the per-row queries.
|
||||
// Fire-and-forget — bulk scoring's correctness doesn't depend on the
|
||||
// Fire-and-forget - bulk scoring's correctness doesn't depend on the
|
||||
// cache write succeeding.
|
||||
redis
|
||||
.pipeline(
|
||||
|
||||
@@ -150,8 +150,8 @@ async function resolveLeadCategory(
|
||||
|
||||
/**
|
||||
* Soft cap on board rows. The kanban legitimately needs every active
|
||||
* interest in one shot — paginating would split deals across pages and
|
||||
* break drag-drop semantics — but unbounded SELECTs are a footgun if a
|
||||
* interest in one shot - paginating would split deals across pages and
|
||||
* break drag-drop semantics - but unbounded SELECTs are a footgun if a
|
||||
* port suddenly has tens of thousands of stale interests. At 5000 the
|
||||
* payload is still well under a megabyte (≈50 bytes per minimal row),
|
||||
* and any port near that ceiling needs virtualization in the kanban UI
|
||||
@@ -181,12 +181,12 @@ export interface BoardFilters {
|
||||
/**
|
||||
* Minimal-projection list for the kanban board. Skips the validator's
|
||||
* `max(100)` page cap since the board renders the entire pipeline at
|
||||
* once. Returns only the fields PipelineCard renders — no tags-list, no
|
||||
* once. Returns only the fields PipelineCard renders - no tags-list, no
|
||||
* notes-count, no EOI status badges, no urgency joins. Always filters
|
||||
* out archived interests (the kanban is for active deals; the list view
|
||||
* has the includeArchived toggle for history).
|
||||
*
|
||||
* Filters are intentionally a SUBSET of listInterests — `pipelineStage`
|
||||
* Filters are intentionally a SUBSET of listInterests - `pipelineStage`
|
||||
* is omitted because the columns ARE the stages, and `includeArchived`
|
||||
* is omitted because the kanban shouldn't surface archived deals.
|
||||
*
|
||||
@@ -198,7 +198,7 @@ export async function listInterestsForBoard(
|
||||
portId: string,
|
||||
filters: BoardFilters = {},
|
||||
): Promise<{ data: BoardInterestRow[]; truncated: boolean; total: number }> {
|
||||
// Kanban shows only active deals — terminal (outcome-set) rows have
|
||||
// Kanban shows only active deals - terminal (outcome-set) rows have
|
||||
// their own /closed views. Pre-2026-05-14 this filter was just
|
||||
// `archivedAt IS NULL`, which worked because setOutcome moved the
|
||||
// stage to the 'completed' sentinel and the kanban only renders the
|
||||
@@ -222,7 +222,7 @@ export async function listInterestsForBoard(
|
||||
|
||||
// Tag-id filter resolves through the join table first so the main
|
||||
// query stays a simple WHERE id IN (…) rather than a SELECT DISTINCT
|
||||
// with LEFT JOIN — keeps Postgres' planner happy at scale.
|
||||
// with LEFT JOIN - keeps Postgres' planner happy at scale.
|
||||
if (filters.tagIds && filters.tagIds.length > 0) {
|
||||
const tagMatches = await db
|
||||
.selectDistinct({ interestId: interestTags.interestId })
|
||||
@@ -235,7 +235,7 @@ export async function listInterestsForBoard(
|
||||
conditions.push(inArray(interests.id, matchingIds));
|
||||
}
|
||||
|
||||
// Search hits client name via the LEFT JOIN. ILIKE is correct here —
|
||||
// Search hits client name via the LEFT JOIN. ILIKE is correct here -
|
||||
// the kanban list is small (≤5000 rows) so an index scan isn't
|
||||
// required, and pg_trgm would be overkill for the board surface.
|
||||
if (filters.search && filters.search.trim().length > 0) {
|
||||
@@ -520,7 +520,7 @@ export async function getInterestById(id: string, portId: string) {
|
||||
const berthMooringNumber = primaryBerth?.mooringNumber ?? null;
|
||||
|
||||
// Total linked-berth count powers the "Berth Interest" milestone on
|
||||
// the OverviewTab — first thing the rep needs to capture, especially
|
||||
// the OverviewTab - first thing the rep needs to capture, especially
|
||||
// for general_interest leads. Resolved here (not from the join above)
|
||||
// so the count includes berths the rep added without marking primary.
|
||||
const [{ count: linkedBerthCount } = { count: 0 }] = await db
|
||||
@@ -566,7 +566,7 @@ export async function getInterestById(id: string, portId: string) {
|
||||
.from(reminders)
|
||||
.where(and(eq(reminders.interestId, id), inArray(reminders.status, ['pending', 'snoozed'])));
|
||||
|
||||
// Activity log entries in the last 7 days — surfaces "rep is engaged"
|
||||
// Activity log entries in the last 7 days - surfaces "rep is engaged"
|
||||
// as a separate signal in the deal-health pulse beyond the coarse
|
||||
// dateLastContact bump.
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 86_400_000);
|
||||
@@ -577,7 +577,7 @@ export async function getInterestById(id: string, portId: string) {
|
||||
and(eq(interestContactLog.interestId, id), gte(interestContactLog.occurredAt, sevenDaysAgo)),
|
||||
);
|
||||
|
||||
// Phase 2 — risk-signal derivation. Three dates feed `computeDealHealth`
|
||||
// Phase 2 - risk-signal derivation. Three dates feed `computeDealHealth`
|
||||
// off the existing event tables so the pulse chip surfaces document
|
||||
// declines / cancelled reservations / berth-resold-to-other without
|
||||
// adding bespoke timestamp columns on `interests`. Each query runs in
|
||||
@@ -606,7 +606,7 @@ export async function getInterestById(id: string, portId: string) {
|
||||
.where(and(eq(berthReservations.interestId, id), eq(berthReservations.status, 'cancelled')))
|
||||
.orderBy(desc(berthReservations.updatedAt))
|
||||
.limit(1),
|
||||
// "Berth sold to another deal" — any of this interest's linked berths
|
||||
// "Berth sold to another deal" - any of this interest's linked berths
|
||||
// has at least one OTHER interest with a `won` outcome. Take the
|
||||
// latest such outcome timestamp. archivedAt is a close proxy for the
|
||||
// moment the win was finalised on the conflicting deal.
|
||||
@@ -630,7 +630,7 @@ export async function getInterestById(id: string, portId: string) {
|
||||
const dateDocumentDeclined = declinedRow[0]?.at ?? null;
|
||||
const dateReservationCancelled = cancelledReservationRow[0]?.at ?? null;
|
||||
// db.execute(sql`...`) returns either an array (postgres-js driver) or
|
||||
// a `{rows: []}` object depending on driver build — match the dual
|
||||
// a `{rows: []}` object depending on driver build - match the dual
|
||||
// shape used by src/lib/storage/migrate.ts.
|
||||
const berthResoldRaw = berthResoldRow as unknown as
|
||||
| Array<{ at: Date | null }>
|
||||
@@ -640,7 +640,7 @@ export async function getInterestById(id: string, portId: string) {
|
||||
: (berthResoldRaw.rows ?? []);
|
||||
const dateBerthSoldToOther = berthResoldRows[0]?.at ?? null;
|
||||
|
||||
// Resolve the assignee's display name for the header chip — falling back
|
||||
// Resolve the assignee's display name for the header chip - falling back
|
||||
// to the raw ID is fine if the user record is missing (deleted/disabled).
|
||||
let assignedToName: string | null = null;
|
||||
if (interest.assignedTo) {
|
||||
@@ -656,7 +656,7 @@ export async function getInterestById(id: string, portId: string) {
|
||||
...interest,
|
||||
clientName: clientRow?.fullName ?? null,
|
||||
clientPrimaryEmail: emailContact?.value ?? null,
|
||||
/** Contact-row id for the primary email — surfaces so the interest UI
|
||||
/** Contact-row id for the primary email - surfaces so the interest UI
|
||||
* can inline-edit through PATCH /api/v1/clients/[id]/contacts/[contactId]. */
|
||||
clientPrimaryEmailContactId: emailContact?.id ?? null,
|
||||
clientPrimaryPhone: phoneContact?.value ?? null,
|
||||
@@ -673,7 +673,7 @@ export async function getInterestById(id: string, portId: string) {
|
||||
activeReminderCount,
|
||||
assignedToName,
|
||||
recentActivityCount,
|
||||
// Phase 2 — risk-signal dates derived from event tables. Feed
|
||||
// Phase 2 - risk-signal dates derived from event tables. Feed
|
||||
// computeDealHealth so the pulse chip surfaces document declines,
|
||||
// cancelled reservations, and "berth resold to another deal" without
|
||||
// bespoke timestamp columns on the interest record.
|
||||
@@ -705,7 +705,7 @@ export async function createInterest(portId: string, data: CreateInterestInput,
|
||||
data.yachtId,
|
||||
);
|
||||
|
||||
// Per-port reminder defaults — applied only when the caller omitted
|
||||
// Per-port reminder defaults - applied only when the caller omitted
|
||||
// reminderEnabled / reminderDays. Honors the /admin/reminders page.
|
||||
const reminderConfig = await getPortReminderConfig(portId);
|
||||
const resolvedReminderEnabled = interestData.reminderEnabled ?? reminderConfig.defaultEnabled;
|
||||
@@ -781,7 +781,7 @@ export async function createInterest(portId: string, data: CreateInterestInput,
|
||||
}),
|
||||
);
|
||||
|
||||
// Phase 6 — CRM → Umami attribution. Fire an inbound-lead event so
|
||||
// Phase 6 - CRM → Umami attribution. Fire an inbound-lead event so
|
||||
// marketing can correlate inquiry volume with website traffic by
|
||||
// source / referrer.
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
@@ -902,7 +902,7 @@ export async function updateInterest(
|
||||
userId: data.assignedTo!,
|
||||
type: 'interest_assigned',
|
||||
title: 'New deal assigned to you',
|
||||
description: `${clientLabel} — ${existing.pipelineStage.replace(/_/g, ' ')}`,
|
||||
description: `${clientLabel} - ${existing.pipelineStage.replace(/_/g, ' ')}`,
|
||||
link: `/interests/${id}` as never,
|
||||
entityType: 'interest',
|
||||
entityId: id,
|
||||
@@ -959,13 +959,13 @@ export async function changeInterestStage(
|
||||
// Block egregious skips. The transition table allows reasonable forward
|
||||
// jumps (e.g. enquiry → eoi) while rejecting things like contract → enquiry.
|
||||
// Same-stage no-ops are allowed.
|
||||
// Override (sales-rep manual fix) bypasses the table — the route handler
|
||||
// Override (sales-rep manual fix) bypasses the table - the route handler
|
||||
// gates this on the `interests.override_stage` permission and requires
|
||||
// a reason, recorded in the audit log below.
|
||||
if (!data.override && !canTransitionStage(existing.pipelineStage, data.pipelineStage)) {
|
||||
// F21: use the human-readable stage labels in error copy.
|
||||
throw new ValidationError(
|
||||
`Cannot move interest from "${STAGE_LABELS[existing.pipelineStage as PipelineStage] ?? existing.pipelineStage}" directly to "${STAGE_LABELS[data.pipelineStage as PipelineStage] ?? data.pipelineStage}". Use the override option if you need to skip stages — requires a reason.`,
|
||||
`Cannot move interest from "${STAGE_LABELS[existing.pipelineStage as PipelineStage] ?? existing.pipelineStage}" directly to "${STAGE_LABELS[data.pipelineStage as PipelineStage] ?? data.pipelineStage}". Use the override option if you need to skip stages - requires a reason.`,
|
||||
);
|
||||
}
|
||||
if (data.override && (!data.reason || data.reason.trim().length < 5)) {
|
||||
@@ -1010,7 +1010,7 @@ export async function changeInterestStage(
|
||||
const milestoneDate = data.milestoneDate ? new Date(data.milestoneDate) : new Date();
|
||||
const milestoneUpdates: Record<string, unknown> = {};
|
||||
// For doc-bearing stages (eoi/reservation/contract) the milestone date is
|
||||
// owned by the doc-send/sign flow, not the stage move — these only fire
|
||||
// owned by the doc-send/sign flow, not the stage move - these only fire
|
||||
// when the rep stamps a date manually via override.
|
||||
if (data.pipelineStage === 'eoi') milestoneUpdates.dateEoiSent = milestoneDate;
|
||||
if (data.pipelineStage === 'reservation') milestoneUpdates.dateReservationSigned = milestoneDate;
|
||||
@@ -1052,7 +1052,7 @@ export async function changeInterestStage(
|
||||
}),
|
||||
);
|
||||
|
||||
// Phase 6 — CRM → Umami attribution for pipeline movement.
|
||||
// Phase 6 - CRM → Umami attribution for pipeline movement.
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
trackEvent(portId, 'interest-stage-changed', {
|
||||
interestId: id,
|
||||
@@ -1153,7 +1153,7 @@ export async function advanceStageIfBehind(
|
||||
* - 'off' → no-op (audit log of the event still fires upstream)
|
||||
*
|
||||
* Use this from every lifecycle event handler that wants admin-controlled
|
||||
* cadence — the bare `advanceStageIfBehind` stays available for paths
|
||||
* cadence - the bare `advanceStageIfBehind` stays available for paths
|
||||
* where the move is unconditional (manual rep action, completion of a
|
||||
* doc the admin can't disable).
|
||||
*/
|
||||
@@ -1174,7 +1174,7 @@ export async function advanceStageIfBehindGated(
|
||||
const mode = await getStageAdvanceMode(portId, trigger);
|
||||
if (mode === 'off') return false;
|
||||
if (mode === 'auto') return advanceStageIfBehind(interestId, portId, target, meta, reason);
|
||||
// 'suggest' — notify the rep with an Approve link, no auto-move. The
|
||||
// 'suggest' - notify the rep with an Approve link, no auto-move. The
|
||||
// rep can click the notification to fire the same advance manually.
|
||||
const existing = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
@@ -1193,7 +1193,7 @@ export async function advanceStageIfBehindGated(
|
||||
title: `Advance to ${target}?`,
|
||||
description:
|
||||
reason ??
|
||||
`${trigger} fired — suggested advance from ${existing.pipelineStage} to ${target}.`,
|
||||
`${trigger} fired - suggested advance from ${existing.pipelineStage} to ${target}.`,
|
||||
link: `/interests/${interestId}`,
|
||||
entityType: 'interest',
|
||||
entityId: interestId,
|
||||
@@ -1211,7 +1211,7 @@ export async function advanceStageIfBehindGated(
|
||||
// Records a terminal outcome for the interest. The `outcome` column is the
|
||||
// canonical terminal-state signal; `pipelineStage` stays where it was so
|
||||
// reports can answer "what stage was this deal at when it closed?". Prior to
|
||||
// 2026-05-14 this method forced pipelineStage='completed' — a sentinel
|
||||
// 2026-05-14 this method forced pipelineStage='completed' - a sentinel
|
||||
// outside the 7-stage canon that broke type narrowing + downstream stage
|
||||
// label lookups. Active-interest queries filter by `outcome IS NULL` so
|
||||
// the rep-facing kanban still hides closed deals.
|
||||
@@ -1266,7 +1266,7 @@ export async function setInterestOutcome(
|
||||
// via system_settings.berth_rules.
|
||||
void evaluateRule('interest_completed', id, portId, meta);
|
||||
|
||||
// Phase 2 nested-subfolders — rename the interest's document folder
|
||||
// Phase 2 nested-subfolders - rename the interest's document folder
|
||||
// to surface the outcome inline (e.g. "Deal A1-A3 (Won)"). Dynamic
|
||||
// import avoids the circular dep with document-folders.service which
|
||||
// already pulls from interests.service for the primary-berth label.
|
||||
@@ -1277,10 +1277,10 @@ export async function setInterestOutcome(
|
||||
: null,
|
||||
)
|
||||
.catch(() => {
|
||||
// Folder may not exist yet (first upload happens later) — silent.
|
||||
// Folder may not exist yet (first upload happens later) - silent.
|
||||
});
|
||||
|
||||
// Phase 6 — CRM → Umami attribution. Fire a custom Umami event so
|
||||
// Phase 6 - CRM → Umami attribution. Fire a custom Umami event so
|
||||
// marketing can correlate inbound website traffic with the resulting
|
||||
// deal outcome. Dynamic import to avoid a circular service dep at
|
||||
// module-load time.
|
||||
@@ -1316,7 +1316,7 @@ export async function clearInterestOutcome(
|
||||
// - Else if the current stage is the legacy 'completed' sentinel,
|
||||
// default to 'qualified' (closest analog of the pre-refactor
|
||||
// 'in_communication' which would have lived there).
|
||||
// - Else preserve the current stage — post-refactor setOutcome stops
|
||||
// - Else preserve the current stage - post-refactor setOutcome stops
|
||||
// touching pipelineStage, so the deal already knows where it was
|
||||
// when the rep closed it. Reopening should drop the rep back into
|
||||
// that same column on the kanban.
|
||||
@@ -1393,7 +1393,7 @@ export async function archiveInterest(id: string, portId: string, meta: AuditMet
|
||||
|
||||
// G-C4: fire the berth-rule (default mode 'suggest' for interest_archived).
|
||||
// G-I2: notify sales of the next-in-line interests on the released berth so
|
||||
// they can follow up — mirrors the client-archive flow but scoped to a
|
||||
// they can follow up - mirrors the client-archive flow but scoped to a
|
||||
// single interest's primary berth.
|
||||
if (primaryBerth) {
|
||||
void evaluateRule('interest_archived', id, portId, meta);
|
||||
@@ -1599,7 +1599,7 @@ export async function unlinkBerth(id: string, portId: string, meta: AuditMeta) {
|
||||
|
||||
export async function getInterestStageCounts(portId: string) {
|
||||
// Kanban / board counts surface active deals only (no terminal
|
||||
// outcomes) — terminal rows belong on a separate /closed surface.
|
||||
// outcomes) - terminal rows belong on a separate /closed surface.
|
||||
const rows = await db
|
||||
.select({ stage: interests.pipelineStage, count: sql<number>`count(*)::int` })
|
||||
.from(interests)
|
||||
|
||||
@@ -242,7 +242,7 @@ export async function createInvoice(portId: string, data: CreateInvoiceInput, me
|
||||
const invoiceNumber = await generateInvoiceNumber(portId, tx);
|
||||
|
||||
// Calculate subtotal from line items. The `z.coerce.number()` in
|
||||
// the schema makes the parsed value a number at runtime — narrow
|
||||
// the schema makes the parsed value a number at runtime - narrow
|
||||
// the post-parse shape locally so v4's stricter input typing
|
||||
// (unknown for coerced fields) doesn't leak into arithmetic.
|
||||
type ParsedLineItem = { quantity: number; unitPrice: number; description: string };
|
||||
@@ -427,7 +427,7 @@ export async function updateInvoice(
|
||||
if (data.kind !== undefined) updateData.kind = data.kind;
|
||||
|
||||
// Recalculate totals if line items changed (see createInvoice for
|
||||
// the ParsedLineItem narrowing rationale — same coerced-number
|
||||
// the ParsedLineItem narrowing rationale - same coerced-number
|
||||
// story applies on the update path).
|
||||
if (data.lineItems !== undefined) {
|
||||
type ParsedLineItem = { quantity: number; unitPrice: number; description: string };
|
||||
@@ -584,7 +584,7 @@ export async function sendInvoice(id: string, portId: string, meta: AuditMeta) {
|
||||
const invoice = await getInvoiceById(id, portId);
|
||||
|
||||
// Invoice PDF generation has been removed (the CRM no longer renders
|
||||
// client-facing PDFs from scratch — see the PDF stack overhaul spec).
|
||||
// client-facing PDFs from scratch - see the PDF stack overhaul spec).
|
||||
// The "send" event still fires so the queue + audit + socket flow
|
||||
// remains intact; downstream consumers can decide whether to render
|
||||
// an external document, link to the in-app view, or wait for the
|
||||
|
||||
@@ -200,7 +200,7 @@ export async function resolveInterestReportData(
|
||||
|
||||
// Join client (one-to-one) + primary berth (one-to-one via the
|
||||
// `is_primary=true` row). Keep the join LEFT so interests without
|
||||
// a primary berth still render — those are the early-stage deals
|
||||
// a primary berth still render - those are the early-stage deals
|
||||
// that haven't been pitched a specific mooring yet.
|
||||
const rows = await db
|
||||
.select({
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* After a smart-archive releases a berth back to available, the sales
|
||||
* team should be told who else expressed interest in that berth so they
|
||||
* can follow up. This is informational only — no automatic stage
|
||||
* can follow up. This is informational only - no automatic stage
|
||||
* transitions on the next interests.
|
||||
*
|
||||
* Recipients = port users whose role grants `interests.change_stage`
|
||||
@@ -24,7 +24,7 @@ export interface BerthReleaseNotificationInput {
|
||||
mooringNumber: string;
|
||||
archivedClientName: string;
|
||||
/** ids of the next-in-line interests on this berth (with the metadata
|
||||
* needed for the notification body — comes from the dossier). */
|
||||
* needed for the notification body - comes from the dossier). */
|
||||
nextInLineInterests: Array<{
|
||||
interestId: string;
|
||||
clientName: string | null;
|
||||
@@ -62,7 +62,7 @@ export async function notifyNextInLine(input: BerthReleaseNotificationInput): Pr
|
||||
const previewLines = input.nextInLineInterests.slice(0, 5).map((i) => {
|
||||
const stageLabel =
|
||||
STAGE_LABELS[i.pipelineStage as PipelineStage] ?? i.pipelineStage.replace(/_/g, ' ');
|
||||
return `${i.clientName ?? '(unknown)'} — ${stageLabel}`;
|
||||
return `${i.clientName ?? '(unknown)'} - ${stageLabel}`;
|
||||
});
|
||||
const more =
|
||||
input.nextInLineInterests.length > 5
|
||||
@@ -70,7 +70,7 @@ export async function notifyNextInLine(input: BerthReleaseNotificationInput): Pr
|
||||
: '';
|
||||
const description = input.nextInLineInterests.length
|
||||
? `${previewLines.join('\n')}${more}`
|
||||
: 'No prior interests recorded — this berth is fully available again.';
|
||||
: 'No prior interests recorded - this berth is fully available again.';
|
||||
|
||||
// 3. Fire-and-forget per recipient. dedupeKey collapses duplicate
|
||||
// fires within the cooldown window if multiple events queue up.
|
||||
@@ -79,7 +79,7 @@ export async function notifyNextInLine(input: BerthReleaseNotificationInput): Pr
|
||||
portId: input.portId,
|
||||
userId,
|
||||
type: 'berth_released',
|
||||
title: `Berth ${input.mooringNumber} released — ${input.archivedClientName} archived`,
|
||||
title: `Berth ${input.mooringNumber} released - ${input.archivedClientName} archived`,
|
||||
description,
|
||||
link: `/berths/${input.berthId}`,
|
||||
entityType: 'berth',
|
||||
|
||||
@@ -87,7 +87,7 @@ async function verifyParentBelongsToPort(
|
||||
* can show "from interest E17" or "from yacht Sea Breeze" badges
|
||||
* and offer a "Group by source" view alongside chronological.
|
||||
*
|
||||
* Company-owned yachts the client is a member of are excluded —
|
||||
* Company-owned yachts the client is a member of are excluded -
|
||||
* those are properly the company's notes, not the client's.
|
||||
*/
|
||||
export interface AggregatedClientNote {
|
||||
@@ -100,7 +100,7 @@ export interface AggregatedClientNote {
|
||||
authorId: string;
|
||||
authorName: string | null;
|
||||
source: 'client' | 'interest' | 'yacht';
|
||||
/** Origin entity id — interest_id / yacht_id / client_id. */
|
||||
/** Origin entity id - interest_id / yacht_id / client_id. */
|
||||
sourceId: string;
|
||||
/** Human label for the source (interest's berth mooring, yacht
|
||||
* name, or "Client" for client-level). */
|
||||
@@ -283,7 +283,7 @@ export async function listForYachtAggregated(
|
||||
? await db
|
||||
.select({ id: clients.id, name: clients.fullName })
|
||||
.from(clients)
|
||||
// M-MT04: defense-in-depth port_id filter — without it a stale
|
||||
// M-MT04: defense-in-depth port_id filter - without it a stale
|
||||
// ownerClientId persisted by a prior cross-port migration could
|
||||
// surface the wrong tenant's client name. Belt-and-braces given
|
||||
// yacht ownership is polymorphic via a non-FK pair.
|
||||
@@ -384,7 +384,7 @@ export async function listForYachtAggregated(
|
||||
* company (polymorphic ownership: `owner_type='company' AND
|
||||
* owner_id=companyId`) + every interest currently linked to those
|
||||
* yachts. Personal-side notes from individual company members are
|
||||
* NOT included — they belong on the client's own dossier.
|
||||
* NOT included - they belong on the client's own dossier.
|
||||
*/
|
||||
export async function listForCompanyAggregated(
|
||||
portId: string,
|
||||
@@ -871,7 +871,7 @@ export async function update(
|
||||
const [updated] = await db
|
||||
.update(yachtNotes)
|
||||
.set({ content: data.content, updatedAt: new Date() })
|
||||
// M-MT02: defense-in-depth — pin the UPDATE to the (id, parent) pair
|
||||
// M-MT02: defense-in-depth - pin the UPDATE to the (id, parent) pair
|
||||
// so a swapped noteId can't land on a sibling yacht's note even if
|
||||
// the existing read above already validated ownership.
|
||||
.where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId)))
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
|
||||
|
||||
portsFired += 1;
|
||||
|
||||
// Find all users with a port-role on this port — that's the
|
||||
// Find all users with a port-role on this port - that's the
|
||||
// recipient set. Future iteration could honor per-user opt-out
|
||||
// flags from userNotificationPreferences.
|
||||
const portUsers = await db
|
||||
@@ -104,7 +104,7 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
|
||||
|
||||
if (portUsers.length === 0) continue;
|
||||
|
||||
// Resolve branding once per port — every user on this port gets
|
||||
// Resolve branding once per port - every user on this port gets
|
||||
// the same shell.
|
||||
const branding = await getBrandingShell(port.id);
|
||||
|
||||
@@ -155,7 +155,7 @@ export async function runNotificationDigest(now: Date = new Date()): Promise<Dig
|
||||
{ branding },
|
||||
);
|
||||
|
||||
// M-EM04: dedicated catalog key — admins can override the
|
||||
// M-EM04: dedicated catalog key - admins can override the
|
||||
// digest subject from /admin/email without an unsafe cast and
|
||||
// the digest's setting key now namespaces cleanly.
|
||||
const subject = await resolveSubject({
|
||||
|
||||
@@ -221,7 +221,7 @@ export async function markRead(notificationId: string, userId: string): Promise<
|
||||
throw new NotFoundError('Notification');
|
||||
}
|
||||
|
||||
// Pin the WHERE to (id, userId, portId) — the SELECT just resolved
|
||||
// Pin the WHERE to (id, userId, portId) - the SELECT just resolved
|
||||
// notif.portId for this notification, so the update is fully tenant-
|
||||
// scoped and a future caller mistake (or a cache layer that ever
|
||||
// surfaced a foreign-port id) can't flip a row in another port.
|
||||
@@ -346,7 +346,7 @@ export async function notifyDocumentEvent(
|
||||
const title = DOCUMENT_EVENT_TITLES[eventType];
|
||||
const notifType = DOCUMENT_EVENT_NOTIF_TYPES[eventType];
|
||||
|
||||
// Cap the user fan-out — a document with many watchers (rare but
|
||||
// Cap the user fan-out - a document with many watchers (rare but
|
||||
// possible on hot pipeline items) would otherwise issue an unbounded
|
||||
// burst of notification inserts + socket emits. 8 in flight keeps
|
||||
// peak DB writes bounded and emits steady on the socket bus.
|
||||
|
||||
@@ -77,7 +77,7 @@ function safeParse(content: string): ParsedReceipt {
|
||||
}
|
||||
|
||||
async function runOpenAi({ imageBuffer, mimeType, apiKey, model }: RunArgs): Promise<OcrRunResult> {
|
||||
// Default OpenAI client has no timeout — a hung request would hold a Bull
|
||||
// Default OpenAI client has no timeout - a hung request would hold a Bull
|
||||
// documents-worker concurrency slot until the OS reset it (~15 min). The
|
||||
// 30s cap matches the cap on the (newer) email-draft worker fetch.
|
||||
const client = new OpenAI({ apiKey, timeout: OCR_TIMEOUT_MS });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Payment-records service. The CRM does NOT generate invoices — banks invoice
|
||||
* Payment-records service. The CRM does NOT generate invoices - banks invoice
|
||||
* clients directly. We record that money was received (or refunded) with an
|
||||
* optional uploaded receipt for audit purposes.
|
||||
*
|
||||
@@ -35,7 +35,7 @@ export async function listPaymentsForInterest(interestId: string, portId: string
|
||||
}
|
||||
|
||||
/** Net deposit total for an interest. `deposit` rows add; `refund` rows
|
||||
* subtract (their `amount` may be either positive or already negative — we
|
||||
* subtract (their `amount` may be either positive or already negative - we
|
||||
* always treat refunds as deductions to match the UI convention). */
|
||||
export async function getDepositTotalForInterest(
|
||||
interestId: string,
|
||||
@@ -56,7 +56,7 @@ export async function getDepositTotalForInterest(
|
||||
),
|
||||
);
|
||||
|
||||
// Use BigInt-ish accumulator via Number — amounts are EUR scale; we don't
|
||||
// Use BigInt-ish accumulator via Number - amounts are EUR scale; we don't
|
||||
// need cent-precise math for the auto-advance gate, but we DO normalize the
|
||||
// sign of refunds so a refund stored as "+200" still subtracts.
|
||||
let net = 0;
|
||||
|
||||
@@ -22,7 +22,7 @@ export const SETTING_KEYS = {
|
||||
// to (typically the marketing site's hosted form). When blank, the
|
||||
// built-in CRM route `/public/supplemental-info/<token>` is used.
|
||||
supplementalFormUrl: 'supplemental_form_url',
|
||||
// email_signature_html / email_footer_html — removed; the email shell
|
||||
// email_signature_html / email_footer_html - removed; the email shell
|
||||
// reads branding_email_header_html / branding_email_footer_html from
|
||||
// /admin/branding, which is the source of truth.
|
||||
emailAllowPersonalAccountSends: 'email_allow_personal_account_sends',
|
||||
@@ -42,7 +42,7 @@ export const SETTING_KEYS = {
|
||||
documensoClientRecipientId: 'documenso_client_recipient_id',
|
||||
documensoDeveloperRecipientId: 'documenso_developer_recipient_id',
|
||||
documensoApprovalRecipientId: 'documenso_approval_recipient_id',
|
||||
// Per-port Documenso webhook secret — two ports pointed at different
|
||||
// Per-port Documenso webhook secret - two ports pointed at different
|
||||
// Documenso instances cannot share the global env secret. The receiver
|
||||
// resolves the matching port by trying each enabled secret with a
|
||||
// timing-safe comparison.
|
||||
@@ -116,14 +116,14 @@ export const SETTING_KEYS = {
|
||||
// Berths
|
||||
berthsDefaultCurrency: 'berths_default_currency',
|
||||
|
||||
// Pipeline auto-advance — per-trigger mode (auto | suggest | off).
|
||||
// Pipeline auto-advance - per-trigger mode (auto | suggest | off).
|
||||
// Stored as a single JSON blob keyed by trigger name so the admin UI
|
||||
// edits/saves the full map atomically. Defaults applied in
|
||||
// `getStageAdvanceMode` — aggressive defaults match the conventional
|
||||
// `getStageAdvanceMode` - aggressive defaults match the conventional
|
||||
// CRM behaviour (EOI signed → reservation auto-advances).
|
||||
stageAdvanceRules: 'stage_advance_rules',
|
||||
|
||||
// Residential partner-forwarding recipients — comma-separated emails
|
||||
// Residential partner-forwarding recipients - comma-separated emails
|
||||
// that receive a courtesy notification on every new residential
|
||||
// inquiry. Blank disables. See createResidentialInterest +
|
||||
// forwardResidentialInquiryToPartner for usage.
|
||||
@@ -137,7 +137,7 @@ export type StageAdvanceMode = 'auto' | 'suggest' | 'off';
|
||||
/**
|
||||
* Stage transitions that callers can gate through the admin's
|
||||
* `stage_advance_rules` setting. Keys are the trigger names already
|
||||
* used by the rules engine (`berth-rules-engine.ts`) — keeping them in
|
||||
* used by the rules engine (`berth-rules-engine.ts`) - keeping them in
|
||||
* sync lets a single admin toggle drive both side-effects (berth status)
|
||||
* and stage moves.
|
||||
*/
|
||||
@@ -150,7 +150,7 @@ export type StageAdvanceTrigger =
|
||||
|
||||
const STAGE_ADVANCE_DEFAULTS: Record<StageAdvanceTrigger, StageAdvanceMode> = {
|
||||
// Sending the EOI is the moment the deal formally enters the document-
|
||||
// signing pursuit phase — auto-advance so the kanban tracks reality
|
||||
// signing pursuit phase - auto-advance so the kanban tracks reality
|
||||
// without a rep having to click.
|
||||
eoi_sent: 'auto',
|
||||
// EOI signed = formal commitment to proceed → advance to reservation.
|
||||
@@ -303,7 +303,7 @@ export interface PortDocumensoConfig {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
apiVersion: DocumensoApiVersion;
|
||||
/** Resolution provenance — `port` / `global` / `env` / `default` /
|
||||
/** Resolution provenance - `port` / `global` / `env` / `default` /
|
||||
* `none`. Surfaces in DOCUMENSO_AUTH_FAILURE messages so a 401 in
|
||||
* prod tells the operator "this came from env fallback" vs "this
|
||||
* came from a per-port admin entry" without checking logs. */
|
||||
@@ -338,7 +338,7 @@ export interface PortDocumensoConfig {
|
||||
* 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
|
||||
/** Per-port display labels for the developer + approver slots - drive
|
||||
* email subjects and signer-progress UI copy. */
|
||||
developerLabel: string;
|
||||
approverLabel: string;
|
||||
@@ -351,7 +351,7 @@ export interface PortDocumensoConfig {
|
||||
/**
|
||||
* v2-only: PARALLEL (default) or SEQUENTIAL signing-order enforcement.
|
||||
* `null` keeps the upstream default (PARALLEL); a non-null value gets
|
||||
* passed verbatim. v1 instances ignore this — see admin Documenso page.
|
||||
* passed verbatim. v1 instances ignore this - see admin Documenso page.
|
||||
*/
|
||||
signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null;
|
||||
/**
|
||||
@@ -468,7 +468,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
||||
* finds a match, then dispatches with the resolved portId.
|
||||
*
|
||||
* `null` portId in the returned array means "matches but no port was
|
||||
* resolved" — the caller falls back to the legacy global path.
|
||||
* resolved" - the caller falls back to the legacy global path.
|
||||
*/
|
||||
export interface DocumensoSecretEntry {
|
||||
portId: string | null;
|
||||
@@ -503,7 +503,7 @@ export async function listDocumensoWebhookSecrets(): Promise<DocumensoSecretEntr
|
||||
try {
|
||||
secret = decrypt(JSON.stringify(row.value));
|
||||
} catch {
|
||||
// Decryption failure (corrupt envelope, key mismatch) — skip the
|
||||
// Decryption failure (corrupt envelope, key mismatch) - skip the
|
||||
// entry so a stale row doesn't crash the entire receiver loop.
|
||||
secret = null;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function isPortalEnabledForPort(portId: string): Promise<boolean> {
|
||||
* form when the kill switch is flipped, so guessed `/portal/*` URLs
|
||||
* don't surface a working-looking form that just rejects every submit.
|
||||
*
|
||||
* Default-OFF (returns false) when there are no setting rows — preserves
|
||||
* Default-OFF (returns false) when there are no setting rows - preserves
|
||||
* the legacy default-on behaviour for fresh installs / ports that never
|
||||
* touched the setting.
|
||||
*
|
||||
@@ -144,7 +144,7 @@ async function issueActivationToken(
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||
const portName = port?.name ?? 'Port Nimara';
|
||||
|
||||
// URL fragment (#token=…) instead of query string — keeps the
|
||||
// URL fragment (#token=…) instead of query string - keeps the
|
||||
// activation token out of server logs, proxy logs, Referer header,
|
||||
// and any CDN/edge cache. The portal /activate page reads the token
|
||||
// client-side via `window.location.hash`. See PRE-DEPLOY-PLAN § 1.2.5.
|
||||
@@ -413,7 +413,7 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
||||
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, user.portId) });
|
||||
const portName = port?.name ?? 'Port Nimara';
|
||||
// Same URL-fragment treatment as activation links — token never
|
||||
// Same URL-fragment treatment as activation links - token never
|
||||
// travels server-side. See PRE-DEPLOY-PLAN § 1.2.5.
|
||||
const link = `${env.APP_URL}/portal/reset-password#token=${encodeURIComponent(raw)}`;
|
||||
const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset');
|
||||
|
||||
@@ -249,7 +249,7 @@ export async function getClientInvoices(
|
||||
.map((c) => c.value.toLowerCase());
|
||||
|
||||
// G-I5: the most common B2B pattern is "individual client buys through their
|
||||
// company" — those invoices ship with billingEntityType='company' and the
|
||||
// company" - those invoices ship with billingEntityType='company' and the
|
||||
// portal user (client) is just a director of that company. Filtering on
|
||||
// billingEmail alone hides these invoices. Resolve director memberships
|
||||
// through company_memberships (role='director', active = endDate IS NULL)
|
||||
@@ -292,7 +292,7 @@ export async function getClientInvoices(
|
||||
|
||||
// Fetch only the invoices matching any of the client's email addresses or
|
||||
// company memberships. Without the predicate push-down here every portal
|
||||
// invoice page-load full-scanned the invoices table and filtered in JS —
|
||||
// invoice page-load full-scanned the invoices table and filtered in JS -
|
||||
// by 12mo it would have been the worst portal endpoint in the platform.
|
||||
// Defensive limit 100 caps the upper bound for clients with abnormally many
|
||||
// invoices.
|
||||
@@ -350,7 +350,7 @@ export async function getDocumentDownloadUrl(
|
||||
|
||||
// M-IN01: 4-hour TTL for portal links so clients clicking on a saved
|
||||
// email don't see "link expired" after 15 minutes. Storage backend's
|
||||
// default is 900s — that's appropriate for in-CRM previews but
|
||||
// default is 900s - that's appropriate for in-CRM previews but
|
||||
// hostile for end-clients who may revisit a doc the next morning.
|
||||
return presignDownloadUrl(file.storagePath, 4 * 3600);
|
||||
}
|
||||
|
||||
@@ -47,13 +47,13 @@ export async function createPort(data: CreatePortInput, meta: AuditMeta) {
|
||||
// fallback, and `scripts/backfill-document-folders.ts` covers
|
||||
// orphaned ports). Swallow + log instead of propagating, so the
|
||||
// operator doesn't see a 500 from `createPort` against an already-
|
||||
// committed port row — they'd retry and hit a 409 slug-exists.
|
||||
// committed port row - they'd retry and hit a 409 slug-exists.
|
||||
try {
|
||||
await ensureSystemRoots(port!.id, meta.userId);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ portId: port!.id, err },
|
||||
'ensureSystemRoots failed after port create — port row is live; system folders will be created on first admin action.',
|
||||
'ensureSystemRoots failed after port create - port row is live; system folders will be created on first admin action.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Public interest creation — extracted from `/api/public/interests/route.ts`
|
||||
* Public interest creation - extracted from `/api/public/interests/route.ts`
|
||||
* per the C.4 audit finding ("Public POST routes bypass service layer"). The
|
||||
* pre-extraction route was 346 lines of inline DB logic + audit + email
|
||||
* fan-out, which made unit testing the dedup, ownership, and address rules
|
||||
@@ -11,9 +11,9 @@
|
||||
* - This service handles the transactional trio creation (client + yacht
|
||||
* + interest, plus optional company + membership + address).
|
||||
*
|
||||
* The companion routes — `/api/public/website-inquiries/route.ts` (pure raw
|
||||
* The companion routes - `/api/public/website-inquiries/route.ts` (pure raw
|
||||
* capture; no entity creation) and `/api/public/residential-inquiries/route.ts`
|
||||
* (residential funnel, separate schema) — were intentionally NOT extracted
|
||||
* (residential funnel, separate schema) - were intentionally NOT extracted
|
||||
* here. Their bodies are smaller and their concerns don't overlap with the
|
||||
* marina-funnel logic this service encapsulates.
|
||||
*/
|
||||
@@ -72,7 +72,7 @@ export async function createPublicInterest(
|
||||
|
||||
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
|
||||
|
||||
// Resolve berth by mooring number (if provided). Read-only lookup — safe
|
||||
// Resolve berth by mooring number (if provided). Read-only lookup - safe
|
||||
// to do outside the transaction.
|
||||
let berthId: string | null = null;
|
||||
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
|
||||
@@ -89,7 +89,7 @@ export async function createPublicInterest(
|
||||
// ─── Transactional trio creation ────────────────────────────────────────
|
||||
const result = await withTransaction(async (tx) => {
|
||||
// 1. Find or create client by email. The inquiry-funnel audit
|
||||
// flagged that the previous exact match was case-sensitive —
|
||||
// flagged that the previous exact match was case-sensitive -
|
||||
// capital-letter resubmissions spawned duplicate client+yacht+
|
||||
// interest rows. Match LOWER(value) instead so foo@x.com and
|
||||
// Foo@X.COM dedupe to the same client.
|
||||
|
||||
@@ -143,7 +143,7 @@ export async function deleteCriterion(id: string, portId: string, meta: AuditMet
|
||||
* Whole-list reorder. Rewrites display_order to match the array index of
|
||||
* each id, inside a single transaction so partial failure can't leave the
|
||||
* port with a scrambled order. The ids array must cover exactly the port's
|
||||
* current criteria — any mismatch (extra or missing id) is rejected so the
|
||||
* current criteria - any mismatch (extra or missing id) is rejected so the
|
||||
* UI can't silently drop a criterion by sending a stale list.
|
||||
*/
|
||||
export async function reorderCriteria(
|
||||
@@ -226,7 +226,7 @@ export interface QualificationRow {
|
||||
* - `dimensions` → ticked when EITHER (a) the linked yacht has all
|
||||
* three dims (length/width/draft) OR (b) the interest itself has
|
||||
* desired-berth dims set. The "no yacht needed" case is the second
|
||||
* branch — a client buying a berth doesn't have to own a vessel,
|
||||
* branch - a client buying a berth doesn't have to own a vessel,
|
||||
* they just have to know the berth size they want.
|
||||
*/
|
||||
autoSatisfied: boolean;
|
||||
@@ -234,7 +234,7 @@ export interface QualificationRow {
|
||||
* Human-readable summary of WHY a criterion is auto-satisfied (e.g.
|
||||
* "Desired: 60 × 25 × 6 ft"). Empty string when the criterion is not
|
||||
* auto-satisfied OR when no derivation rule applies. Surfaced on the
|
||||
* checklist row so the rep can see the evidence behind the tick — the
|
||||
* checklist row so the rep can see the evidence behind the tick - the
|
||||
* "why is this checked?" question came up in UAT.
|
||||
*/
|
||||
evidence: string;
|
||||
@@ -242,7 +242,7 @@ export interface QualificationRow {
|
||||
|
||||
/**
|
||||
* The qualification state for a specific interest, joined with the port's
|
||||
* current criterion definitions. Returns only currently-enabled criteria —
|
||||
* current criterion definitions. Returns only currently-enabled criteria -
|
||||
* disabled ones are hidden from the rep but their state rows are preserved
|
||||
* in the DB for audit.
|
||||
*/
|
||||
@@ -250,7 +250,7 @@ export async function listInterestQualifications(
|
||||
interestId: string,
|
||||
portId: string,
|
||||
): Promise<QualificationRow[]> {
|
||||
// Pull the interest row with the fields needed to derive auto-satisfaction —
|
||||
// Pull the interest row with the fields needed to derive auto-satisfaction -
|
||||
// desired-berth dims (length/width/draft) plus a linked yacht if any. Cost
|
||||
// is one extra column-select vs the previous columns:{id:true} probe, so
|
||||
// negligible.
|
||||
@@ -310,7 +310,7 @@ export async function listInterestQualifications(
|
||||
const explicit = s?.confirmed ?? false;
|
||||
const evidence = autoSatisfied ? computeEvidence(c.key, ctx) : '';
|
||||
// Derived-only criteria (e.g. `dimensions`) ignore the explicit tick
|
||||
// entirely — if the underlying evidence disappears, the row un-ticks.
|
||||
// entirely - if the underlying evidence disappears, the row un-ticks.
|
||||
// Judgement-based criteria keep the OR semantic so a rep's explicit
|
||||
// confirmation survives an evidence change.
|
||||
const confirmed = isDerivedOnly(c.key) ? autoSatisfied : explicit || autoSatisfied;
|
||||
@@ -370,7 +370,7 @@ function computeAutoSatisfied(key: string, ctx: AutoCtx): boolean {
|
||||
return hasYachtDims || hasDesiredDims;
|
||||
}
|
||||
if (key === 'intent_confirmed') {
|
||||
// Signing an EOI (or later) is the strongest signal of intent —
|
||||
// Signing an EOI (or later) is the strongest signal of intent -
|
||||
// auto-tick once the rep has moved past Qualified. The criterion
|
||||
// can still be ticked manually before then.
|
||||
const stageIdx = PIPELINE_STAGES.indexOf(ctx.pipelineStage);
|
||||
@@ -383,7 +383,7 @@ function computeAutoSatisfied(key: string, ctx: AutoCtx): boolean {
|
||||
/**
|
||||
* Returns a short human-readable string explaining what data drove the
|
||||
* auto-satisfaction. Mirrors `computeAutoSatisfied`'s branching so the UI
|
||||
* can render "Auto · <evidence>" — closes the "why is this ticked?" gap.
|
||||
* can render "Auto · <evidence>" - closes the "why is this ticked?" gap.
|
||||
*/
|
||||
function computeEvidence(key: string, ctx: AutoCtx): string {
|
||||
if (key === 'dimensions') {
|
||||
@@ -411,7 +411,7 @@ function computeEvidence(key: string, ctx: AutoCtx): string {
|
||||
/**
|
||||
* Upsert a single criterion's confirmed-state for an interest. Stamping the
|
||||
* server-side fields (confirmedBy / confirmedAt) makes the row a proper
|
||||
* audit record — the caller can't backdate it.
|
||||
* audit record - the caller can't backdate it.
|
||||
*/
|
||||
export async function setInterestQualification(
|
||||
interestId: string,
|
||||
@@ -425,7 +425,7 @@ export async function setInterestQualification(
|
||||
});
|
||||
if (!interest) throw new NotFoundError('Interest');
|
||||
|
||||
// Refuse keys the port doesn't have a criterion for — keeps state rows
|
||||
// Refuse keys the port doesn't have a criterion for - keeps state rows
|
||||
// referentially consistent with the visible config.
|
||||
const criterion = await db.query.qualificationCriteria.findFirst({
|
||||
where: and(
|
||||
|
||||
@@ -54,7 +54,7 @@ function decode(member: string): { type: RecentlyViewedType; id: string } | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an entity view. Fire-and-forget — caller should NOT await this
|
||||
* Records an entity view. Fire-and-forget - caller should NOT await this
|
||||
* in the hot path; the redis op is logged-and-swallowed on failure since
|
||||
* a missed view never breaks the user experience.
|
||||
*/
|
||||
|
||||
@@ -497,7 +497,7 @@ export async function processFollowUpReminders() {
|
||||
);
|
||||
}
|
||||
|
||||
// Single port-room emit summarising the batch — the per-row emit was
|
||||
// Single port-room emit summarising the batch - the per-row emit was
|
||||
// mostly noise to the dashboard and amplified socket traffic linearly
|
||||
// with interest count.
|
||||
if (newReminders.length > 0) {
|
||||
@@ -532,7 +532,7 @@ export async function processOverdueReminders() {
|
||||
.set({ status: 'pending', snoozedUntil: null, updatedAt: now })
|
||||
.where(and(eq(reminders.status, 'snoozed'), lte(reminders.snoozedUntil, now)));
|
||||
|
||||
// Phase 4 — claim due reminders by stamping fired_at in a single
|
||||
// Phase 4 - claim due reminders by stamping fired_at in a single
|
||||
// UPDATE...RETURNING. Postgres's row locks guarantee only one worker
|
||||
// wins per row, so parallel maintenance workers can't double-fire the
|
||||
// same reminder. Limited to status='pending' (the un-snooze pass
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function fetchPipelineData(
|
||||
_params: Record<string, unknown>,
|
||||
): Promise<PipelineData> {
|
||||
// Count interests per pipeline stage (non-archived).
|
||||
// The reporting audit caught the missing .groupBy() — without it,
|
||||
// The reporting audit caught the missing .groupBy() - without it,
|
||||
// postgres rejects the SELECT or collapses every interest into a
|
||||
// single ELSE-stage row. groupBy fixes the per-stage breakdown.
|
||||
const stageCounts = await db
|
||||
@@ -110,7 +110,7 @@ export async function fetchPipelineData(
|
||||
topInterests: topInterestsRows.map((r) => ({
|
||||
id: r.id,
|
||||
clientId: r.clientId,
|
||||
// M-L02: canonicalize for the same reason — the PDF stage label
|
||||
// M-L02: canonicalize for the same reason - the PDF stage label
|
||||
// should always resolve from the modern 7-stage set.
|
||||
pipelineStage: canonicalizeStage(r.pipelineStage),
|
||||
berthPrice: r.berthPrice ? String(r.berthPrice) : null,
|
||||
@@ -146,7 +146,7 @@ export async function fetchRevenueData(
|
||||
const stageRevenueMap = rollupStageRevenue(stageRevenue);
|
||||
|
||||
// Total revenue from WON interests only. Reporting audit caught the
|
||||
// `outcome='won'` is the canonical money-changed-hands signal — won
|
||||
// `outcome='won'` is the canonical money-changed-hands signal - won
|
||||
// deals can technically be set from any pipeline stage, and the legacy
|
||||
// belt-and-suspenders `pipeline_stage='completed'` filter is brittle to
|
||||
// future cleanup of the 'completed' sentinel-stage convention (see
|
||||
@@ -164,7 +164,7 @@ export async function fetchRevenueData(
|
||||
and(eq(interests.portId, portId), eq(interests.outcome, 'won'), isNull(interests.archivedAt)),
|
||||
);
|
||||
|
||||
// Pipeline-weighted forecast — sums (berth price × stage weight) for
|
||||
// Pipeline-weighted forecast - sums (berth price × stage weight) for
|
||||
// every active interest. Stage weights resolve from
|
||||
// `system_settings.pipeline_weights` (per-port admin override) and
|
||||
// fall back to STAGE_WEIGHTS defaults. The PDF surfaces this number
|
||||
@@ -192,7 +192,7 @@ export async function fetchRevenueData(
|
||||
.where(activeInterestsWhere(portId))
|
||||
.groupBy(interests.pipelineStage);
|
||||
|
||||
// M-L02 covered inside computeTotalForecast via canonicalizeStage —
|
||||
// M-L02 covered inside computeTotalForecast via canonicalizeStage -
|
||||
// legacy stage keys hit the weight map under their modern equivalent.
|
||||
const totalForecast = computeTotalForecast(forecastRows, pipelineWeights);
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export function computeTotalForecast(
|
||||
}
|
||||
|
||||
/**
|
||||
* Occupancy rate as a percentage. "Occupied" = sold only — per the
|
||||
* Occupancy rate as a percentage. "Occupied" = sold only - per the
|
||||
* 2026-05-14 product decision, under_offer is a hold (blocks sale to
|
||||
* other clients) but doesn't count as the berth being occupied yet.
|
||||
* Returns the rate to 1 decimal place; returns 0 when totalBerths=0
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ConflictError, NotFoundError } from '@/lib/errors';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
|
||||
/**
|
||||
* CRUD for saved PDF-report templates. Multi-tenant safe — every
|
||||
* CRUD for saved PDF-report templates. Multi-tenant safe - every
|
||||
* query carries `port_id = ctx.portId` and the unique sibling-name
|
||||
* index is `(port_id, kind, LOWER(name))`, so two different ports
|
||||
* can both have a "Monthly board report" template without colliding.
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
* from the validators module.
|
||||
*
|
||||
* Each stage carries:
|
||||
* - id (machine — used in the DB pipeline_stage column)
|
||||
* - id (machine - used in the DB pipeline_stage column)
|
||||
* - label (display)
|
||||
* - terminal hint ('won' | 'lost' | null) — drives funnel reports
|
||||
* - 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
|
||||
@@ -38,8 +38,8 @@ const DEFAULT_STAGES: ResidentialStage[] = [
|
||||
{ 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' },
|
||||
{ id: 'closed_won', label: 'Closed - won', terminal: 'won' },
|
||||
{ id: 'closed_lost', label: 'Closed - lost', terminal: 'lost' },
|
||||
];
|
||||
|
||||
export async function listStages(portId: string): Promise<ResidentialStage[]> {
|
||||
@@ -83,7 +83,7 @@ export interface SaveStagesArgs {
|
||||
* 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)
|
||||
* orphan - remaining orphans are left at their old (now-removed)
|
||||
* stage and will need a follow-up cleanup. */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export async function createResidentialClient(
|
||||
|
||||
// Best-effort auto-link to an existing main-client record. Match by
|
||||
// email (cheap, single index lookup) then by E.164 phone (next-best).
|
||||
// Failures or no-match scenarios silently leave the row unlinked —
|
||||
// Failures or no-match scenarios silently leave the row unlinked -
|
||||
// reps can wire it up via the admin UI later.
|
||||
void findAndLinkMatchingMainClient(row.id, portId).catch((err) => {
|
||||
console.error('[residential] auto-link match failed', err);
|
||||
@@ -250,7 +250,7 @@ export async function listResidentialInterests(
|
||||
// stage / tag aggregation lives there). Page size is capped by the
|
||||
// validator so this stays a single bulk IN-list query.
|
||||
// `buildListQuery` returns `data: typeof table.$inferSelect[]` so the
|
||||
// row type is `residentialInterests` — known shape, but TS infers
|
||||
// row type is `residentialInterests` - known shape, but TS infers
|
||||
// `unknown[]` through the generic helper. Cast through `unknown` once
|
||||
// here so the downstream enrichment is type-clean.
|
||||
type InterestRow = typeof residentialInterests.$inferSelect;
|
||||
@@ -336,7 +336,7 @@ export async function createResidentialInterest(
|
||||
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: row.id });
|
||||
|
||||
// Fire-and-forget partner-forward email. Failures here MUST NOT block
|
||||
// the create — partner notification is a courtesy. Errors are logged
|
||||
// the create - partner notification is a courtesy. Errors are logged
|
||||
// server-side so the operator can see them, but the API still 201s.
|
||||
void forwardResidentialInquiryToPartner({
|
||||
portId,
|
||||
@@ -470,7 +470,7 @@ async function forwardResidentialInquiryToPartner(input: {
|
||||
* 2. phoneE164 (residential.phone_e164 matches clients.contacts of
|
||||
* channel='phone' or 'whatsapp')
|
||||
*
|
||||
* Match ordering is "exact email beats phone beats nothing" — the
|
||||
* Match ordering is "exact email beats phone beats nothing" - the
|
||||
* first hit wins. Returns the linked main-client id when a match was
|
||||
* found, or null when no match exists.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Per-port sales-email configuration (Phase 7 — see plan §4.9).
|
||||
* Per-port sales-email configuration (Phase 7 - see plan §4.9).
|
||||
*
|
||||
* Distinct from {@link getPortEmailConfig} (`port-config.ts`) which resolves
|
||||
* the **noreply** account used by automated/system emails. The sales account
|
||||
@@ -13,7 +13,7 @@
|
||||
*
|
||||
* SECURITY (§14.10): SMTP/IMAP passwords are encrypted at rest using the
|
||||
* existing `EMAIL_CREDENTIAL_KEY` symmetric key. Reps cannot read the
|
||||
* decrypted value via the API — only `manage_settings` admins can write,
|
||||
* decrypted value via the API - only `manage_settings` admins can write,
|
||||
* and even they only ever see a placeholder mask on read (see the admin
|
||||
* route handler).
|
||||
*/
|
||||
@@ -85,7 +85,7 @@ const DEFAULT_BERTH_PDF_BODY = [
|
||||
'',
|
||||
'Please find attached the spec sheet for berth {{berth.mooringNumber}} at {{port.name}}.',
|
||||
'',
|
||||
'Happy to set up a call to walk through the details — let me know what works.',
|
||||
'Happy to set up a call to walk through the details - let me know what works.',
|
||||
'',
|
||||
'Best,',
|
||||
].join('\n');
|
||||
@@ -148,7 +148,7 @@ export async function getSalesEmailConfig(portId: string): Promise<SalesEmailCon
|
||||
authMethod: authMethod ?? 'app_password',
|
||||
// "Usable" means we have host + (user, pass) pair OR host + no auth
|
||||
// (some test/local SMTP doesn't auth). For prod-realistic, we require
|
||||
// creds — empty creds means we'll just bounce against the relay.
|
||||
// creds - empty creds means we'll just bounce against the relay.
|
||||
isUsable: Boolean(
|
||||
(smtpHost ?? env.SMTP_HOST) && ((smtpUser && smtpPass) ?? (env.SMTP_USER && env.SMTP_PASS)),
|
||||
),
|
||||
@@ -344,7 +344,7 @@ export async function createSalesTransporter(portId: string): Promise<{
|
||||
}
|
||||
|
||||
/**
|
||||
* Public-facing sanitizer — strips encrypted fields and replaces password
|
||||
* Public-facing sanitizer - strips encrypted fields and replaces password
|
||||
* fields with a boolean `isSet` marker. Used by the admin GET endpoint so
|
||||
* reps with `manage_settings` can see whether creds are configured without
|
||||
* the API ever returning the ciphertext (much less plaintext).
|
||||
@@ -358,7 +358,7 @@ export function redactSalesConfigForResponse(
|
||||
imap: Omit<SalesImapConfig, 'imapPass'> & { imapPassIsSet: boolean };
|
||||
content: SalesContentConfig;
|
||||
} {
|
||||
// Spread without the password fields — never reflect the decrypted value
|
||||
// Spread without the password fields - never reflect the decrypted value
|
||||
// (or its ciphertext) back to the API surface.
|
||||
const email = {
|
||||
fromAddress: cfg.fromAddress,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* keyword, so `smtp` lands on the email settings page even though the
|
||||
* label reads "Email accounts".
|
||||
*
|
||||
* Why hardcoded vs introspecting routes? The catalog is curated — only
|
||||
* Why hardcoded vs introspecting routes? The catalog is curated - only
|
||||
* pages worth jumping to from a global search appear here, and each
|
||||
* entry has hand-picked keyword synonyms that route inference can't
|
||||
* derive. Adding a route to the catalog is cheap; misfiring routes are
|
||||
@@ -20,11 +20,11 @@ import type { RolePermissions } from '@/lib/db/schema/users';
|
||||
export type NavCatalogCategory = 'settings' | 'admin' | 'dashboard';
|
||||
|
||||
export interface NavCatalogEntry {
|
||||
/** Path template — `:portSlug` is substituted at lookup time. */
|
||||
/** Path template - `:portSlug` is substituted at lookup time. */
|
||||
href: string;
|
||||
label: string;
|
||||
category: NavCatalogCategory;
|
||||
/** Lowercase aliases — query is matched against label + these. */
|
||||
/** Lowercase aliases - query is matched against label + these. */
|
||||
keywords: string[];
|
||||
/**
|
||||
* Permission gate; only shown to users whose `RolePermissions` resolves
|
||||
@@ -59,7 +59,7 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
||||
keywords: ['preferences', 'configuration', 'config'],
|
||||
},
|
||||
// The granular settings cards below redirect to the `/admin/<x>` routes
|
||||
// that actually exist — the catalog previously listed `/settings/<x>`
|
||||
// that actually exist - the catalog previously listed `/settings/<x>`
|
||||
// paths that have never had route folders. We keep the keyword aliases
|
||||
// so the cmd-K search still finds them under the right destination.
|
||||
{
|
||||
@@ -187,6 +187,13 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
||||
keywords: ['activity', 'history', 'events', 'who did what', 'compliance'],
|
||||
requires: 'admin.view_audit_log',
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/admin/berths',
|
||||
label: 'Berths admin',
|
||||
category: 'admin',
|
||||
keywords: ['bulk add berths', 'reconcile berths', 'berth pdf', 'mooring', 'bulk'],
|
||||
requires: 'admin.manage_settings',
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/admin/inquiries',
|
||||
label: 'Website inquiries inbox',
|
||||
@@ -204,7 +211,7 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
||||
// ─── Admin → granular section cards (the AdminSectionsBrowser groups) ────
|
||||
// These deep-link to specific admin sub-pages. Each one's `keywords`
|
||||
// mirrors the corresponding entry in src/components/admin/
|
||||
// admin-sections-browser.tsx — so typing a setting key in the topbar
|
||||
// admin-sections-browser.tsx - so typing a setting key in the topbar
|
||||
// global search finds the same card the in-admin search would.
|
||||
{
|
||||
href: '/:portSlug/admin/settings',
|
||||
@@ -377,13 +384,8 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
||||
keywords: ['openai', 'anthropic', 'gpt', 'claude', 'llm', 'api key', 'embeddings'],
|
||||
requires: 'admin.manage_settings',
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/admin/ocr',
|
||||
label: 'Receipt OCR',
|
||||
category: 'admin',
|
||||
keywords: ['receipt', 'scan', 'tesseract', 'expense scanner', 'confidence'],
|
||||
requires: 'admin.manage_settings',
|
||||
},
|
||||
// /admin/ocr collapsed into /admin/ai on 2026-05-22 (the OcrSettingsForm
|
||||
// already lived on both pages). Keywords surfaced via the AI tile.
|
||||
{
|
||||
href: '/:portSlug/admin/website-analytics',
|
||||
label: 'Website analytics (Umami)',
|
||||
@@ -405,7 +407,7 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
||||
keywords: ['roles', 'permissions', 'access control', 'rbac'],
|
||||
requires: 'admin.manage_users',
|
||||
},
|
||||
// /admin/invitations was merged into /admin/users on 2026-05-21 — the
|
||||
// /admin/invitations was merged into /admin/users on 2026-05-21 - the
|
||||
// standalone catalog entry would route to the redirect stub. Reps
|
||||
// searching for "invite" still land on the right surface via the
|
||||
// /admin/users keyword list (extended below).
|
||||
@@ -422,7 +424,7 @@ export function resolveHref(href: string, portSlug: string): string {
|
||||
* label + each keyword; ranking favors label hits over keyword hits and
|
||||
* prefix hits over mid-string hits.
|
||||
*
|
||||
* Pure / sync — runs in-process. The catalog is ~15 entries today, so
|
||||
* Pure / sync - runs in-process. The catalog is ~15 entries today, so
|
||||
* the linear scan is irrelevant cost-wise.
|
||||
*/
|
||||
export function searchNavCatalog(
|
||||
@@ -447,7 +449,7 @@ export function searchNavCatalog(
|
||||
// Some hrefs intentionally appear in multiple catalog categories
|
||||
// (e.g. /admin/templates lives under both 'settings' and 'admin').
|
||||
// Keep the highest-scoring variant so the dropdown never renders
|
||||
// two rows with the same `id` (href) — React would otherwise warn
|
||||
// two rows with the same `id` (href) - React would otherwise warn
|
||||
// about duplicate keys.
|
||||
const existing = byHref.get(entry.href);
|
||||
if (!existing || score > existing.score) {
|
||||
@@ -468,7 +470,7 @@ function scoreEntry(q: string, entry: NavCatalogEntry): number {
|
||||
if (label.startsWith(q)) return 80;
|
||||
if (label.includes(q)) return 60;
|
||||
|
||||
// Keyword hits — strongest if the keyword exactly equals the query
|
||||
// Keyword hits - strongest if the keyword exactly equals the query
|
||||
// (e.g. user types "smtp"), then prefix, then substring.
|
||||
for (const kw of entry.keywords) {
|
||||
const k = kw.toLowerCase();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Global search service — drives the topbar `CommandSearch` dropdown.
|
||||
* Global search service - drives the topbar `CommandSearch` dropdown.
|
||||
*
|
||||
* Buckets covered: clients (with email/phone via client_contacts JOIN),
|
||||
* residential clients, yachts, companies, interests (federated when a
|
||||
@@ -11,7 +11,7 @@
|
||||
* Matching strategy per column type:
|
||||
* - Long text fields (full_name, company name, yacht name, descriptions)
|
||||
* use `to_tsvector('simple', col) @@ to_tsquery('simple', "joh:* & smi:*")`
|
||||
* so partial words match mid-typing — `joh smi` finds "John Smith".
|
||||
* so partial words match mid-typing - `joh smi` finds "John Smith".
|
||||
* A trigram (`similarity()`) fallback is unioned in for typo tolerance
|
||||
* on names ("Jhon" → "John").
|
||||
* - Identifier fields (mooring numbers, hull/registration, tax IDs,
|
||||
@@ -22,7 +22,7 @@
|
||||
* populated by the i18n PhoneInput pipeline).
|
||||
*
|
||||
* Permissions: the caller passes `isSuperAdmin` + `permissions`. Each
|
||||
* bucket gates itself — viewers don't see invoice/expense rows they
|
||||
* bucket gates itself - viewers don't see invoice/expense rows they
|
||||
* couldn't open. The query for the bucket is skipped entirely (cheaper
|
||||
* than running it and filtering empty results out).
|
||||
*
|
||||
@@ -31,7 +31,7 @@
|
||||
* see other than `portId`. Returned in a separate `otherPorts` field
|
||||
* so the UI can present them as a dimmed secondary section.
|
||||
*
|
||||
* Affinity ranking: callers may pass a `recentlyTouchedIds` Set —
|
||||
* Affinity ranking: callers may pass a `recentlyTouchedIds` Set -
|
||||
* matching rows whose id is in the set get sorted to the top of their
|
||||
* bucket. The id set is derived from the user's last 30 days of
|
||||
* `audit_logs` writes (see `getRecentlyTouchedIds`). This is the cheap
|
||||
@@ -156,7 +156,7 @@ export interface FileResult {
|
||||
id: string;
|
||||
filename: string;
|
||||
category: string | null;
|
||||
/** "client:<name>" | "yacht:<name>" | "company:<name>" — best owner label. */
|
||||
/** "client:<name>" | "yacht:<name>" | "company:<name>" - best owner label. */
|
||||
ownerLabel: string | null;
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ export interface NavResult {
|
||||
* 7-stage equivalents before display.
|
||||
*/
|
||||
export interface StageSuggestionResult {
|
||||
/** Canonical PIPELINE_STAGES value — drives the URL filter. */
|
||||
/** Canonical PIPELINE_STAGES value - drives the URL filter. */
|
||||
stage: string;
|
||||
/** Human-friendly label (STAGE_LABELS[stage]). */
|
||||
label: string;
|
||||
@@ -220,7 +220,7 @@ export interface NoteResult {
|
||||
id: string;
|
||||
/** Trimmed snippet of the matching note content. */
|
||||
snippet: string;
|
||||
/** Source entity type — drives the link target + chip label. */
|
||||
/** Source entity type - drives the link target + chip label. */
|
||||
source: 'client' | 'interest' | 'yacht' | 'company';
|
||||
sourceId: string;
|
||||
/** Friendly label for the source (e.g. "Mary Smith", "B17", "Sea Breeze"). */
|
||||
@@ -277,7 +277,7 @@ export interface SearchOptions {
|
||||
limit?: number;
|
||||
/** When set, only this bucket's query runs (used by /search?type=clients). */
|
||||
type?: keyof Omit<SearchResults, 'totals' | 'otherPorts'>;
|
||||
/** Super-admin only — also search ports the user can access other than `portId`. */
|
||||
/** Super-admin only - also search ports the user can access other than `portId`. */
|
||||
includeOtherPorts?: boolean;
|
||||
/** Set of entity ids the user has recently touched (for affinity boost). */
|
||||
recentlyTouchedIds?: Set<string>;
|
||||
@@ -290,7 +290,7 @@ export interface SearchOptions {
|
||||
* does prefix matching per token. Returns null if no usable token is
|
||||
* present after sanitization.
|
||||
*
|
||||
* Sanitization is critical — `to_tsquery` raises a syntax error on
|
||||
* Sanitization is critical - `to_tsquery` raises a syntax error on
|
||||
* unescaped `&`, `|`, `:`, `!`, `(`, `)` etc., and we don't want a query
|
||||
* for "AT&T" to fail loudly when the user just wants the obvious match.
|
||||
*/
|
||||
@@ -352,7 +352,7 @@ function applyAffinity<T extends { id: string }>(rows: T[], touched?: Set<string
|
||||
* "John" means *your* John, not a stranger.
|
||||
*
|
||||
* Reads from `audit_logs` which records create/update/delete; this misses
|
||||
* pure read-only views, but that's fine — read-only "I just looked at
|
||||
* pure read-only views, but that's fine - read-only "I just looked at
|
||||
* this client" tracking is handled separately by `recently-viewed.service`
|
||||
* (different signal, different surface).
|
||||
*/
|
||||
@@ -392,7 +392,7 @@ export async function getRecentlyTouchedIds(
|
||||
|
||||
const DEFAULT_LIMIT = 5;
|
||||
|
||||
// Safe sentinels so we never bind NULL into to_tsquery/ILIKE — Postgres
|
||||
// Safe sentinels so we never bind NULL into to_tsquery/ILIKE - Postgres
|
||||
// evaluation order is unspecified, so a NULL guard in WHERE may not
|
||||
// reliably short-circuit the function call. These strings are valid in
|
||||
// every context they're used and never realistically match content.
|
||||
@@ -661,12 +661,12 @@ async function searchInterests(
|
||||
|
||||
// Federate: an interest matches if the client name OR the primary
|
||||
// berth's mooring number OR the linked yacht's name matches the query.
|
||||
// This is the relational expansion the user explicitly asked for —
|
||||
// This is the relational expansion the user explicitly asked for -
|
||||
// type "A12" and the linked interests show up alongside the berth.
|
||||
// Two-step query: DISTINCT ON in the inner subquery dedupes the row
|
||||
// explosion from the LEFT JOIN to interest_berths (an interest with
|
||||
// 3 non-primary berths would otherwise produce 3 rows). The outer
|
||||
// SELECT then applies the human-friendly ordering — open-before-closed,
|
||||
// SELECT then applies the human-friendly ordering - open-before-closed,
|
||||
// then by pipeline stage. Done as a wrapping subquery because Postgres
|
||||
// requires DISTINCT-ON's ORDER BY to lead with the DISTINCT key, but
|
||||
// we want the *final* sort to be by outcome.
|
||||
@@ -760,7 +760,7 @@ async function searchResidentialInterests(
|
||||
|
||||
async function searchBerths(portId: string, query: string, limit: number): Promise<BerthResult[]> {
|
||||
// Mooring numbers are short alphanumeric codes (A1, B12, E18) where
|
||||
// prefix-on-number expansion produces confusing UX — typing "A1"
|
||||
// prefix-on-number expansion produces confusing UX - typing "A1"
|
||||
// when A1 exists shouldn't *also* surface A10, A11, A12. Reps know
|
||||
// mooring numbers and almost always type them in full.
|
||||
//
|
||||
@@ -778,7 +778,7 @@ async function searchBerths(portId: string, query: string, limit: number): Promi
|
||||
const ilikePattern = `%${trimmed}%`;
|
||||
const prefixPattern = `${trimmed}%`;
|
||||
|
||||
// First: try for an exact match. Cheap — uses the unique-index on
|
||||
// First: try for an exact match. Cheap - uses the unique-index on
|
||||
// (port_id, mooring_number).
|
||||
const exact = await db.execute<{
|
||||
id: string;
|
||||
@@ -811,7 +811,7 @@ async function searchBerths(portId: string, query: string, limit: number): Promi
|
||||
}));
|
||||
}
|
||||
|
||||
// No exact match — fall back to letter+number-prefix matching plus
|
||||
// No exact match - fall back to letter+number-prefix matching plus
|
||||
// a generic area/ILIKE fallback for non-structured queries.
|
||||
const rows = await db.execute<{
|
||||
id: string;
|
||||
@@ -1125,7 +1125,7 @@ async function searchBrochures(
|
||||
*
|
||||
* Each suggestion carries a live count of non-archived interests in that
|
||||
* stage so the rep sees how many records they're about to filter to.
|
||||
* Costs one COUNT(*) query — a single `GROUP BY` keeps it O(stages).
|
||||
* Costs one COUNT(*) query - a single `GROUP BY` keeps it O(stages).
|
||||
*/
|
||||
async function searchStages(
|
||||
portId: string,
|
||||
@@ -1148,13 +1148,13 @@ async function searchStages(
|
||||
const labelTokens = label.split(/\s+/).filter(Boolean);
|
||||
|
||||
let score = 0;
|
||||
// (1) modern label tokens — each query token must prefix some label token
|
||||
// (1) modern label tokens - each query token must prefix some label token
|
||||
const allModernHit = queryTokens.every(
|
||||
(q) => labelTokens.some((lt) => lt.startsWith(q)) || stage.startsWith(q),
|
||||
);
|
||||
if (allModernHit) score = 100;
|
||||
|
||||
// (2) stage key fragments — substring on the raw enum slug
|
||||
// (2) stage key fragments - substring on the raw enum slug
|
||||
if (score < 50 && stage.includes(normalized.replace(/\s+/g, '_'))) {
|
||||
score = 50;
|
||||
}
|
||||
@@ -1201,7 +1201,7 @@ async function searchStages(
|
||||
stage,
|
||||
label: STAGE_LABELS[stage],
|
||||
count: countByStage.get(stage) ?? 0,
|
||||
// Caller (CommandSearch) prefixes the portSlug — keep this slug-less
|
||||
// Caller (CommandSearch) prefixes the portSlug - keep this slug-less
|
||||
// so the search service stays portSlug-agnostic.
|
||||
href: `/interests?pipelineStage=${stage}`,
|
||||
}));
|
||||
@@ -1249,7 +1249,7 @@ async function searchTags(portId: string, query: string, limit: number): Promise
|
||||
async function searchNotes(portId: string, query: string, limit: number): Promise<NoteResult[]> {
|
||||
const ilikePattern = `%${query}%`;
|
||||
|
||||
// UNION across the four note tables — keeps the result shape uniform
|
||||
// UNION across the four note tables - keeps the result shape uniform
|
||||
// and lets Postgres pick its own join plan per branch. Each branch
|
||||
// resolves its parent label inline:
|
||||
// client → client.full_name
|
||||
@@ -1359,7 +1359,7 @@ async function searchOtherPorts(
|
||||
const ilikePattern = `%${query}%`;
|
||||
const tsQ = buildPrefixTsquery(query);
|
||||
|
||||
// One UNION query touching the high-signal entities only — clients,
|
||||
// One UNION query touching the high-signal entities only - clients,
|
||||
// yachts, companies, interests, berths. Capped tight (limit applies to
|
||||
// the total, not per-bucket) so super-admin cross-port noise stays out
|
||||
// of the way.
|
||||
@@ -1438,7 +1438,7 @@ async function searchOtherPorts(
|
||||
// ─── Public entrypoint ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Graph expansion — for every direct match in a search, fetch the
|
||||
* Graph expansion - for every direct match in a search, fetch the
|
||||
* 1-hop related entities and add them to the appropriate bucket.
|
||||
*
|
||||
* Berth match → its interests + their clients + their yachts
|
||||
@@ -1452,7 +1452,7 @@ async function searchOtherPorts(
|
||||
* carries a `relatedVia` hint so the UI can show "via Berth A10" beneath
|
||||
* the row's title.
|
||||
*
|
||||
* Rows that are already a direct match are NOT duplicated — the dedupe
|
||||
* Rows that are already a direct match are NOT duplicated - the dedupe
|
||||
* runs on `id`. Direct matches always take precedence (their relatedVia
|
||||
* stays unset).
|
||||
*/
|
||||
@@ -1869,7 +1869,7 @@ async function expandGraph(
|
||||
|
||||
/**
|
||||
* Merge direct-match rows with graph-expansion rows. Direct matches
|
||||
* (those without `relatedVia` set) take precedence — if a row appears
|
||||
* (those without `relatedVia` set) take precedence - if a row appears
|
||||
* in both, the direct version wins. Direct matches sort before
|
||||
* related matches.
|
||||
*/
|
||||
@@ -1891,7 +1891,7 @@ function mergeWithExpansion<T extends { id: string; relatedVia?: RelatedVia | nu
|
||||
* unrequested or permission-denied buckets come back as empty arrays so
|
||||
* the UI can render uniformly.
|
||||
*
|
||||
* Per-bucket queries are run in parallel via `Promise.all` — total
|
||||
* Per-bucket queries are run in parallel via `Promise.all` - total
|
||||
* latency is bounded by the single slowest bucket.
|
||||
*
|
||||
* Graph expansion: after the direct-match phase, related entities are
|
||||
@@ -1907,10 +1907,10 @@ export async function search(
|
||||
const empty = emptyResults();
|
||||
if (!query || query.trim().length < 1) return empty;
|
||||
|
||||
// Single-bucket mode (used by /search?type=clients) — skip everything
|
||||
// Single-bucket mode (used by /search?type=clients) - skip everything
|
||||
// else for speed. Graph-expansion buckets (clients, yachts, companies,
|
||||
// interests, berths) fall through to the full pipeline below so that
|
||||
// related-via matches survive the chip narrow — otherwise typing
|
||||
// related-via matches survive the chip narrow - otherwise typing
|
||||
// "carlos vega" with the Yachts chip selected would return zero rows
|
||||
// even though the All chip shows "Yachts (1)" (the yacht owned by
|
||||
// Carlos, surfaced through expandGraph). We trim to the requested
|
||||
@@ -1924,7 +1924,7 @@ export async function search(
|
||||
if (opts.type && !narrowTo) return runSingleBucket(portId, query, limit, opts);
|
||||
|
||||
// We always run the name-bearing buckets even for email/phone-shaped
|
||||
// queries — a client named "test+marketing" is rare but real.
|
||||
// queries - a client named "test+marketing" is rare but real.
|
||||
|
||||
const [
|
||||
clients,
|
||||
@@ -1970,7 +1970,7 @@ export async function search(
|
||||
: Promise.resolve([]),
|
||||
searchTags(portId, query, limit),
|
||||
// Notes search runs whenever the user can read any note-bearing
|
||||
// entity. Reads are gated by the JOINs in searchNotes itself —
|
||||
// entity. Reads are gated by the JOINs in searchNotes itself -
|
||||
// a note's row only surfaces when its parent entity is in this
|
||||
// port. The dropdown UI sticks notes at the bottom (per the
|
||||
// user's "low-noise" preference).
|
||||
@@ -2011,7 +2011,7 @@ export async function search(
|
||||
//
|
||||
// Latency optimization: when every relationship-bearing bucket already
|
||||
// has the maximum number of direct matches the dropdown will render,
|
||||
// graph expansion only adds rows that get truncated downstream — skip
|
||||
// graph expansion only adds rows that get truncated downstream - skip
|
||||
// the (cross-table-heavy) expansion query entirely. Saves the biggest
|
||||
// single SQL call in the search path on common-term queries.
|
||||
const allBucketsFull =
|
||||
@@ -2122,7 +2122,7 @@ export async function search(
|
||||
|
||||
// When narrowing to a graph bucket, zero out every other bucket so the
|
||||
// dropdown only renders the chosen one. Totals for the other buckets
|
||||
// stay populated so the chip row still shows their counts — the client
|
||||
// stay populated so the chip row still shows their counts - the client
|
||||
// already snapshots the last "all" totals separately, but keeping them
|
||||
// here means a direct API hit with ?type=yachts still sees all chip
|
||||
// counts for free.
|
||||
@@ -2347,7 +2347,7 @@ function recentSearchKey(userId: string, portId: string): string {
|
||||
return `recent-search:${userId}:${portId}`;
|
||||
}
|
||||
|
||||
/** Fire-and-forget — saves a search term to the user's recent searches. */
|
||||
/** Fire-and-forget - saves a search term to the user's recent searches. */
|
||||
export function saveRecentSearch(userId: string, portId: string, searchTerm: string): void {
|
||||
const key = recentSearchKey(userId, portId);
|
||||
redis
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function getSetting(key: string, portId: string) {
|
||||
export async function upsertSetting(key: string, value: unknown, portId: string, meta: AuditMeta) {
|
||||
// Read existing first for the audit-log diff (before/after). The actual
|
||||
// write goes through onConflictDoUpdate so two concurrent calls can't
|
||||
// both observe `existing=null` and both INSERT — the (key, port_id)
|
||||
// both observe `existing=null` and both INSERT - the (key, port_id)
|
||||
// unique index now treats NULLs as equal (migration 0047).
|
||||
const existing = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
|
||||
@@ -66,7 +66,7 @@ export async function upsertSetting(key: string, value: unknown, portId: string,
|
||||
// H-06: keys ending with `_encrypted` carry AES-GCM ciphertext that's only
|
||||
// useful with EMAIL_CREDENTIAL_KEY. Recording the ciphertext verbatim in
|
||||
// audit_logs.new_value would turn the audit log (readable by any admin
|
||||
// with `admin.view_audit_log`) into a credential bundle — if the
|
||||
// with `admin.view_audit_log`) into a credential bundle - if the
|
||||
// encryption key is ever rotated/leaked the history exfils every
|
||||
// configured password. Mask the value but keep the audit trail itself
|
||||
// (who toggled what, when).
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Pre-EOI supplemental info form service.
|
||||
*
|
||||
* Three operations:
|
||||
* 1. `issueToken` — rep clicks "Request more info" → token row + email queued.
|
||||
* 2. `loadByToken` — public form fetches prefill data; rejects expired/consumed tokens.
|
||||
* 3. `applySubmission` — public form POST → diff against current data, apply
|
||||
* 1. `issueToken` - rep clicks "Request more info" → token row + email queued.
|
||||
* 2. `loadByToken` - public form fetches prefill data; rejects expired/consumed tokens.
|
||||
* 3. `applySubmission` - public form POST → diff against current data, apply
|
||||
* updates, consume token. All inside one transaction.
|
||||
*/
|
||||
|
||||
@@ -118,7 +118,7 @@ export interface SupplementalTokenHistoryRow {
|
||||
|
||||
/**
|
||||
* Lists supplemental-info-request issuances for an interest, newest first.
|
||||
* Used by the rep-facing "issuance history" surface on the interest page —
|
||||
* Used by the rep-facing "issuance history" surface on the interest page -
|
||||
* shows when each token was generated, whether it's been consumed, and
|
||||
* lets the rep re-send the latest active token without minting a fresh
|
||||
* one.
|
||||
@@ -300,7 +300,7 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
|
||||
|
||||
// Track every field-level override we apply this submission so we
|
||||
// can write them to interest_field_history below in a single batch.
|
||||
// The shape is { fieldPath, oldValue, newValue } — submission_id is
|
||||
// The shape is { fieldPath, oldValue, newValue } - submission_id is
|
||||
// attached after we have a form_submissions row id (if/when that
|
||||
// surface lands; today supplemental-info doesn't materialise a
|
||||
// form_submissions row, so submissionId stays null).
|
||||
@@ -512,8 +512,8 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
|
||||
|
||||
// Write the diff log so the Field history panel on Interest +
|
||||
// Client detail can surface every override the client made via the
|
||||
// form. Empty array means nothing actually changed — the client
|
||||
// confirmed every field as-was — in which case we skip the batch.
|
||||
// form. Empty array means nothing actually changed - the client
|
||||
// confirmed every field as-was - in which case we skip the batch.
|
||||
if (overrides.length > 0) {
|
||||
await tx.insert(interestFieldHistory).values(
|
||||
overrides.map((o) => ({
|
||||
|
||||
@@ -379,7 +379,7 @@ export async function getRecentErrors(limit = 20): Promise<RecentError[]> {
|
||||
.filter((r): r is PromiseFulfilledResult<RecentError[]> => r.status === 'fulfilled')
|
||||
.flatMap((r) => r.value);
|
||||
|
||||
// Captured 5xx requests from the per-request error_events table —
|
||||
// Captured 5xx requests from the per-request error_events table -
|
||||
// this is the deepest source: full stack head + body excerpt + path.
|
||||
// The dedicated /admin/errors page paginates this; here we surface
|
||||
// the most recent for the dashboard.
|
||||
|
||||
@@ -5,7 +5,7 @@ import { env } from '@/lib/env';
|
||||
import { trackedLinks, type NewTrackedLink } from '@/lib/db/schema/tracked-links';
|
||||
|
||||
/**
|
||||
* Phase 4c — service-layer helpers for tracked redirect links. Use
|
||||
* Phase 4c - service-layer helpers for tracked redirect links. Use
|
||||
* `createTrackedLink` from any email-composer flow to wrap an outbound
|
||||
* URL in a `/q/<slug>` short-link that records click-throughs.
|
||||
*
|
||||
@@ -35,7 +35,7 @@ export interface CreateTrackedLinkInput {
|
||||
|
||||
export async function createTrackedLink(input: CreateTrackedLinkInput) {
|
||||
// Retry on slug collision (extremely rare). Three attempts is more
|
||||
// than enough — at our slug entropy a single collision in 1M links
|
||||
// than enough - at our slug entropy a single collision in 1M links
|
||||
// would be a once-per-century event.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const slug = generateSlug();
|
||||
|
||||
@@ -55,7 +55,7 @@ function readSecret(raw: string | null | undefined): string | null {
|
||||
if (!v) return null;
|
||||
// `encrypt()` returns `<iv-hex>:<cipher-hex>:<tag-hex>` (3 colon-separated
|
||||
// hex chunks). If we see that shape, decrypt; otherwise treat as legacy
|
||||
// plaintext. The fallback path is a transition affordance — operators
|
||||
// plaintext. The fallback path is a transition affordance - operators
|
||||
// should re-save the setting via the admin UI, which writes the
|
||||
// encrypted form going forward.
|
||||
const parts = v.split(':');
|
||||
@@ -218,7 +218,7 @@ function pickUnit(range: DateRange): 'hour' | 'day' | 'month' {
|
||||
* `comparison` block carries the equivalent values for the previous
|
||||
* window of the same length (so a 30-day range comes back with the prior
|
||||
* 30 days as `comparison.*`). Verified empirically against Umami v3.1.0
|
||||
* — earlier internal types modelled this as `{value, prev}` per metric,
|
||||
* - earlier internal types modelled this as `{value, prev}` per metric,
|
||||
* which matched neither v2 nor v3 and caused the dashboard tile to read
|
||||
* `pageviews.value` as undefined and render 0.
|
||||
*/
|
||||
@@ -271,7 +271,7 @@ export async function getPageviewsSeries(
|
||||
|
||||
/**
|
||||
* Valid `type` values for `/api/websites/:id/metrics` on Umami v2.x / v3.x.
|
||||
* `path` replaces the old `url` value — sending `type=url` against a v3
|
||||
* `path` replaces the old `url` value - sending `type=url` against a v3
|
||||
* instance returns 400. The full Umami enum also includes `entry|exit|
|
||||
* title|query|region|city|language|screen|hostname|tag|distinctId`; only
|
||||
* the ones the CRM actually surfaces are listed here.
|
||||
@@ -376,7 +376,7 @@ export async function testConnection(
|
||||
|
||||
// ─── Realtime panel ────────────────────────────────────────────────────────
|
||||
//
|
||||
// `/api/realtime/:id` is the richer alternative to `/active` — returns
|
||||
// `/api/realtime/:id` is the richer alternative to `/active` - returns
|
||||
// totals, top URLs being viewed right now, top countries, a 30-min
|
||||
// time-series and a recent-event stream. Used by the realtime dashboard.
|
||||
|
||||
@@ -501,7 +501,7 @@ export async function getSessionActivity(
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions by hour-of-week heatmap — returns a 7×24 nested-array (rows are
|
||||
* Sessions by hour-of-week heatmap - returns a 7×24 nested-array (rows are
|
||||
* days Sun..Sat, columns are hours 0..23). Drives the engagement heatmap
|
||||
* card.
|
||||
*/
|
||||
@@ -521,7 +521,7 @@ export async function getSessionsWeekly(
|
||||
// ─── Events ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Wrappers ready for when the marketing site starts firing `umami.track()`
|
||||
// calls. Until then, every read returns an empty list — wired now so the
|
||||
// calls. Until then, every read returns an empty list - wired now so the
|
||||
// UI surface can light up immediately on the day events start arriving.
|
||||
|
||||
export interface UmamiEvent {
|
||||
@@ -682,7 +682,7 @@ async function getTracker(portId: string): Promise<Umami | null> {
|
||||
* traffic with downstream deal outcomes.
|
||||
*
|
||||
* Soft-fail: if Umami is unreachable or misconfigured the call swallows
|
||||
* the error and logs a warning — outcome events shouldn't fail a CRM
|
||||
* the error and logs a warning - outcome events shouldn't fail a CRM
|
||||
* mutation.
|
||||
*/
|
||||
export async function trackEvent(
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function listUsers(portId: string) {
|
||||
// Two passes:
|
||||
// 1. Users with an explicit user_port_roles row for this port
|
||||
// 2. All super-admins (they have global access via the
|
||||
// userProfiles.isSuperAdmin flag, no per-port row required —
|
||||
// userProfiles.isSuperAdmin flag, no per-port row required -
|
||||
// previous query missed them and the admin list looked empty
|
||||
// to the only super-admin viewing it)
|
||||
const portRoleRows = await db
|
||||
@@ -91,7 +91,7 @@ export async function listUsers(portId: string) {
|
||||
isActive: row.isActive,
|
||||
isSuperAdmin: row.isSuperAdmin,
|
||||
lastLoginAt: row.lastLoginAt,
|
||||
// Synthetic role label — super admins don't have a per-port
|
||||
// Synthetic role label - super admins don't have a per-port
|
||||
// role row, but the UI expects a `role` object. The list
|
||||
// already shows the "Super Admin" badge separately.
|
||||
role: { id: 'super_admin', name: 'super_admin' },
|
||||
@@ -261,14 +261,14 @@ export async function updateUser(
|
||||
// Better Auth's credential provider authenticates by
|
||||
// `account.accountId` (the email captured at sign-up), NOT by
|
||||
// `user.email`. Without this update the user can't sign in with
|
||||
// either address — old fails because user.email no longer matches,
|
||||
// either address - old fails because user.email no longer matches,
|
||||
// new fails because there's no account.accountId row for it.
|
||||
await db
|
||||
.update(account)
|
||||
.set({ accountId: newEmailLower, updatedAt: new Date() })
|
||||
.where(and(eq(account.userId, userId), eq(account.providerId, 'credential')));
|
||||
|
||||
// Revoke every active session — the admin just changed the identity
|
||||
// Revoke every active session - the admin just changed the identity
|
||||
// the user authenticates with, so existing sessions are effectively
|
||||
// orphaned and a security risk if the account is being rotated due
|
||||
// to compromise. The user re-authenticates with the new address.
|
||||
@@ -276,7 +276,7 @@ export async function updateUser(
|
||||
}
|
||||
|
||||
if (wantsEmailChange && previousEmail) {
|
||||
// Best-effort notification — failure to send doesn't roll back the
|
||||
// Best-effort notification - failure to send doesn't roll back the
|
||||
// change because Better Auth's primary identity has already moved.
|
||||
// The user still gets in with the new address; this is just an
|
||||
// outbound courtesy.
|
||||
@@ -301,7 +301,7 @@ export async function updateUser(
|
||||
// assign a role whose effective permission set contains any leaf
|
||||
// the caller doesn't hold. Super admins bypass. When
|
||||
// `callerPermissions` isn't passed (legacy callers / system jobs)
|
||||
// we skip the check — but every interactive API route should pass
|
||||
// we skip the check - but every interactive API route should pass
|
||||
// ctx.permissions + ctx.isSuperAdmin through.
|
||||
if (callerPermissions && !callerIsSuperAdmin) {
|
||||
const newRolePerms = newRole.permissions as Record<string, Record<string, boolean>>;
|
||||
@@ -397,7 +397,7 @@ export async function removeUserFromPort(userId: string, portId: string, meta: A
|
||||
|
||||
/**
|
||||
* Sends the "your admin changed your sign-in email" courtesy notice to
|
||||
* the prior address. Best-effort — failures are logged but don't roll
|
||||
* the prior address. Best-effort - failures are logged but don't roll
|
||||
* back the change; Better Auth has already pointed the account at the
|
||||
* new address by the time this fires.
|
||||
*/
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function dispatchWebhookEvent(
|
||||
})
|
||||
.returning({ id: webhookDeliveries.id });
|
||||
|
||||
// Stable jobId off the delivery row's UUID — the row exists once
|
||||
// Stable jobId off the delivery row's UUID - the row exists once
|
||||
// per (webhook, event-instance) so this naturally dedups a
|
||||
// double-dispatch of the same internal event without blocking
|
||||
// legitimate retries (those re-enqueue from the worker with the
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function listWebhooks(portId: string) {
|
||||
|
||||
export async function getWebhook(portId: string, webhookId: string) {
|
||||
// M-MT05: portId in the WHERE so the row never leaves the DB if it
|
||||
// belongs to a different tenant — the prior JS-side .portId !== portId
|
||||
// belongs to a different tenant - the prior JS-side .portId !== portId
|
||||
// check fired AFTER the row was already loaded, which a future timing-
|
||||
// or audit-side-channel could exploit.
|
||||
const webhook = await db.query.webhooks.findFirst({
|
||||
@@ -134,7 +134,7 @@ export async function updateWebhook(
|
||||
data: UpdateWebhookInput,
|
||||
meta: AuditMeta,
|
||||
) {
|
||||
// M-MT05: portId in WHERE — same reasoning as getWebhook.
|
||||
// M-MT05: portId in WHERE - same reasoning as getWebhook.
|
||||
const existing = await db.query.webhooks.findFirst({
|
||||
where: and(eq(webhooks.id, webhookId), eq(webhooks.portId, portId)),
|
||||
});
|
||||
@@ -172,7 +172,7 @@ export async function updateWebhook(
|
||||
// ─── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteWebhook(portId: string, webhookId: string, meta: AuditMeta) {
|
||||
// M-MT05: portId in WHERE — same reasoning as getWebhook.
|
||||
// M-MT05: portId in WHERE - same reasoning as getWebhook.
|
||||
const existing = await db.query.webhooks.findFirst({
|
||||
where: and(eq(webhooks.id, webhookId), eq(webhooks.portId, portId)),
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function createYacht(portId: string, data: CreateYachtInput, meta:
|
||||
currentOwnerId: data.owner.id,
|
||||
status: data.status ?? 'active',
|
||||
notes: data.notes ?? null,
|
||||
// Phase 3c — origin tracking. Defaults to 'manual' at the DB
|
||||
// Phase 3c - origin tracking. Defaults to 'manual' at the DB
|
||||
// level; pass-through allows the EOI spawn flow to mark the row
|
||||
// as 'eoi-generated' with the generating document_id.
|
||||
source: data.source ?? 'manual',
|
||||
|
||||
Reference in New Issue
Block a user