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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user