feat(launch-readiness-batch): UAT drains, navigation refactor, launch infra, trackers

Bundles the rest of the in-flight work from this UAT round into one
checkpoint. Each sub-area is independent; see the headings below.

UAT polish (drained 11 findings from active-uat.md):
- Dialog primitive default bumped sm:max-w-xl/lg:max-w-3xl →
  sm:max-w-2xl/lg:max-w-4xl so multi-field forms + PDF previews
  aren't cramped at 1440-1920px.
- Notes tab badge aggregation: new countFor{Client,Yacht,Company}
  Aggregated helpers in notes.service mirror the listFor*Aggregated
  symmetric-reach joins. yacht-tabs + company-tabs render the
  badge; client-tabs already had badge support.
- Supplemental-info form polish bundle: BrandedAuthShell gains a
  `width: 'sm' | 'md'` prop (md uses min-h-dvh scroll instead of
  fixed inset-0 pin so long forms scroll naturally). Form picks up
  port branding (logoUrl + backgroundUrl + appName) via
  loadByToken. Address fields completed (street + city + region +
  postal + country). Port name eyebrow + success-state copy added.
- new-document-menu Upload-file landing toast: per-file completion
  emits toast.success with action link to the destination entity
  or folder.
- interest-tabs OverviewTab "from client" pill on Email + Phone
  rows via new EditableRow `inheritedFrom` prop.
- create-document-wizard subject picker → segmented button strip
  (5 types visible at once).

Launch infra:
- UTM column wiring (Init 1b step 4): migration
  0089_website_submissions_utm.sql adds utm_source/medium/campaign/
  term/content + composite index (port_id, utm_source, received_at)
  for per-campaign rollups. website-inquiries intake accepts the
  five fields. Residential intake intentionally untouched per audit
  scope.
- Invoicing module gate (Init 1c spike): new
  invoices-module.service + invoices layout guard + registry entry
  invoices_module_enabled (default false). Audit conclusion in
  launch-readiness.md: payments table is canonical money path;
  /invoices flow is parallel infrastructure now hidden by default.

Smart-back navigation refactor:
- Replaced breadcrumb component with history-aware Back button.
  New route-labels.ts + use-smart-back hook +
  navigation-history-tracker so back falls through to the parent
  route when there's no prior page in history.
- Sidebar / topbar / mobile-topbar adopt the new pattern; old
  breadcrumb-store kept for back-compat consumers but the
  breadcrumbs component is gone.
- 6 detail pages (admin/errors per-id + codes, invoices/
  upload-receipts, reports kind, tenancies detail, analytics
  metric, client detail) migrated.

Trackers + docs:
- docs/launch-readiness.md — master pre-launch tracker. Includes
  the reports gap audit (cross-cutting filter set, Marketing +
  Financial blockers, custom builder remaining entities, scheduled
  CSV/XLSX, template scope picker).
- docs/superpowers/audits/active-uat.md — 15 findings flipped
  OPEN → SHIPPED locally with fix-applied notes; 4 OPEN remaining
  (each blocked on user input or cross-repo).
- CLAUDE.md — minor session notes carried forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 22:42:37 +02:00
parent 3bdf59e917
commit cb8292464c
62 changed files with 2944 additions and 662 deletions

View File

