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:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.
//

View File

@@ -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), {

View File

@@ -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.

View File

@@ -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, ''));

View File

@@ -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(

View File

@@ -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

View File

@@ -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);

View File

@@ -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,

View File

@@ -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.
*/

View File

@@ -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');

View File

@@ -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,

View File

@@ -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[] = [];

View File

@@ -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)

View File

@@ -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');

View File

@@ -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.

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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),

View File

@@ -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',
);
}
}

View File

@@ -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;
}

View File

@@ -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',
};

View File

@@ -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

View File

@@ -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')) {

View File

@@ -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 };

View File

@@ -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: [

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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 [];
}
}

View File

@@ -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).

View File

@@ -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(/\/+$/, '');

View File

@@ -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({

View File

@@ -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;

View File

@@ -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> {

View File

@@ -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);

View File

@@ -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

View File

@@ -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({

View File

@@ -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);

View File

@@ -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).

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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

View File

@@ -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');
}
}

View File

@@ -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 ~510x 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);

View File

@@ -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 = [

View File

@@ -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.
*/

View File

@@ -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';

View File

@@ -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,

View File

@@ -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({

View File

@@ -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(

View File

@@ -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.
*/

View File

@@ -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,

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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)

View File

@@ -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

View File

@@ -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({

View File

@@ -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',

View File

@@ -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)))

View File

@@ -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({

View File

@@ -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.

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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');

View File

@@ -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);
}

View File

@@ -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.',
);
}

View File

@@ -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.

View File

@@ -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(

View File

@@ -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.
*/

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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.
*

View File

@@ -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,

View File

@@ -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();

View File

@@ -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

View File

@@ -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).

View File

@@ -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) => ({

View File

@@ -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.

View File

@@ -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();

View File

@@ -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(

View File

@@ -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.
*/

View File

@@ -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

View File

@@ -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)),
});

View File

@@ -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',