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

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