@@ -57,18 +57,28 @@ export async function resolvePortIdFromSlug(slug: string): Promise<string | null
*/
export async function apiFetch<T = unknown>(url: string, opts: ApiFetchOptions = {}): Promise<T> {
let portId: string | null = null;
// Tracks whether the URL itself named a port slug. When true we MUST
// NOT fall back to the persisted Zustand value — that's the
// cross-port-data-leak bug: a stale `currentPortId` from a prior
// session would send the wrong `X-Port-Id` header and return wrong-
// port data on a fresh refresh of /port-<slug>/...
let urlHadPortSlug = false;
if (typeof window !== 'undefined') {
const slug = window.location.pathname.split('/').filter(Boolean)[0];
if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api' && slug !== 'dashboard') {
urlHadPortSlug = true;
portId = await resolvePortIdFromSlug(slug);
}
}
// Fall back to the Zustand cache when the URL didn't yield a port -
// e.g. global routes (/dashboard) where the rep hasn't picked a port
// yet but a previous session set one.
if (!portId) {
// Fall back to the Zustand cache ONLY when the URL didn't carry a
// port slug at all (e.g. /dashboard / non-tenant routes where the
// rep hasn't picked a port yet but a previous session set one).
// When the URL had a slug but lookup failed, leave portId null — the
// server will reject the request cleanly rather than silently
// serving cross-port data from the stale cache.
if (!portId && !urlHadPortSlug) {
portId = useUIStore.getState().currentPortId;
}

View File

@@ -0,0 +1,22 @@
-- 0089_website_submissions_utm.sql
--
-- Capture UTM attribution columns on website_submissions so the
-- Marketing report (and downstream attribution analysis) can group
-- inquiries by campaign / source / medium without re-parsing the JSON
-- payload on every read.
--
-- All five columns are nullable: UTM presence is opportunistic
-- (driven by whatever the marketing site's tracker plumbed through),
-- not a hard requirement on intake. Index over (port_id, utm_source,
-- received_at) makes "campaign performance for the last 90 days" a
-- single index scan.
ALTER TABLE website_submissions
ADD COLUMN utm_source text,
ADD COLUMN utm_medium text,
ADD COLUMN utm_campaign text,
ADD COLUMN utm_term text,
ADD COLUMN utm_content text;
CREATE INDEX idx_ws_utm_source
ON website_submissions (port_id, utm_source, received_at);

View File

@@ -54,6 +54,15 @@ export const websiteSubmissions = pgTable(
/** Capture-time metadata for debugging. */
sourceIp: text('source_ip'),
userAgent: text('user_agent'),
/** UTM attribution columns. Opportunistic — populated when the
* marketing site's tracker pulled them out of the query string or
* the referrer. Indexed jointly with port_id + received_at via
* migration 0089 for fast per-campaign rollups. */
utmSource: text('utm_source'),
utmMedium: text('utm_medium'),
utmCampaign: text('utm_campaign'),
utmTerm: text('utm_term'),
utmContent: text('utm_content'),
receivedAt: timestamp('received_at', { withTimezone: true }).notNull().defaultNow(),
/** Triage workflow state. Default 'open'; transitions to
* 'converted' (operator created a client/interest from this row),
@@ -70,6 +79,7 @@ export const websiteSubmissions = pgTable(
index('idx_ws_port_received').on(table.portId, table.receivedAt),
index('idx_ws_kind').on(table.kind),
index('idx_ws_triage_state').on(table.portId, table.triageState, table.receivedAt),
index('idx_ws_utm_source').on(table.portId, table.utmSource, table.receivedAt),
],
);

View File

@@ -16,6 +16,8 @@
* function. Templates call `renderShell({ title, body, branding })`.
*/
import type * as React from 'react';
import { absolutizeBrandingUrl } from '@/lib/branding/url';
// Neutral defaults - no tenant-specific imagery leaks across ports.
@@ -96,6 +98,77 @@ export function brandingPrimaryColor(branding?: BrandingShell | null): string {
return branding?.primaryColor ?? DEFAULT_PRIMARY_COLOR;
}
/**
* Shared style conventions for transactional email bodies.
*
* Templates compose these instead of inlining one-off `style={{...}}` objects
* so the visual rhythm stays consistent across every email - centered title
* in the brand accent, body paragraphs left-aligned at 16px / 1.5 line-height,
* centered CTA button, fine-print block separated by a soft divider, centered
* sign-off in the same accent. Modeled on the hand-rolled templates from the
* original portal (signature-notifications.ts) so the look carries forward.
*
* Functions accept an `accent` color (the resolved port primary) where it's
* load-bearing; constants do not.
*/
export const emailStyle = {
/** Page heading: centered, brand-accent, bold. Used once at the top. */
title: (accent: string): React.CSSProperties => ({
textAlign: 'center',
fontSize: '22px',
fontWeight: 'bold',
color: accent,
margin: '0 0 16px 0',
}),
/** Body paragraph: 16px / 1.5 line-height, left-aligned for readability. */
paragraph: {
fontSize: '16px',
lineHeight: '1.5',
margin: '0 0 16px 0',
color: '#333333',
} satisfies React.CSSProperties,
/** Soft hairline divider above fine-print blocks. */
divider: {
border: 'none',
borderTop: '1px solid #eee',
margin: '28px 0 0 0',
} satisfies React.CSSProperties,
/** Fine print: 14px muted, line-height 1.5. */
finePrint: {
fontSize: '14px',
color: '#666666',
lineHeight: '1.5',
margin: '12px 0 0 0',
} satisfies React.CSSProperties,
/** Sign-off block: left-aligned, 16px, sits BETWEEN the last body
* paragraph and the primary CTA so the email reads like a letter
* (greeting -> body -> sign-off -> button -> button-fallback fine
* print). Top margin is intentionally modest because preceding
* paragraphs already carry 16px bottom margin. */
signoff: {
textAlign: 'left',
fontSize: '16px',
color: '#333333',
margin: '8px 0 0 0',
} satisfies React.CSSProperties,
/** Outer wrapper that centers the primary CTA button. */
buttonRow: {
textAlign: 'center',
margin: '28px 0',
} satisfies React.CSSProperties,
/** Primary CTA button style. Compose with `buttonRow` for the surrounding center. */
button: (accent: string): React.CSSProperties => ({
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 35px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}),
} as const;
/**
* URL-safe escaper for `href="..."` interpolations inside email
* templates. The email-deliverability audit flagged that every template

View File

@@ -44,6 +44,11 @@ function AdminEmailChangeBody({
<Text style={{ margin: '20px 0', textAlign: 'center', fontSize: '16px' }}>
<strong>{newEmail}</strong>
</Text>
<Text style={{ fontSize: '16px', marginTop: '8px' }}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
{loginUrl ? (
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
@@ -68,11 +73,6 @@ function AdminEmailChangeBody({
If this change wasn&apos;t expected, please contact your administrator straight away. The
previous address (where this message was delivered) is no longer accepted for sign-in.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
</>
);
}

View File

@@ -1,7 +1,13 @@
import { Button, Hr, Link, Text, render } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
import {
brandingPrimaryColor,
emailStyle,
renderShell,
safeUrl,
type BrandingShell,
} from '@/lib/email/shell';
interface InviteData {
link: string;
@@ -36,34 +42,25 @@ function InviteBody({
const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome aboard,';
return (
<>
<Text style={{ marginBottom: '10px', fontSize: '18px', fontWeight: 'bold', color: accent }}>
Welcome to the {portName} CRM
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
<Text style={emailStyle.title(accent)}>Welcome to the {portName} CRM</Text>
<Text style={emailStyle.paragraph}>{greeting}</Text>
<Text style={emailStyle.paragraph}>
You&apos;ve been invited to join the {portName} CRM as a {role}. Use the button below to set
your password and activate your account at your convenience - the link will remain valid for{' '}
{ttlHours} hours.
</Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(link)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 35px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}}
>
<Text style={emailStyle.signoff}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
<div style={emailStyle.buttonRow}>
<Button href={safeUrl(link)} style={emailStyle.button(accent)}>
Set up your account
</Button>
</div>
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} />
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}>
<Hr style={emailStyle.divider} />
<Text style={emailStyle.finePrint}>
If the button doesn&apos;t work, paste this link into your browser:
<br />
<Link
@@ -73,11 +70,6 @@ function InviteBody({
{link}
</Link>
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
</>
);
}

View File

@@ -81,6 +81,19 @@ function InvitationBody({ data, accent }: { data: InvitationData; accent: string
{data.customMessage}
</Text>
) : null}
<Text style={{ fontSize: '16px', marginTop: '8px' }}>
With warm regards,
<br />
{data.senderName ? (
<>
{data.senderName}
<br />
<strong>The {data.portName} Team</strong>
</>
) : (
<strong>The {data.portName} Team</strong>
)}
</Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(data.signingUrl)}
@@ -113,19 +126,6 @@ function InvitationBody({ data, accent }: { data: InvitationData; accent: string
Signing happens directly inside our website - your data isn&apos;t sent to a third-party
signing service.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<br />
{data.senderName ? (
<>
{data.senderName}
<br />
<strong>The {data.portName} Team</strong>
</>
) : (
<strong>The {data.portName} Team</strong>
)}
</Text>
</>
);
}
@@ -270,6 +270,11 @@ function ReminderBody({ data, accent }: { data: ReminderData; accent: string })
{data.customMessage}
</Text>
) : null}
<Text style={{ fontSize: '16px', marginTop: '8px' }}>
With warm regards,
<br />
<strong>The {data.portName} Team</strong>
</Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(data.signingUrl)}
@@ -297,11 +302,6 @@ function ReminderBody({ data, accent }: { data: ReminderData; accent: string })
{data.signingUrl}
</Link>
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<br />
<strong>The {data.portName} Team</strong>
</Text>
</>
);
}

View File

@@ -2,7 +2,13 @@ import { render } from '@react-email/components';
import { Button, Hr, Link, Text } from '@react-email/components';
import * as React from 'react';
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
import {
brandingPrimaryColor,
emailStyle,
renderShell,
safeUrl,
type BrandingShell,
} from '@/lib/email/shell';
interface ActivationData {
portName: string;
@@ -41,42 +47,26 @@ function ActivationBody({
return (
<>
<Text
style={{
marginBottom: '10px',
fontSize: '18px',
fontWeight: 'bold',
color: accent,
}}
>
Welcome to {portName}
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
<Text style={emailStyle.title(accent)}>Welcome to {portName}</Text>
<Text style={emailStyle.paragraph}>{greeting}</Text>
<Text style={emailStyle.paragraph}>
It&apos;s our pleasure to invite you to the {portName} client portal - your private space to
review your berth, manage signed documents, and stay in touch with your sales liaison. The
button below will let you set a password and activate your account at your convenience.
Please use it within {ttlHours} hours.
</Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(link)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 35px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}}
>
<Text style={emailStyle.signoff}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
<div style={emailStyle.buttonRow}>
<Button href={safeUrl(link)} style={emailStyle.button(accent)}>
Activate account
</Button>
</div>
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} />
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}>
<Hr style={emailStyle.divider} />
<Text style={emailStyle.finePrint}>
If the button doesn&apos;t work, paste this link into your browser:
<br />
<Link
@@ -86,11 +76,6 @@ function ActivationBody({
{link}
</Link>
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
</>
);
}
@@ -106,48 +91,27 @@ function ResetBody({
return (
<>
<Text
style={{
marginBottom: '10px',
fontSize: '18px',
fontWeight: 'bold',
color: accent,
}}
>
Reset your password
</Text>
<Text style={{ marginBottom: '10px', fontSize: '16px', lineHeight: '1.5' }}>{greeting}</Text>
<Text style={{ marginBottom: '20px', fontSize: '16px', lineHeight: '1.5' }}>
<Text style={emailStyle.title(accent)}>Reset your password</Text>
<Text style={emailStyle.paragraph}>{greeting}</Text>
<Text style={emailStyle.paragraph}>
We received a request to reset the password on your {portName} client portal account. Use
the button below to choose a new one - the link will remain valid for {ttlMinutes} minutes.
</Text>
<div style={{ textAlign: 'center', margin: '30px 0' }}>
<Button
href={safeUrl(link)}
style={{
display: 'inline-block',
backgroundColor: accent,
color: '#ffffff',
textDecoration: 'none',
padding: '14px 35px',
borderRadius: '5px',
fontWeight: 'bold',
fontSize: '16px',
}}
>
Reset password
</Button>
</div>
<Hr style={{ border: 'none', borderTop: '1px solid #eee', margin: '20px 0 0' }} />
<Text style={{ fontSize: '14px', color: '#666', lineHeight: '1.5', padding: '15px 0 0' }}>
If you didn&apos;t request this, you may safely ignore this message - your existing password
will continue to work.
</Text>
<Text style={{ fontSize: '16px', marginTop: '30px' }}>
<Text style={emailStyle.signoff}>
With warm regards,
<br />
<strong>The {portName} Team</strong>
</Text>
<div style={emailStyle.buttonRow}>
<Button href={safeUrl(link)} style={emailStyle.button(accent)}>
Reset password
</Button>
</div>
<Hr style={emailStyle.divider} />
<Text style={emailStyle.finePrint}>
If you didn&apos;t request this, you may safely ignore this message - your existing password
will continue to work.
</Text>
</>
);
}

View File

@@ -10,6 +10,7 @@
* adding an entry surfaces it without any UI change.
*/
import type { BrandingShell } from '@/lib/email/shell';
import { activationEmail, resetEmail } from '@/lib/email/templates/portal-auth';
import { crmInviteEmail } from '@/lib/email/templates/crm-invite';
import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change';
@@ -52,6 +53,11 @@ export interface SampleContext {
recipientEmail: string;
portName: string;
portUrl: string;
/** Per-port branding shell (logo, blur background, accent color, header/footer
* HTML). Resolved once by the test-template route via getBrandingShell and
* forwarded into every template so previews match the production look.
* Null is acceptable - templates fall back to neutral defaults. */
branding: BrandingShell | null;
}
export const TEST_TEMPLATES: TestTemplateMeta[] = [
@@ -60,185 +66,224 @@ export const TEST_TEMPLATES: TestTemplateMeta[] = [
label: 'Portal · Activation invite',
description: 'Fires when an admin invites a client to activate their portal account.',
render: (s) =>
activationEmail({
recipientName: s.recipientName,
portName: s.portName,
link: `${s.portUrl}/portal/activate/sample-token`,
ttlHours: 24,
}),
activationEmail(
{
recipientName: s.recipientName,
portName: s.portName,
link: `${s.portUrl}/portal/activate/sample-token`,
ttlHours: 24,
},
{ branding: s.branding },
),
},
{
id: 'portal_reset',
label: 'Portal · Password reset',
description: 'Fires when a portal user requests a password reset link.',
render: (s) =>
resetEmail({
recipientName: s.recipientName,
portName: s.portName,
link: `${s.portUrl}/portal/reset/sample-token`,
ttlMinutes: 120,
}),
resetEmail(
{
recipientName: s.recipientName,
portName: s.portName,
link: `${s.portUrl}/portal/reset/sample-token`,
ttlMinutes: 120,
},
{ branding: s.branding },
),
},
{
id: 'crm_invite',
label: 'CRM · Teammate invitation',
description: 'Fires when a super-admin invites a new teammate to the CRM.',
render: (s) =>
crmInviteEmail({
recipientName: s.recipientName,
portName: s.portName,
isSuperAdmin: false,
link: `${s.portUrl}/invite/sample-token`,
ttlHours: 72,
}),
crmInviteEmail(
{
recipientName: s.recipientName,
portName: s.portName,
isSuperAdmin: false,
link: `${s.portUrl}/invite/sample-token`,
ttlHours: 72,
},
{ branding: s.branding },
),
},
{
id: 'admin_email_change',
label: 'CRM · Admin email change confirmation',
description: 'Fires when an admin updates their CRM login email - confirmation step.',
render: (s) =>
adminEmailChangeEmail({
recipientName: s.recipientName,
portName: s.portName,
newEmail: s.recipientEmail,
changedByDisplayName: 'Sample Admin',
loginUrl: `${s.portUrl}/login`,
}),
adminEmailChangeEmail(
{
recipientName: s.recipientName,
portName: s.portName,
newEmail: s.recipientEmail,
changedByDisplayName: 'Sample Admin',
loginUrl: `${s.portUrl}/login`,
},
{ branding: s.branding },
),
},
{
id: 'notification_digest',
label: 'Reminders · Notification digest',
description: 'Fires on the configured cadence (daily/weekly) with the reps open reminders.',
render: (s) =>
notificationDigestEmail({
recipientName: s.recipientName,
portName: s.portName,
items: [
{
type: 'reminder',
title: 'Follow up with Matthew Ciaccio on Berth A1',
description: 'Reservation EOI sent 5 days ago - no response yet.',
link: `${s.portUrl}/clients/sample-client-id`,
createdAt: new Date(Date.now() - 86_400_000),
},
{
type: 'alert',
title: 'Berth B12 PDF parse failed',
description: null,
link: `${s.portUrl}/berths/sample-berth-id`,
createdAt: new Date(Date.now() - 2 * 86_400_000),
},
],
totalUnread: 2,
inboxLink: `${s.portUrl}/inbox`,
}),
notificationDigestEmail(
{
recipientName: s.recipientName,
portName: s.portName,
items: [
{
type: 'reminder',
title: 'Follow up with Matthew Ciaccio on Berth A1',
description: 'Reservation EOI sent 5 days ago - no response yet.',
link: `${s.portUrl}/clients/sample-client-id`,
createdAt: new Date(Date.now() - 86_400_000),
},
{
type: 'alert',
title: 'Berth B12 PDF parse failed',
description: null,
link: `${s.portUrl}/berths/sample-berth-id`,
createdAt: new Date(Date.now() - 2 * 86_400_000),
},
],
totalUnread: 2,
inboxLink: `${s.portUrl}/inbox`,
},
{ branding: s.branding },
),
},
{
id: 'signing_invitation',
label: 'Documenso · Signing invitation',
description: 'Fires when the rep dispatches the first signing-invite email for a doc.',
render: (s) =>
signingInvitationEmail({
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
signerRole: 'client',
signingUrl: `${s.portUrl}/sign/sample-token`,
senderName: 'Sample Sales Manager',
customMessage: null,
}),
signingInvitationEmail(
{
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
signerRole: 'client',
signingUrl: `${s.portUrl}/sign/sample-token`,
senderName: 'Sample Sales Manager',
customMessage: null,
},
{ branding: s.branding },
),
},
{
id: 'signing_reminder',
label: 'Documenso · Signing reminder',
description: 'Fires when a manual reminder is dispatched for an outstanding signer.',
render: (s) =>
signingReminderEmail({
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
signingUrl: `${s.portUrl}/sign/sample-token`,
invitedAgo: '5 days ago',
customMessage: null,
}),
signingReminderEmail(
{
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
signingUrl: `${s.portUrl}/sign/sample-token`,
invitedAgo: '5 days ago',
customMessage: null,
},
{ branding: s.branding },
),
},
{
id: 'signing_completed',
label: 'Documenso · Fully signed notification',
description: 'Fires when every required signer has signed and the document is complete.',
render: (s) =>
signingCompletedEmail({
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
clientName: s.recipientName,
completedAt: new Date(),
}),
signingCompletedEmail(
{
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
clientName: s.recipientName,
completedAt: new Date(),
},
{ branding: s.branding },
),
},
{
id: 'signing_cancelled',
label: 'Documenso · Signing cancelled',
description: 'Fires when the rep cancels a document mid-signature with notify-recipients.',
render: (s) =>
signingCancelledEmail({
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
reason: 'Customer renegotiated terms; a fresh contract will follow.',
}),
signingCancelledEmail(
{
recipientName: s.recipientName,
portName: s.portName,
documentLabel: 'Sales Contract',
reason: 'Customer renegotiated terms; a fresh contract will follow.',
},
{ branding: s.branding },
),
},
{
id: 'inquiry_client_confirmation',
label: 'Public inquiry · Client confirmation',
description: 'Fires when a public-site visitor submits the contact form (their copy).',
render: (s) =>
inquiryClientConfirmation({
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
mooringNumber: 'A1',
contactEmail: 'sales@portnimara.com',
portName: s.portName,
}),
inquiryClientConfirmation(
{
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
mooringNumber: 'A1',
contactEmail: 'sales@portnimara.com',
portName: s.portName,
},
{ branding: s.branding },
),
},
{
id: 'inquiry_sales_notification',
label: 'Public inquiry · Sales notification',
description: 'Fires alongside the client confirmation - alerts the sales rep to a new lead.',
render: (s) =>
inquirySalesNotification({
fullName: s.recipientName,
email: s.recipientEmail,
phone: '+1 555 0100',
mooringNumber: 'A1',
crmUrl: `${s.portUrl}/clients/sample-client-id`,
portName: s.portName,
}),
inquirySalesNotification(
{
fullName: s.recipientName,
email: s.recipientEmail,
phone: '+1 555 0100',
mooringNumber: 'A1',
crmUrl: `${s.portUrl}/clients/sample-client-id`,
portName: s.portName,
},
{ branding: s.branding },
),
},
{
id: 'residential_client_confirmation',
label: 'Residential inquiry · Client confirmation',
description: 'Fires when a residential-site visitor submits the contact form.',
render: (s) =>
residentialClientConfirmation({
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
contactEmail: 'sales@portnimara.com',
portName: s.portName,
}),
residentialClientConfirmation(
{
firstName: s.recipientName.split(' ')[0] ?? s.recipientName,
contactEmail: 'sales@portnimara.com',
portName: s.portName,
},
{ branding: s.branding },
),
},
{
id: 'residential_sales_alert',
label: 'Residential inquiry · Sales alert',
description: 'Fires alongside the residential client confirmation - alerts the sales team.',
render: (s) =>
residentialSalesAlert({
fullName: s.recipientName,
email: s.recipientEmail,
phone: '+1 555 0100',
placeOfResidence: 'Monaco',
preferredContactMethod: 'email',
notes: 'Looking for year-round mooring + marina apartment access.',
crmDeepLink: `${s.portUrl}/residential/clients/sample-id`,
portName: s.portName,
}),
residentialSalesAlert(
{
fullName: s.recipientName,
email: s.recipientEmail,
phone: '+1 555 0100',
placeOfResidence: 'Monaco',
preferredContactMethod: 'email',
notes: 'Looking for year-round mooring + marina apartment access.',
crmDeepLink: `${s.portUrl}/residential/clients/sample-id`,
portName: s.portName,
},
{ branding: s.branding },
),
},
];

51
src/lib/route-labels.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Canonical human-readable labels for URL path segments. Used by the smart
* back button to derive a sensible "Back to <X>" label from the URL when a
* detail page hasn't registered an explicit back-context hint, and by the
* mobile topbar's title fallback.
*
* Add new top-level routes here so the back button doesn't fall through to
* a slugified guess (e.g. "berths" -> "Berths" works automatically via
* `formatSegment`, but "audit" would render as "Audit" instead of "Audit Log"
* without the explicit entry).
*/
export const SEGMENT_LABELS: Record<string, string> = {
dashboard: 'Dashboard',
clients: 'Clients',
yachts: 'Yachts',
companies: 'Companies',
interests: 'Interests',
berths: 'Berths',
documents: 'Documents',
files: 'Files',
expenses: 'Expenses',
invoices: 'Invoices',
email: 'Email',
inbox: 'Inbox',
reminders: 'Reminders',
alerts: 'Alerts',
settings: 'Settings',
admin: 'Administration',
reports: 'Reports',
tenancies: 'Tenancies',
residential: 'Residential',
new: 'New',
edit: 'Edit',
profile: 'Profile',
notifications: 'Notifications',
'website-analytics': 'Website Analytics',
};
/** UUID v4-ish (or any 36-char hex+dash) - used to skip entity-id segments
* from URL-derived labels since they're never human-readable. */
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isIdSegment(segment: string): boolean {
return UUID_RE.test(segment);
}
export function formatSegment(segment: string): string {
return (
SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
);
}

View File

@@ -4,7 +4,6 @@ import { db } from '@/lib/db';
import {
clients,
clientContacts,
clientNotes,
clientRelationships,
clientTags,
clientAddresses,
@@ -445,10 +444,12 @@ export async function getClientById(id: string, portId: string) {
.where(
and(eq(interests.portId, portId), eq(interests.clientId, id), isNull(interests.archivedAt)),
);
const [noteCountRow] = await db
.select({ count: count() })
.from(clientNotes)
.where(eq(clientNotes.clientId, id));
// Aggregated note count — matches what `NotesList` renders below
// (direct client notes + interest_notes + yacht_notes for owned
// yachts + company_notes for active memberships). Bare clientNotes
// count would understate when the rep adds notes to linked entities.
const { countForClientAggregated } = await import('@/lib/services/notes.service');
const aggregatedNoteCount = await countForClientAggregated(portId, id);
return {
...client,
@@ -459,7 +460,7 @@ export async function getClientById(id: string, portId: string) {
companies: membershipRows,
activeTenancies,
interestCount: interestCountRow?.count ?? 0,
noteCount: noteCountRow?.count ?? 0,
noteCount: aggregatedNoteCount,
clientPortalEnabled: portalEnabled,
};
}

View File

@@ -126,10 +126,17 @@ export async function getCompanyById(id: string, portId: string) {
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
});
// Aggregated note count for the Notes tab badge. Symmetric-reach via
// owned yachts + their linked interests (member-client personal
// notes intentionally excluded — they belong on the client dossier).
const { countForCompanyAggregated } = await import('@/lib/services/notes.service');
const noteCount = await countForCompanyAggregated(portId, id).catch(() => 0);
return {
...rest,
tags: tagJoins.map((t) => t.tag),
addresses,
noteCount,
};
}

View File

@@ -149,13 +149,26 @@ export async function captureErrorEvent(args: CaptureArgs): Promise<void> {
// 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.
//
// Drizzle wraps postgres errors and rethrows with the failed SQL as
// the visible `message`, so the actual reason (e.g. "column does not
// exist") is on `cause.message`. Capture cause.message + cause.detail
// + cause.hint into metadata so the inspector list view can surface
// the real fault instead of just the prepared statement.
const enriched: Record<string, unknown> = { ...(args.metadata ?? {}) };
if (err && typeof err === 'object') {
const e = err as { code?: unknown; severity?: unknown; cause?: { code?: unknown } };
const e = err as {
code?: unknown;
severity?: unknown;
cause?: { code?: unknown; message?: unknown; detail?: unknown; hint?: unknown };
};
if (typeof e.code === 'string') enriched.code = e.code;
if (typeof e.severity === 'string') enriched.severity = e.severity;
if (e.cause && typeof e.cause === 'object' && typeof e.cause.code === 'string') {
enriched.causeCode = e.cause.code;
if (e.cause && typeof e.cause === 'object') {
if (typeof e.cause.code === 'string') enriched.causeCode = e.cause.code;
if (typeof e.cause.message === 'string') enriched.causeMessage = e.cause.message;
if (typeof e.cause.detail === 'string') enriched.causeDetail = e.cause.detail;
if (typeof e.cause.hint === 'string') enriched.causeHint = e.cause.hint;
}
}

View File

@@ -0,0 +1,49 @@
/**
* Expenses module gate. Port-scoped on/off switch for the entire expense
* + receipt-upload surface (sidebar entries, /expenses routes, mobile
* scanner, receipt-upload explainer).
*
* Defaults to ENABLED so existing ports keep the feature on deploy.
* When an admin turns it off:
* - the sidebar entries (Expenses + How to upload receipts) disappear
* via the port-resolved expensesModuleByPort prop on the layout
* - the expenses routes render a "Module disabled" page instead of
* the real content, so bookmarks land somewhere meaningful and the
* operator can re-enable from one click
* - previously-recorded expense rows are preserved (no destructive
* cleanup) so re-enabling restores everything
*/
import { and, eq, isNull, or } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
/**
* Resolve whether the Expenses module is currently active for the given
* port. Reads from `system_settings.expenses_module_enabled` (port-
* scoped row first, then global row, then registry default = true).
*
* Defaulting to enabled mirrors how the feature behaved before the
* toggle existed: deploying this change to a port that has never
* configured the setting leaves the feature visible.
*/
export async function isExpensesModuleEnabled(portId: string): Promise<boolean> {
const settingRow = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(
and(
eq(systemSettings.key, 'expenses_module_enabled'),
or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)),
),
)
.limit(1);
// Stored JSONB shape is the raw boolean (`true` / `false`); no
// unwrapping needed because the admin-settings PUT handler writes the
// primitive directly.
if (settingRow[0]?.value === false) return false;
// Any value other than an explicit `false` (incl. missing row, true,
// unrecognized shape) means enabled - matches the registry default.
return true;
}

View File

@@ -0,0 +1,47 @@
/**
* Invoices module gate. Port-scoped on/off switch for the standalone
* `/invoices` flow.
*
* Audit conclusion (2026-05-27, launch-readiness Initiative 1c): the
* `invoices` schema is rich (invoices + invoice_line_items +
* invoice_expenses + send + payment + PDF) but the dev DB has zero rows
* and no rep ever clicks through. The canonical "money received" path
* is the per-interest Payments tab (records into `payments` and auto-
* advances pipeline). The standalone /invoices flow is parallel
* infrastructure for employee expense reports + the rare case where a
* port operator wants to invoice a client directly from the CRM.
*
* Defaults to DISABLED so new ports launch with a clean surface; admins
* can opt in from Admin → Operations. Existing ports keep the legacy
* surface visible until explicitly turned off.
*
* Behaviour when disabled:
* - the (already-removed) sidebar entry stays hidden
* - the /invoices and /invoices/new and /invoices/[id] routes render a
* "Module disabled" page instead of the full form
* - the API endpoints (`/api/v1/invoices/*`) still respond so any
* historical PDF links / webhook callbacks keep resolving
* - existing invoice rows are preserved
*/
import { and, eq, isNull, or } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
export async function isInvoicesModuleEnabled(portId: string): Promise<boolean> {
const settingRow = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(
and(
eq(systemSettings.key, 'invoices_module_enabled'),
or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)),
),
)
.limit(1);
// Stored JSONB shape is the raw boolean. The registry default is `false`,
// so a missing row → disabled. Anything other than an explicit `true`
// keeps the module hidden.
return settingRow[0]?.value === true;
}

View File

@@ -1,10 +1,10 @@
import { eq, and, desc, inArray } from 'drizzle-orm';
import { eq, and, desc, inArray, sql, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clientNotes, clients } from '@/lib/db/schema/clients';
import { interestNotes, interests } from '@/lib/db/schema/interests';
import { yachtNotes, yachts } from '@/lib/db/schema/yachts';
import { companyNotes, companies } from '@/lib/db/schema/companies';
import { companyNotes, companies, companyMemberships } from '@/lib/db/schema/companies';
import {
residentialClients,
residentialClientNotes,
@@ -111,6 +111,218 @@ export interface AggregatedClientNote {
pipelineStageAtCreation?: string | null;
}
// ─── Aggregated counts ──────────────────────────────────────────────────────
//
// Mirror the symmetric-reach unions used by the `listFor*Aggregated`
// helpers, but return scalar totals so tab badges on entity detail
// pages match what the NotesList renders below them. Each function is
// port-scoped (defense-in-depth) and tolerates zero linked-entity ids
// by short-circuiting the relevant counts to 0.
async function scalarCount(query: Promise<Array<{ count: number }>>): Promise<number> {
const rows = await query;
return rows[0]?.count ?? 0;
}
/**
* Total note count visible on a client's Notes tab = direct
* client_notes + interest_notes (interests where client_id=X) +
* yacht_notes (yachts currently owned by this client) +
* company_notes (companies the client has an active membership in).
*/
export async function countForClientAggregated(portId: string, clientId: string): Promise<number> {
await verifyParentBelongsToPort('clients', clientId, portId);
const [interestRows, yachtRows, membershipRows] = await Promise.all([
db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))),
db
.select({ id: yachts.id })
.from(yachts)
.where(
and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'client'),
eq(yachts.currentOwnerId, clientId),
),
),
db
.select({ companyId: companyMemberships.companyId })
.from(companyMemberships)
.innerJoin(companies, eq(companies.id, companyMemberships.companyId))
.where(
and(
eq(companyMemberships.clientId, clientId),
isNull(companyMemberships.endDate),
eq(companies.portId, portId),
),
),
]);
const interestIds = interestRows.map((r) => r.id);
const yachtIds = yachtRows.map((r) => r.id);
const companyIds = Array.from(new Set(membershipRows.map((r) => r.companyId)));
const [clientCount, interestCount, yachtCount, companyCount] = await Promise.all([
scalarCount(
db
.select({ count: sql<number>`count(*)::int` })
.from(clientNotes)
.where(eq(clientNotes.clientId, clientId)),
),
interestIds.length > 0
? scalarCount(
db
.select({ count: sql<number>`count(*)::int` })
.from(interestNotes)
.where(inArray(interestNotes.interestId, interestIds)),
)
: Promise.resolve(0),
yachtIds.length > 0
? scalarCount(
db
.select({ count: sql<number>`count(*)::int` })
.from(yachtNotes)
.where(inArray(yachtNotes.yachtId, yachtIds)),
)
: Promise.resolve(0),
companyIds.length > 0
? scalarCount(
db
.select({ count: sql<number>`count(*)::int` })
.from(companyNotes)
.where(inArray(companyNotes.companyId, companyIds)),
)
: Promise.resolve(0),
]);
return clientCount + interestCount + yachtCount + companyCount;
}
/**
* Total note count visible on a yacht's Notes tab = direct
* yacht_notes + the polymorphic owner-side notes (client_notes when
* owner_type='client', company_notes when owner_type='company') +
* interest_notes (interests currently linked to this yacht).
*/
export async function countForYachtAggregated(portId: string, yachtId: string): Promise<number> {
await verifyParentBelongsToPort('yachts', yachtId, portId);
const [yacht] = await db
.select({
id: yachts.id,
ownerType: yachts.currentOwnerType,
ownerId: yachts.currentOwnerId,
})
.from(yachts)
.where(and(eq(yachts.id, yachtId), eq(yachts.portId, portId)))
.limit(1);
if (!yacht) throw new NotFoundError('Yacht');
const interestRows = await db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.yachtId, yachtId), eq(interests.portId, portId)));
const interestIds = interestRows.map((r) => r.id);
const [yachtCount, ownerCount, interestCount] = await Promise.all([
scalarCount(
db
.select({ count: sql<number>`count(*)::int` })
.from(yachtNotes)
.where(eq(yachtNotes.yachtId, yachtId)),
),
yacht.ownerType === 'client' && yacht.ownerId
? scalarCount(
db
.select({ count: sql<number>`count(*)::int` })
.from(clientNotes)
.where(eq(clientNotes.clientId, yacht.ownerId)),
)
: yacht.ownerType === 'company' && yacht.ownerId
? scalarCount(
db
.select({ count: sql<number>`count(*)::int` })
.from(companyNotes)
.where(eq(companyNotes.companyId, yacht.ownerId)),
)
: Promise.resolve(0),
interestIds.length > 0
? scalarCount(
db
.select({ count: sql<number>`count(*)::int` })
.from(interestNotes)
.where(inArray(interestNotes.interestId, interestIds)),
)
: Promise.resolve(0),
]);
return yachtCount + ownerCount + interestCount;
}
/**
* Total note count visible on a company's Notes tab = direct
* company_notes + yacht_notes (yachts owned by this company) +
* interest_notes (interests linked via those yachts). Member-client
* personal notes are NOT counted — they live on the client's dossier.
*/
export async function countForCompanyAggregated(
portId: string,
companyId: string,
): Promise<number> {
await verifyParentBelongsToPort('companies', companyId, portId);
const yachtRows = await db
.select({ id: yachts.id })
.from(yachts)
.where(
and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'company'),
eq(yachts.currentOwnerId, companyId),
),
);
const yachtIds = yachtRows.map((r) => r.id);
const interestRows =
yachtIds.length > 0
? await db
.select({ id: interests.id })
.from(interests)
.where(and(inArray(interests.yachtId, yachtIds), eq(interests.portId, portId)))
: [];
const interestIds = interestRows.map((r) => r.id);
const [companyCount, yachtCount, interestCount] = await Promise.all([
scalarCount(
db
.select({ count: sql<number>`count(*)::int` })
.from(companyNotes)
.where(eq(companyNotes.companyId, companyId)),
),
yachtIds.length > 0
? scalarCount(
db
.select({ count: sql<number>`count(*)::int` })
.from(yachtNotes)
.where(inArray(yachtNotes.yachtId, yachtIds)),
)
: Promise.resolve(0),
interestIds.length > 0
? scalarCount(
db
.select({ count: sql<number>`count(*)::int` })
.from(interestNotes)
.where(inArray(interestNotes.interestId, interestIds)),
)
: Promise.resolve(0),
]);
return companyCount + yachtCount + interestCount;
}
export async function listForClientAggregated(
portId: string,
clientId: string,

View File

@@ -218,8 +218,23 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
label: 'System Settings',
category: 'admin',
keywords: [
'feature flags',
'feature flag',
'client portal',
'client portal enabled',
'tenancies',
'tenancies module',
'tenancy',
'tenancy tracker',
'lease',
'lease windows',
'renewals',
'transfers',
'expenses',
'expenses module',
'receipts',
'expense receipts',
'ai',
'ai interest scoring',
'ai email drafts',
'invoice net10 discount',

View File

@@ -20,8 +20,10 @@ import {
yachts,
clientContacts,
interestFieldHistory,
ports,
} from '@/lib/db/schema';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { getPortBrandingConfig } from '@/lib/services/port-config';
const TOKEN_TTL_DAYS = 14;
const TOKEN_BYTES = 32; // 256-bit → ~43 base64url chars; brute-force infeasible.
@@ -79,10 +81,19 @@ export async function issueToken(input: IssueTokenInput): Promise<{
export interface PrefillData {
/** Token metadata so the form can disable itself when consumed. */
token: { expiresAt: string; consumed: boolean };
/** Port branding + name. Surfaces both as a header (so the recipient
* knows which marina is asking) and as logo / backdrop in the
* shared BrandedAuthShell. */
port: {
name: string;
logoUrl: string | null;
backgroundUrl: string | null;
};
client: {
fullName: string;
streetAddress: string | null;
city: string | null;
subdivisionIso: string | null;
postalCode: string | null;
country: string | null;
primaryEmail: string | null;
@@ -223,15 +234,29 @@ export async function loadByToken(token: string): Promise<PrefillData | null> {
where: and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)),
});
const [port, branding] = await Promise.all([
db.query.ports.findFirst({ where: eq(ports.id, row.portId), columns: { name: true } }),
getPortBrandingConfig(row.portId).catch(() => ({
logoUrl: null,
emailBackgroundUrl: null,
})),
]);
return {
token: {
expiresAt: row.expiresAt.toISOString(),
consumed: !!row.consumedAt,
},
port: {
name: port?.name ?? 'Port Nimara',
logoUrl: branding.logoUrl ?? null,
backgroundUrl: branding.emailBackgroundUrl ?? null,
},
client: {
fullName: client.fullName,
streetAddress: primaryAddress?.streetAddress ?? null,
city: primaryAddress?.city ?? null,
subdivisionIso: primaryAddress?.subdivisionIso ?? null,
postalCode: primaryAddress?.postalCode ?? null,
country: primaryAddress?.countryIso ?? null,
primaryEmail: emailContact?.value ?? null,
@@ -258,7 +283,13 @@ export async function loadByToken(token: string): Promise<PrefillData | null> {
export interface SubmissionInput {
fullName: string;
/** Street address (single line — multi-line entries go into this
* same field as `\n`-joined text). */
address: string | null;
city: string | null;
/** ISO-3166-2 subdivision code (e.g. 'PL-MZ', 'US-CA'). */
subdivisionIso: string | null;
postalCode: string | null;
country: string | null;
email: string | null;
phoneE164: string | null;
@@ -324,7 +355,9 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
.where(eq(clients.id, client.id));
}
if (input.address || input.country) {
const hasAnyAddressInput =
input.address || input.city || input.subdivisionIso || input.postalCode || input.country;
if (hasAnyAddressInput) {
const existingAddr = await tx.query.clientAddresses.findFirst({
where: and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)),
});
@@ -334,43 +367,43 @@ export async function applySubmission(token: string, input: SubmissionInput): Pr
portId: row.portId,
label: 'Primary',
streetAddress: input.address ?? null,
city: input.city ?? null,
subdivisionIso: input.subdivisionIso ?? null,
postalCode: input.postalCode ?? null,
countryIso: input.country ?? null,
isPrimary: true,
});
// Insert-path: every populated field is a "from null → value"
// override so the history panel surfaces the initial population
// the same way it surfaces later edits.
if (input.address) {
overrides.push({
fieldPath: 'client.address.streetAddress',
oldValue: null,
newValue: input.address,
});
}
if (input.country) {
overrides.push({
fieldPath: 'client.address.countryIso',
oldValue: null,
newValue: input.country,
});
const insertOverrides: Array<[string, unknown]> = [
['client.address.streetAddress', input.address],
['client.address.city', input.city],
['client.address.subdivisionIso', input.subdivisionIso],
['client.address.postalCode', input.postalCode],
['client.address.countryIso', input.country],
];
for (const [fieldPath, value] of insertOverrides) {
if (value) overrides.push({ fieldPath, oldValue: null, newValue: value });
}
} else {
const addrPatch: Record<string, unknown> = {};
if (input.address && input.address !== existingAddr.streetAddress) {
addrPatch.streetAddress = input.address;
overrides.push({
fieldPath: 'client.address.streetAddress',
oldValue: existingAddr.streetAddress,
newValue: input.address,
});
}
if (input.country && input.country !== existingAddr.countryIso) {
addrPatch.countryIso = input.country;
overrides.push({
fieldPath: 'client.address.countryIso',
oldValue: existingAddr.countryIso,
newValue: input.country,
});
const updateFields: Array<[string, string | null, string | null | undefined]> = [
['streetAddress', existingAddr.streetAddress, input.address],
['city', existingAddr.city, input.city],
['subdivisionIso', existingAddr.subdivisionIso, input.subdivisionIso],
['postalCode', existingAddr.postalCode, input.postalCode],
['countryIso', existingAddr.countryIso, input.country],
];
for (const [col, oldVal, newVal] of updateFields) {
if (newVal && newVal !== oldVal) {
addrPatch[col] = newVal;
overrides.push({
fieldPath: `client.address.${col}`,
oldValue: oldVal,
newValue: newVal,
});
}
}
if (Object.keys(addrPatch).length > 0) {
await tx

View File

@@ -34,6 +34,13 @@ import { NotFoundError } from '@/lib/errors';
*/
export async function isTenanciesModuleEnabled(portId: string): Promise<boolean> {
// 1. Admin setting check (port-scoped row first, fall back to global).
// Precedence: an EXPLICIT admin choice always wins. If the admin has
// set the toggle to true, the module is on. If they've set it to
// false, the module is off - even if tenancy rows exist for the
// port. This matches the toggle's label ("Tenancies module - off")
// matching what reps see in the sidebar; the previous behaviour of
// silently re-enabling whenever any row existed was confusing and
// contradicted the toggle's own description.
const settingRow = await db
.select({ value: systemSettings.value })
.from(systemSettings)
@@ -44,11 +51,15 @@ export async function isTenanciesModuleEnabled(portId: string): Promise<boolean>
),
)
.limit(1);
if (settingRow[0]?.value === true) return true;
const stored = settingRow[0]?.value;
if (stored === true) return true;
if (stored === false) return false;
// 2. Lazy auto-enable: any row in the table flips the module on for
// the rest of the app, even when the admin setting is still false.
// Once any port has a tenancy, the module's UX is justified.
// 2. No explicit admin choice yet: lazy auto-enable on first row. Once
// a port has at least one tenancy, the module's UX is justified and
// we surface it without making the admin toggle it manually. The
// admin can still flip it off afterwards via the toggle (which
// writes false and short-circuits this branch above).
const rowCheck = await db
.select({ id: berthTenancies.id })
.from(berthTenancies)

View File

@@ -114,9 +114,16 @@ export async function getYachtById(id: string, portId: string) {
const { tags: tagJoins, ...rest } = yacht as typeof yacht & {
tags: Array<{ tag: { id: string; name: string; color: string } }>;
};
// Aggregated note count for the Notes tab badge. Mirrors the
// symmetric-reach used by the NotesList that renders below it.
const { countForYachtAggregated } = await import('@/lib/services/notes.service');
const noteCount = await countForYachtAggregated(portId, id).catch(() => 0);
return {
...rest,
tags: tagJoins.map((t) => t.tag),
noteCount,
};
}

View File

@@ -622,6 +622,46 @@ export const REGISTRY: SettingEntry[] = [
defaultValue: false,
},
// ─── Operations - Expenses module ─────────────────────────────────────────
// Port-scoped gate for the entire Expenses + receipt-upload surface.
// Defaults to enabled so existing ports keep the feature on deploy.
// Disabling hides both sidebar entries (Expenses + How to upload
// receipts) AND swaps the routes for a "Module disabled" placeholder so
// bookmarks land on a meaningful page (not a 404) and direct API hits
// are rejected at the layout boundary.
{
key: 'expenses_module_enabled',
section: 'operations.expenses',
label: 'Expenses module',
description:
'When enabled, reps can record expenses and upload receipts (mobile scanner + manual entry). Turning this off hides Expenses + receipt-upload from the sidebar and blocks the routes with a "module disabled" page. Disabling does not delete previously-recorded expense rows.',
type: 'boolean',
scope: 'port',
defaultValue: true,
},
// ─── Operations - Invoices module ─────────────────────────────────────────
// Port-scoped gate for the standalone `/invoices` flow. Audit conclusion
// (2026-05-27, Initiative 1c): the schema is rich (invoices + invoice_line_items
// + invoice_expenses + send/payment routes + PDF) but the dev DB has zero
// rows. The canonical "money received" path goes via `payments`
// (auto-advances pipeline) and the canonical expense-report path goes via
// `expenses → invoices` only for the employee-expense use case. The sidebar
// nav entry was removed earlier; this toggle hides the route too so
// bookmarks land on a clear "module disabled" page instead of an orphaned
// form. Default OFF for new ports; existing ports keep the surface visible
// until an admin explicitly turns it off.
{
key: 'invoices_module_enabled',
section: 'operations.invoices',
label: 'Standalone invoicing module',
description:
'When enabled, the standalone /invoices flow (create invoice → line items → PDF → send → mark paid) is reachable. The canonical "we received money" path in this CRM goes through the Payments tab on an interest (auto-advances pipeline); the standalone invoicing surface is a separate flow primarily for employee expense reports. Disabling hides /invoices entirely (route renders a "module disabled" page); existing rows are preserved.',
type: 'boolean',
scope: 'port',
defaultValue: false,
},
// ─── Residential - partner forwarding ──────────────────────────────────────
{
key: 'residential_partner_recipients',

View File

@@ -174,7 +174,14 @@ export async function resolveSettings(
const out = new Map<string, ResolvedRaw>();
await Promise.all(
keys.map(async (k) => {
out.set(k, await resolveSettingWithSource(k, portId));
try {
out.set(k, await resolveSettingWithSource(k, portId));
} catch {
// Unknown registry key — common when a feature stores settings via
// its own dedicated route (e.g. branding) and a batch caller asks
// for them by key. Skipping keeps the rest of the batch usable;
// single-key callers via getSetting() still fail loud.
}
}),
);
return out;