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:
@@ -77,14 +77,40 @@ function buildAuth() {
|
||||
// through the shared SMTP infra so EMAIL_REDIRECT_TO honours it
|
||||
// in dev.
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
const { sendEmail } = await import('@/lib/email');
|
||||
const subject = 'Reset your Port Nimara CRM password';
|
||||
const html = `
|
||||
<p>Hi ${user.name || 'there'},</p>
|
||||
<p>You requested a password reset for your Port Nimara CRM account.</p>
|
||||
<p><a href="${url}">Click here to set a new password</a> — the link expires in 1 hour.</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`;
|
||||
const [{ sendEmail }, { renderShell, safeUrl }, { resolveAuthShellBranding }] =
|
||||
await Promise.all([
|
||||
import('@/lib/email'),
|
||||
import('@/lib/email/shell'),
|
||||
import('@/lib/email/auth-shell-branding'),
|
||||
]);
|
||||
|
||||
const branding = await resolveAuthShellBranding();
|
||||
const appName = branding?.appName ?? 'CRM';
|
||||
const subject = `Reset your ${appName} password`;
|
||||
const safeName = (user.name || 'there').replace(/[<>&]/g, '');
|
||||
const body = `
|
||||
<p style="margin-bottom:16px;">Hi ${safeName},</p>
|
||||
<p style="margin-bottom:16px;">You requested a password reset for your ${appName} account.</p>
|
||||
<p style="margin-bottom:16px;">
|
||||
<a href="${safeUrl(url)}" style="color:#2563eb;font-weight:600;">Click here to set a new password</a>
|
||||
— the link expires in 1 hour.
|
||||
</p>
|
||||
<p style="color:#64748b;">If you didn't request this, you can safely ignore this email.</p>
|
||||
`;
|
||||
|
||||
const html = renderShell({
|
||||
title: subject,
|
||||
body,
|
||||
branding: branding
|
||||
? {
|
||||
logoUrl: branding.logoUrl,
|
||||
backgroundUrl: branding.backgroundUrl,
|
||||
primaryColor: null,
|
||||
emailHeaderHtml: null,
|
||||
emailFooterHtml: null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const text = `Reset your password: ${url}`;
|
||||
await sendEmail(user.email, subject, html, undefined, text);
|
||||
},
|
||||
|
||||
@@ -150,6 +150,13 @@ export const documentSends = pgTable(
|
||||
bounceStatus: text('bounce_status'), // 'hard' | 'soft' | 'ooo'
|
||||
bounceReason: text('bounce_reason'),
|
||||
bounceDetectedAt: timestamp('bounce_detected_at', { withTimezone: true }),
|
||||
// Phase 4b — email open tracking. When `trackOpens` is true the send
|
||||
// includes a 1×1 pixel pointing at /api/public/email-pixel/[sendId].
|
||||
// `firstOpenedAt` + `openCount` are denormalised aggregates so the
|
||||
// sends list can render an "opened" pill without a JOIN.
|
||||
trackOpens: boolean('track_opens').notNull().default(false),
|
||||
firstOpenedAt: timestamp('first_opened_at', { withTimezone: true }),
|
||||
openCount: integer('open_count').notNull().default(0),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_ds_client').on(t.clientId, t.sentAt),
|
||||
@@ -164,9 +171,40 @@ export const documentSends = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-open log for emails with `trackOpens=true`. The 1×1 pixel
|
||||
* endpoint inserts here on every fetch (Apple Mail privacy proxy will
|
||||
* over-count; most other clients under-count when images are blocked —
|
||||
* this is the universal email-tracking caveat). Cached aggregates on
|
||||
* `document_sends` keep list rendering fast.
|
||||
*/
|
||||
export const documentSendOpens = pgTable(
|
||||
'document_send_opens',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
sendId: text('send_id')
|
||||
.notNull()
|
||||
.references(() => documentSends.id, { onDelete: 'cascade' }),
|
||||
openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
userAgent: text('user_agent'),
|
||||
referer: text('referer'),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_dso_send').on(t.sendId, t.openedAt),
|
||||
index('idx_dso_port').on(t.portId, t.openedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export type Brochure = typeof brochures.$inferSelect;
|
||||
export type NewBrochure = typeof brochures.$inferInsert;
|
||||
export type BrochureVersion = typeof brochureVersions.$inferSelect;
|
||||
export type NewBrochureVersion = typeof brochureVersions.$inferInsert;
|
||||
export type DocumentSend = typeof documentSends.$inferSelect;
|
||||
export type NewDocumentSend = typeof documentSends.$inferInsert;
|
||||
export type DocumentSendOpen = typeof documentSendOpens.$inferSelect;
|
||||
export type NewDocumentSendOpen = typeof documentSendOpens.$inferInsert;
|
||||
|
||||
@@ -31,6 +31,15 @@ export type RolePermissions = {
|
||||
edit: boolean;
|
||||
import: boolean;
|
||||
manage_waiting_list: boolean;
|
||||
/**
|
||||
* Update berth `price` / `priceCurrency` via the dedicated single
|
||||
* (`PATCH /api/v1/berths/[id]/price`) and bulk
|
||||
* (`POST /api/v1/berths/bulk-update-prices`) endpoints. Carved out
|
||||
* from generic `berths.edit` so admins can grant sales reps the
|
||||
* ability to retune prices without exposing the full berth-edit
|
||||
* surface (dimensions, mooring type, etc.). Always audited.
|
||||
*/
|
||||
update_prices: boolean;
|
||||
};
|
||||
documents: {
|
||||
view: boolean;
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
* per-port fixture builders.
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from './index';
|
||||
import { ports } from './schema/ports';
|
||||
import { roles, userProfiles } from './schema/users';
|
||||
import { systemSettings } from './schema/system';
|
||||
import {
|
||||
ALL_PERMISSIONS,
|
||||
DIRECTOR_PERMISSIONS,
|
||||
@@ -28,12 +29,21 @@ export interface BootstrappedPort {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface PortBrandingSeed {
|
||||
logoUrl?: string;
|
||||
emailBackgroundUrl?: string;
|
||||
appName?: string;
|
||||
emailHeaderHtml?: string;
|
||||
emailFooterHtml?: string;
|
||||
}
|
||||
|
||||
export const PORT_DEFINITIONS: Array<{
|
||||
name: string;
|
||||
slug: string;
|
||||
primaryColor: string;
|
||||
defaultCurrency: string;
|
||||
timezone: string;
|
||||
branding?: PortBrandingSeed;
|
||||
}> = [
|
||||
{
|
||||
name: 'Port Nimara',
|
||||
@@ -41,6 +51,12 @@ export const PORT_DEFINITIONS: Array<{
|
||||
primaryColor: '#0F4C81',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Anguilla',
|
||||
branding: {
|
||||
logoUrl:
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png',
|
||||
emailBackgroundUrl: 'https://s3.portnimara.com/images/Overhead_1_blur.png',
|
||||
appName: 'Port Nimara CRM',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Port Amador',
|
||||
@@ -48,6 +64,8 @@ export const PORT_DEFINITIONS: Array<{
|
||||
primaryColor: '#D97706',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Panama',
|
||||
// Branding intentionally left empty — admin uploads their own assets
|
||||
// via /admin/branding rather than inheriting Port Nimara's look.
|
||||
},
|
||||
];
|
||||
|
||||
@@ -75,16 +93,51 @@ export async function seedBootstrap(): Promise<BootstrappedPort[]> {
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
let portId: string | null = null;
|
||||
if (inserted) {
|
||||
console.log(` Port created: ${def.name} (${inserted.id})`);
|
||||
portIds.push({ id: inserted.id, name: def.name, slug: def.slug });
|
||||
portId = inserted.id;
|
||||
} else {
|
||||
const [existing] = await db.select().from(ports).where(eq(ports.slug, def.slug)).limit(1);
|
||||
if (existing) {
|
||||
console.log(` Port exists: ${def.name} (${existing.id})`);
|
||||
portIds.push({ id: existing.id, name: def.name, slug: def.slug });
|
||||
portId = existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Seed branding settings idempotently. `onConflictDoNothing` so an
|
||||
// admin who's tweaked the values via /admin/branding doesn't get
|
||||
// their work overwritten by a re-seed.
|
||||
if (portId && def.branding) {
|
||||
const brandingPairs: Array<[string, unknown]> = [];
|
||||
if (def.branding.logoUrl) brandingPairs.push(['branding_logo_url', def.branding.logoUrl]);
|
||||
if (def.branding.emailBackgroundUrl)
|
||||
brandingPairs.push(['branding_email_background_url', def.branding.emailBackgroundUrl]);
|
||||
if (def.branding.appName) brandingPairs.push(['branding_app_name', def.branding.appName]);
|
||||
if (def.branding.emailHeaderHtml)
|
||||
brandingPairs.push(['branding_email_header_html', def.branding.emailHeaderHtml]);
|
||||
if (def.branding.emailFooterHtml)
|
||||
brandingPairs.push(['branding_email_footer_html', def.branding.emailFooterHtml]);
|
||||
brandingPairs.push(['branding_primary_color', def.primaryColor]);
|
||||
|
||||
for (const [key, value] of brandingPairs) {
|
||||
// Skip when an existing row is already present — preserves admin
|
||||
// edits across re-seeds. Pair (key, portId) is uniquely indexed.
|
||||
const existing = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
|
||||
});
|
||||
if (existing) continue;
|
||||
await db.insert(systemSettings).values({
|
||||
key,
|
||||
value: value as Record<string, unknown>,
|
||||
portId,
|
||||
updatedBy: SUPER_ADMIN_USER_ID,
|
||||
});
|
||||
}
|
||||
console.log(` Branding seeded for ${def.name} (${brandingPairs.length} keys)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── System roles ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ALL_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -102,7 +102,7 @@ export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -181,7 +181,7 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -260,7 +260,7 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -339,7 +339,13 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
|
||||
berths: {
|
||||
view: true,
|
||||
edit: false,
|
||||
import: false,
|
||||
manage_waiting_list: false,
|
||||
update_prices: false,
|
||||
},
|
||||
documents: {
|
||||
view: true,
|
||||
create: false,
|
||||
@@ -421,7 +427,13 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
|
||||
berths: {
|
||||
view: false,
|
||||
edit: false,
|
||||
import: false,
|
||||
manage_waiting_list: false,
|
||||
update_prices: false,
|
||||
},
|
||||
documents: {
|
||||
view: false,
|
||||
create: false,
|
||||
|
||||
@@ -92,6 +92,12 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
||||
const primaryBerth = await getPrimaryBerth(interestId);
|
||||
const berthMooring = primaryBerth?.mooringNumber ?? null;
|
||||
|
||||
// Resolve the port branding app name once so template-fallback drafts
|
||||
// sign off as "{Port} Team" instead of leaking another tenant's name.
|
||||
const { getPortBrandingConfig } = await import('@/lib/services/port-config');
|
||||
const portBrand = await getPortBrandingConfig(portId).catch(() => null);
|
||||
const brandingAppName = portBrand?.appName?.trim() || 'our marina';
|
||||
|
||||
// Fetch last 5 notes
|
||||
const recentNotes = await db
|
||||
.select({ content: interestNotes.content, createdAt: interestNotes.createdAt })
|
||||
@@ -117,6 +123,7 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
||||
context,
|
||||
berthMooring,
|
||||
pipelineStage: interest.pipelineStage,
|
||||
portName: brandingAppName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -253,6 +260,7 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
||||
context,
|
||||
berthMooring,
|
||||
pipelineStage: interest.pipelineStage,
|
||||
portName: brandingAppName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -266,26 +274,28 @@ function buildTemplateDraft(opts: {
|
||||
context: string;
|
||||
berthMooring: string | null;
|
||||
pipelineStage: string;
|
||||
portName: string;
|
||||
}): DraftResult {
|
||||
const { clientName, context, berthMooring, pipelineStage } = opts;
|
||||
const { clientName, context, berthMooring, pipelineStage, portName } = opts;
|
||||
const berthText = berthMooring ? `berth ${berthMooring}` : 'your requested berth';
|
||||
const signoff = `Kind regards,\n${portName} Team`;
|
||||
|
||||
const templates: Record<string, { subject: string; body: string }> = {
|
||||
introduction: {
|
||||
subject: `Welcome to Port Nimara – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nThank you for your interest in Port Nimara. We are delighted to introduce our marina facilities and look forward to discussing how we can accommodate your needs for ${berthText}.\n\nPlease feel free to reach out at any time.\n\nKind regards,\nPort Nimara Team`,
|
||||
subject: `Welcome to ${portName} – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nThank you for your interest in ${portName}. We are delighted to introduce our marina facilities and look forward to discussing how we can accommodate your needs for ${berthText}.\n\nPlease feel free to reach out at any time.\n\n${signoff}`,
|
||||
},
|
||||
follow_up: {
|
||||
subject: `Following up – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nI wanted to follow up regarding your interest in ${berthText}. Please let us know if you have any questions or if there is anything we can assist you with.\n\nWe look forward to hearing from you.\n\nKind regards,\nPort Nimara Team`,
|
||||
body: `Dear ${clientName},\n\nI wanted to follow up regarding your interest in ${berthText}. Please let us know if you have any questions or if there is anything we can assist you with.\n\nWe look forward to hearing from you.\n\n${signoff}`,
|
||||
},
|
||||
stage_update: {
|
||||
subject: `Update on your application – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nWe are pleased to inform you that your application for ${berthText} has progressed to the "${stageLabel(pipelineStage)}" stage.\n\nWe will be in touch shortly with the next steps.\n\nKind regards,\nPort Nimara Team`,
|
||||
body: `Dear ${clientName},\n\nWe are pleased to inform you that your application for ${berthText} has progressed to the "${stageLabel(pipelineStage)}" stage.\n\nWe will be in touch shortly with the next steps.\n\n${signoff}`,
|
||||
},
|
||||
general: {
|
||||
subject: `Message from Port Nimara – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nThank you for your continued interest in Port Nimara. We appreciate your patience and look forward to assisting you with ${berthText}.\n\nKind regards,\nPort Nimara Team`,
|
||||
subject: `Message from ${portName} – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nThank you for your continued interest in ${portName}. We appreciate your patience and look forward to assisting you with ${berthText}.\n\n${signoff}`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export const emailWorker = new Worker(
|
||||
portId,
|
||||
fallback: email.subject,
|
||||
tokens: {
|
||||
portName: portName ?? 'Port Nimara',
|
||||
portName: portName ?? 'the marina',
|
||||
recipientName: firstName,
|
||||
mooringNumber: mooringNumber ?? '',
|
||||
},
|
||||
@@ -83,7 +83,7 @@ export const emailWorker = new Worker(
|
||||
portId,
|
||||
fallback: notification.subject,
|
||||
tokens: {
|
||||
portName: portName ?? 'Port Nimara',
|
||||
portName: portName ?? 'the marina',
|
||||
clientName: fullName,
|
||||
mooringNumber: mooringNumber ?? '',
|
||||
email,
|
||||
|
||||
@@ -83,9 +83,18 @@ export const notificationsWorker = new Worker(
|
||||
const linkHtml = notif.link
|
||||
? `<p><a href="${safeUrl(`${process.env.APP_URL ?? ''}${notif.link}`)}">View in CRM</a></p>`
|
||||
: '';
|
||||
|
||||
// Subject prefix = port branding `appName` so multi-tenant
|
||||
// deploys read "[Port Amador]"/"[Other Marina]" instead of
|
||||
// a hardcoded "[Port Nimara]".
|
||||
const { getPortBrandingConfig } = await import('@/lib/services/port-config');
|
||||
const portBrand = notif.portId
|
||||
? await getPortBrandingConfig(notif.portId).catch(() => null)
|
||||
: null;
|
||||
const prefix = portBrand?.appName?.trim() || 'CRM';
|
||||
await sendEmail(
|
||||
authUser.email,
|
||||
`[Port Nimara] ${notif.title}`,
|
||||
`[${prefix}] ${notif.title}`,
|
||||
`<p>${bodyText}</p>${linkHtml}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user