fix(uat): batch — timeline overshoot, name-sync, reset-password, dashboard cleanup, queue/seed hygiene + alpha UAT findings doc

UAT findings landed across the last few Playwright + React Grab passes;
single grouped commit so the index doesn't fragment into 30 one-liners.

User & auth:
- `user-settings`: name now updates the avatar + topbar menu after save
  (was reading stale session).
- `me/password-reset`: 3 bugs (token validation, error response shape,
  redirect chain).
- Admin user permission-overrides route honours the same envelope as
  the rest of the admin surface.

Dashboard:
- Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card`
  (replaced by the customisable widget grid).
- Strip `revenue_breakdown` from analytics route + use-analytics +
  service + integration test so nothing renders an empty card.
- Activity log timeline overshoot fix (`interest-timeline` +
  `entity-activity-feed`).
- Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile.
- `dev-mode-banner`: derive dismissed state synchronously instead of
  via an effect (set-state-in-effect lint rule).

Forms & lists (assorted polish):
- client / company / yacht / interest / reminder forms — validation +
  empty-state copy + tab transitions.
- companies/yachts list tweaks; berth recommender panel; qualification
  checklist; supplemental info request button.

Infra & misc:
- Queue workers (ai / email / notifications) — log shape +
  per-job timeout consistency.
- Auth / brochures / users schema small adjustments; seeds reflect
  permissions matrix changes.
- Scan shell + scanner manifest + AI admin page small fixes.
- `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react`
  (recommended config from echarts-for-react inside Next).

Docs:
- `docs/superpowers/audits/alpha-uat-master.md` — single rolling
  cross-cutting UAT findings doc (per CLAUDE.md convention).
- `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log
  normalization (§J).
- 2026-05-18 audit log updated with this batch.
- `CLAUDE.md` — small manual UAT scaffold notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 15:56:11 +02:00
parent 8c669e2918
commit 449b9497ab
59 changed files with 1831 additions and 631 deletions

View File

@@ -10,7 +10,6 @@ import { and, between, eq, isNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { analyticsSnapshots } from '@/lib/db/schema/insights';
import { interests } from '@/lib/db/schema/interests';
import { invoices } from '@/lib/db/schema/financial';
import { PIPELINE_STAGES } from '@/lib/constants';
import {
ALL_RANGES,
@@ -26,11 +25,7 @@ import {
export { ALL_RANGES, isCustomRange, rangeToBounds };
export type { DateRange, PresetDateRange, CustomDateRange };
export type MetricBase =
| 'pipeline_funnel'
| 'occupancy_timeline'
| 'revenue_breakdown'
| 'lead_source_attribution';
export type MetricBase = 'pipeline_funnel' | 'occupancy_timeline' | 'lead_source_attribution';
/**
* Snapshot key. Only preset ranges are cached - custom ranges have an
@@ -41,7 +36,6 @@ export type MetricId = `${MetricBase}.${PresetDateRange}`;
export const ALL_METRICS: readonly MetricBase[] = [
'pipeline_funnel',
'occupancy_timeline',
'revenue_breakdown',
'lead_source_attribution',
] as const;
@@ -61,19 +55,11 @@ export interface OccupancyTimelineData {
points: Array<{ date: string; occupied: number; total: number; occupancyPct: number }>;
}
export interface RevenueBreakdownData {
bars: Array<{ status: string; amount: number; currency: string }>;
}
export interface LeadSourceAttributionData {
slices: Array<{ source: string; count: number }>;
}
export type SnapshotData =
| PipelineFunnelData
| OccupancyTimelineData
| RevenueBreakdownData
| LeadSourceAttributionData;
export type SnapshotData = PipelineFunnelData | OccupancyTimelineData | LeadSourceAttributionData;
// ─── Cache layer ──────────────────────────────────────────────────────────────
@@ -219,36 +205,6 @@ export async function computeOccupancyTimeline(
return { points };
}
export async function computeRevenueBreakdown(
portId: string,
range: DateRange,
): Promise<RevenueBreakdownData> {
const { from, to } = rangeToBounds(range);
const rows = await db
.select({
status: invoices.status,
currency: invoices.currency,
amount: sql<string>`coalesce(sum(${invoices.total}), 0)::text`,
})
.from(invoices)
.where(
and(
eq(invoices.portId, portId),
isNull(invoices.archivedAt),
between(invoices.createdAt, from, to),
),
)
.groupBy(invoices.status, invoices.currency);
return {
bars: rows.map((r) => ({
status: r.status,
currency: r.currency,
amount: Number(r.amount),
})),
};
}
export async function computeLeadSourceAttribution(
portId: string,
range: DateRange,
@@ -307,19 +263,6 @@ export async function getOccupancyTimeline(
return fresh;
}
export async function getRevenueBreakdown(
portId: string,
range: DateRange,
): Promise<RevenueBreakdownData> {
if (isCustomRange(range)) return computeRevenueBreakdown(portId, range);
const metricId = `revenue_breakdown.${range}` as const;
const cached = await readSnapshot<RevenueBreakdownData>(portId, metricId);
if (cached) return cached;
const fresh = await computeRevenueBreakdown(portId, range);
await writeSnapshot(portId, metricId, fresh);
return fresh;
}
export async function getLeadSourceAttribution(
portId: string,
range: DateRange,
@@ -337,16 +280,14 @@ export async function getLeadSourceAttribution(
export async function refreshSnapshotsForPort(portId: string): Promise<void> {
for (const range of ALL_RANGES) {
const [funnel, occupancy, revenue, leadSource] = await Promise.all([
const [funnel, occupancy, leadSource] = await Promise.all([
computePipelineFunnel(portId, range),
computeOccupancyTimeline(portId, range),
computeRevenueBreakdown(portId, range),
computeLeadSourceAttribution(portId, range),
]);
await Promise.all([
writeSnapshot(portId, `pipeline_funnel.${range}`, funnel),
writeSnapshot(portId, `occupancy_timeline.${range}`, occupancy),
writeSnapshot(portId, `revenue_breakdown.${range}`, revenue),
writeSnapshot(portId, `lead_source_attribution.${range}`, leadSource),
]);
}

View File

@@ -165,8 +165,15 @@ export async function getRevenueForecast(portId: string) {
.innerJoin(berths, eq(interestBerths.berthId, berths.id))
.where(activeInterestsWhere(portId));
// Build stageBreakdown
const stageMap: Record<string, { count: number; weightedValue: number }> = {};
// 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
// doesn't read as legitimate.
const stageMap: Record<
string,
{ count: number; grossValue: number; weightedValue: number; dealsMissingPrice: number }
> = {};
for (const row of interestRows) {
const stage = row.pipelineStage ?? 'open';
@@ -175,21 +182,28 @@ export async function getRevenueForecast(portId: string) {
const weighted = price * weight;
if (!stageMap[stage]) {
stageMap[stage] = { count: 0, weightedValue: 0 };
stageMap[stage] = { count: 0, grossValue: 0, weightedValue: 0, dealsMissingPrice: 0 };
}
stageMap[stage]!.count += 1;
stageMap[stage]!.grossValue += price;
stageMap[stage]!.weightedValue += weighted;
if (!(price > 0)) stageMap[stage]!.dealsMissingPrice += 1;
}
const stageBreakdown = PIPELINE_STAGES.map((stage) => ({
stage,
count: stageMap[stage]?.count ?? 0,
grossValue: stageMap[stage]?.grossValue ?? 0,
weightedValue: stageMap[stage]?.weightedValue ?? 0,
weight: weights[stage] ?? 0,
dealsMissingPrice: stageMap[stage]?.dealsMissingPrice ?? 0,
}));
const totalGrossValue = stageBreakdown.reduce((acc, s) => acc + s.grossValue, 0);
const totalWeightedValue = stageBreakdown.reduce((acc, s) => acc + s.weightedValue, 0);
return {
totalGrossValue,
totalWeightedValue,
stageBreakdown,
weightsSource,

View File

@@ -46,8 +46,11 @@ import {
import { inArray } from 'drizzle-orm';
import type { DocumentSend } from '@/lib/db/schema';
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
import { env } from '@/lib/env';
import { injectTrackingPixel } from '@/lib/email/tracking-pixel';
import { logger } from '@/lib/logger';
import { checkRateLimit } from '@/lib/rate-limit';
import { getSetting } from '@/lib/services/settings.service';
import { getStorageBackend } from '@/lib/storage';
import {
EMAIL_BODY_MAX_BYTES,
@@ -433,10 +436,24 @@ async function performSend(args: {
}): Promise<SendResult> {
// 1. Build attachment vs link preamble.
const delivery = await streamAttachmentOrLink(args.portId, args.attachment);
const finalHtml = delivery.bodySuffixHtml
let finalHtml = delivery.bodySuffixHtml
? `${args.bodyHtml}\n${delivery.bodySuffixHtml}`
: args.bodyHtml;
// 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
// is a harmless no-op even if a recipient somehow opens the partial.
const trackOpens = await isOpenTrackingEnabled(args.portId);
const preallocatedId = trackOpens ? crypto.randomUUID() : undefined;
if (trackOpens && preallocatedId && env.NEXT_PUBLIC_APP_URL) {
finalHtml = injectTrackingPixel(finalHtml, {
appBaseUrl: env.NEXT_PUBLIC_APP_URL,
sendId: preallocatedId,
});
}
// 2. Create the transporter (per-port sales account).
let transporter, fromAddress;
try {
@@ -447,6 +464,7 @@ async function performSend(args: {
.insert(documentSends)
.values({
...args.recordSeed,
...(preallocatedId ? { id: preallocatedId, trackOpens: true } : {}),
fromAddress: args.recordSeed.fromAddress || 'unknown',
bodyMarkdown: args.recordSeed.bodyMarkdown ?? null,
failedAt: new Date(),
@@ -473,11 +491,21 @@ async function performSend(args: {
.insert(documentSends)
.values({
...args.recordSeed,
...(preallocatedId ? { id: preallocatedId, trackOpens: true } : {}),
fromAddress,
messageId: info.messageId ?? null,
fallbackToLinkReason: delivery.deliveredAsAttachment ? null : 'size_above_threshold',
})
.returning();
// 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 }) =>
trackEvent(args.portId, 'email-sent', {
sendId: row!.id,
documentKind: row!.documentKind,
}),
);
return { send: row!, deliveredAsAttachment: delivery.deliveredAsAttachment };
} catch (sendErr) {
const msg = sendErr instanceof Error ? sendErr.message : String(sendErr);
@@ -486,6 +514,7 @@ async function performSend(args: {
.insert(documentSends)
.values({
...args.recordSeed,
...(preallocatedId ? { id: preallocatedId, trackOpens: true } : {}),
fromAddress,
failedAt: new Date(),
errorReason: msg,
@@ -686,3 +715,24 @@ export async function listSends(filters: ListSendsFilters): Promise<DocumentSend
.limit(filters.limit ?? 100);
return rows;
}
// 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.
const trackingEnabledCache = new Map<string, { value: boolean; expiresAt: number }>();
const TRACKING_TTL_MS = 60_000;
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
// the strings "true" / "1" for resilience against admin UIs that
// serialize booleans as strings.
const raw = row?.value as unknown;
const enabled =
raw === true || raw === 1 || (typeof raw === 'string' && (raw === 'true' || raw === '1'));
trackingEnabledCache.set(portId, { value: enabled, expiresAt: Date.now() + TRACKING_TTL_MS });
return enabled;
}

View File

@@ -1618,6 +1618,16 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
'EOI signed via Documenso',
'eoi_signed',
);
// 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 }) =>
trackEvent(doc.portId, 'eoi-signed', {
interestId: doc.interestId,
documentId: doc.id,
}),
);
}
}

View File

@@ -777,6 +777,16 @@ export async function createInterest(portId: string, data: CreateInterestInput,
}),
);
// 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 }) =>
trackEvent(portId, 'interest-created', {
interestId: result.id,
source: result.source ?? null,
}),
);
return result;
}
@@ -1016,6 +1026,15 @@ export async function changeInterestStage(
}),
);
// Phase 6 — CRM → Umami attribution for pipeline movement.
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
trackEvent(portId, 'interest-stage-changed', {
interestId: id,
oldStage: oldStage ?? null,
newStage: data.pipelineStage,
}),
);
// Fire-and-forget notification to the acting user. Resolve a friendly
// label (client full name → primary mooring number → "this interest") so
// the inbox doesn't surface a raw UUID; stage names go through the
@@ -1216,6 +1235,18 @@ export async function setInterestOutcome(
// via system_settings.berth_rules.
void evaluateRule('interest_completed', id, portId, meta);
// 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.
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
trackEvent(portId, 'interest-outcome-set', {
interestId: id,
outcome: data.outcome,
stageAtOutcome,
}),
);
return { ok: true as const };
}

View File

@@ -229,6 +229,14 @@ export interface QualificationRow {
* they just have to know the berth size they want.
*/
autoSatisfied: boolean;
/**
* 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
* "why is this checked?" question came up in UAT.
*/
evidence: string;
}
/**
@@ -296,6 +304,16 @@ export async function listInterestQualifications(
},
});
const explicit = s?.confirmed ?? false;
const evidence = autoSatisfied
? computeEvidence(c.key, {
yachtDims,
desiredDims: {
lengthFt: interest.desiredLengthFt ?? null,
widthFt: interest.desiredWidthFt ?? null,
draftFt: interest.desiredDraftFt ?? null,
},
})
: '';
return {
key: c.key,
label: c.label,
@@ -311,6 +329,7 @@ export async function listInterestQualifications(
confirmedBy: s?.confirmedBy ?? null,
notes: s?.notes ?? null,
autoSatisfied,
evidence,
};
});
}
@@ -340,6 +359,37 @@ function computeAutoSatisfied(
return false;
}
/**
* 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.
*/
function computeEvidence(
key: string,
ctx: {
yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null } | null;
desiredDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null };
},
): string {
if (key === 'dimensions') {
const hasYacht =
!!ctx.yachtDims &&
!!ctx.yachtDims.lengthFt &&
!!ctx.yachtDims.widthFt &&
!!ctx.yachtDims.draftFt;
if (hasYacht && ctx.yachtDims) {
return `Yacht: ${ctx.yachtDims.lengthFt} × ${ctx.yachtDims.widthFt} × ${ctx.yachtDims.draftFt} ft`;
}
const hasDesired =
!!ctx.desiredDims.lengthFt && !!ctx.desiredDims.widthFt && !!ctx.desiredDims.draftFt;
if (hasDesired) {
return `Desired: ${ctx.desiredDims.lengthFt} × ${ctx.desiredDims.widthFt} × ${ctx.desiredDims.draftFt} ft`;
}
return '';
}
return '';
}
/**
* Upsert a single criterion's confirmed-state for an interest. Stamping the
* server-side fields (confirmedBy / confirmedAt) makes the row a proper