diff --git a/src/app/api/v1/companies/[id]/notes/route.ts b/src/app/api/v1/companies/[id]/notes/route.ts
index 882f1b1e..fff2adf4 100644
--- a/src/app/api/v1/companies/[id]/notes/route.ts
+++ b/src/app/api/v1/companies/[id]/notes/route.ts
@@ -8,11 +8,14 @@ import { createNoteSchema } from '@/lib/validators/notes';
import * as notesService from '@/lib/services/notes.service';
export const GET = withAuth(
- withPermission('companies', 'view', async (_req, ctx, params) => {
+ withPermission('companies', 'view', async (req, ctx, params) => {
try {
const companyId = params.id;
if (!companyId) throw new NotFoundError('Company');
- const notes = await notesService.listForEntity(ctx.portId, 'companies', companyId);
+ const aggregate = new URL(req.url).searchParams.get('aggregate') === 'true';
+ const notes = aggregate
+ ? await notesService.listForCompanyAggregated(ctx.portId, companyId)
+ : await notesService.listForEntity(ctx.portId, 'companies', companyId);
return NextResponse.json({ data: notes });
} catch (error) {
return errorResponse(error);
diff --git a/src/app/api/v1/residential/clients/[id]/notes/route.ts b/src/app/api/v1/residential/clients/[id]/notes/route.ts
index 0309611d..c6a420e5 100644
--- a/src/app/api/v1/residential/clients/[id]/notes/route.ts
+++ b/src/app/api/v1/residential/clients/[id]/notes/route.ts
@@ -7,11 +7,14 @@ import * as notesService from '@/lib/services/notes.service';
import { errorResponse, NotFoundError } from '@/lib/errors';
export const GET = withAuth(
- withPermission('residential_clients', 'view', async (_req, ctx, params) => {
+ withPermission('residential_clients', 'view', async (req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new NotFoundError('Residential client');
- const notes = await notesService.listForEntity(ctx.portId, 'residential_clients', id);
+ const aggregate = new URL(req.url).searchParams.get('aggregate') === 'true';
+ const notes = aggregate
+ ? await notesService.listForResidentialClientAggregated(ctx.portId, id)
+ : await notesService.listForEntity(ctx.portId, 'residential_clients', id);
return NextResponse.json({ data: notes });
} catch (error) {
return errorResponse(error);
diff --git a/src/app/api/v1/yachts/[id]/notes/route.ts b/src/app/api/v1/yachts/[id]/notes/route.ts
index 9265eff1..9730e9c1 100644
--- a/src/app/api/v1/yachts/[id]/notes/route.ts
+++ b/src/app/api/v1/yachts/[id]/notes/route.ts
@@ -8,11 +8,14 @@ import { createNoteSchema } from '@/lib/validators/notes';
import * as notesService from '@/lib/services/notes.service';
export const GET = withAuth(
- withPermission('yachts', 'view', async (_req, ctx, params) => {
+ withPermission('yachts', 'view', async (req, ctx, params) => {
try {
const yachtId = params.id;
if (!yachtId) throw new NotFoundError('Yacht');
- const notes = await notesService.listForEntity(ctx.portId, 'yachts', yachtId);
+ const aggregate = new URL(req.url).searchParams.get('aggregate') === 'true';
+ const notes = aggregate
+ ? await notesService.listForYachtAggregated(ctx.portId, yachtId)
+ : await notesService.listForEntity(ctx.portId, 'yachts', yachtId);
return NextResponse.json({ data: notes });
} catch (error) {
return errorResponse(error);
diff --git a/src/components/companies/company-tabs.tsx b/src/components/companies/company-tabs.tsx
index 037e7fea..4cb8063d 100644
--- a/src/components/companies/company-tabs.tsx
+++ b/src/components/companies/company-tabs.tsx
@@ -169,17 +169,6 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
- {/* Notes */}
-
-
Notes
-
-
-
{/* Tags */}
Tags
@@ -236,7 +225,12 @@ export function getCompanyTabs({
id: 'notes',
label: 'Notes',
content: (
-
+
),
},
{
diff --git a/src/components/residential/residential-client-tabs.tsx b/src/components/residential/residential-client-tabs.tsx
index b9705dca..e90b370b 100644
--- a/src/components/residential/residential-client-tabs.tsx
+++ b/src/components/residential/residential-client-tabs.tsx
@@ -120,6 +120,7 @@ export function getResidentialClientTabs({
entityType="residential_clients"
entityId={clientId}
currentUserId={currentUserId}
+ aggregate
/>
),
},
diff --git a/src/components/residential/residential-interest-tabs.tsx b/src/components/residential/residential-interest-tabs.tsx
index 2f581226..b57c2395 100644
--- a/src/components/residential/residential-interest-tabs.tsx
+++ b/src/components/residential/residential-interest-tabs.tsx
@@ -156,14 +156,6 @@ function OverviewTab({
onSave={save('preferences')}
/>
-
-
-
diff --git a/src/components/shared/notes-list.tsx b/src/components/shared/notes-list.tsx
index c724a460..e76e446b 100644
--- a/src/components/shared/notes-list.tsx
+++ b/src/components/shared/notes-list.tsx
@@ -10,6 +10,14 @@ import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { apiFetch } from '@/lib/api/client';
+type NoteSource =
+ | 'client'
+ | 'interest'
+ | 'yacht'
+ | 'company'
+ | 'residential_client'
+ | 'residential_interest';
+
interface Note {
id: string;
content: string;
@@ -19,26 +27,72 @@ interface Note {
createdAt: string;
updatedAt: string;
/** Aggregated-mode only: which child entity this note came from. */
- source?: 'client' | 'interest' | 'yacht';
+ source?: NoteSource;
sourceId?: string;
sourceLabel?: string;
}
+type NotesEntityType =
+ | 'clients'
+ | 'interests'
+ | 'yachts'
+ | 'companies'
+ | 'residential_clients'
+ | 'residential_interests';
+
+/** Maps the entity-type the list is rendered for to the `source` value
+ * the aggregator uses when a note came from THAT entity itself
+ * (vs. a related entity). Used to decide whether a note is editable
+ * in-place or read-only with an "Open source" affordance. */
+const SELF_SOURCE: Record = {
+ clients: 'client',
+ yachts: 'yacht',
+ companies: 'company',
+ residential_clients: 'residential_client',
+ // Aggregate-mode is only meaningful for the entities above. Interests
+ // and residential_interests are leaf nodes — there's nothing to roll
+ // up to them.
+ interests: null,
+ residential_interests: null,
+};
+
+const AGGREGATABLE: ReadonlySet = new Set([
+ 'clients',
+ 'yachts',
+ 'companies',
+ 'residential_clients',
+]);
+
+const SOURCE_BADGE_CLASS: Record = {
+ client: 'bg-violet-100 text-violet-900',
+ interest: 'bg-blue-100 text-blue-900',
+ yacht: 'bg-emerald-100 text-emerald-900',
+ company: 'bg-amber-100 text-amber-900',
+ residential_client: 'bg-violet-100 text-violet-900',
+ residential_interest: 'bg-blue-100 text-blue-900',
+};
+
+const SOURCE_LABEL: Record = {
+ client: 'Client',
+ interest: 'Interest',
+ yacht: 'Yacht',
+ company: 'Company',
+ residential_client: 'Resident',
+ residential_interest: 'Inquiry',
+};
+
interface NotesListProps {
- entityType:
- | 'clients'
- | 'interests'
- | 'yachts'
- | 'companies'
- | 'residential_clients'
- | 'residential_interests';
+ entityType: NotesEntityType;
entityId: string;
currentUserId?: string;
/**
- * When `entityType='clients'` and this is true, the list aggregates
- * notes from the client + their interests + directly-owned yachts.
- * Notes from interests/yachts render with a source chip and are
- * read-only here (edit them on the source entity's page).
+ * Aggregate-on-read: union the entity's own notes with notes from
+ * related entities (interests, owned yachts / company yachts, owner
+ * client). Cross-source notes render with a source chip and are
+ * read-only here — open the source entity's page to edit.
+ *
+ * Supported for entityType in {clients, yachts, companies,
+ * residential_clients}. Ignored for interests / residential_interests.
*/
aggregate?: boolean;
}
@@ -48,10 +102,17 @@ const NOTE_EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
/** Sort by source then chronologically inside each source.
* Used by the aggregated view's "Group by source" toggle. */
function sortByGroup(notes: Note[]): Note[] {
- const sourceOrder: Record = { client: 0, interest: 1, yacht: 2 };
+ const sourceOrder: Record = {
+ client: 0,
+ company: 1,
+ yacht: 2,
+ interest: 3,
+ residential_client: 0,
+ residential_interest: 1,
+ };
return [...notes].sort((a, b) => {
- const aRank = sourceOrder[a.source ?? 'client'] ?? 99;
- const bRank = sourceOrder[b.source ?? 'client'] ?? 99;
+ const aRank = sourceOrder[a.source ?? ''] ?? 99;
+ const bRank = sourceOrder[b.source ?? ''] ?? 99;
if (aRank !== bRank) return aRank - bRank;
const aLabel = a.sourceLabel ?? '';
const bLabel = b.sourceLabel ?? '';
@@ -67,7 +128,7 @@ export function NotesList({ entityType, entityId, currentUserId, aggregate }: No
const [editContent, setEditContent] = useState('');
const [groupBySource, setGroupBySource] = useState(false);
- const aggregateOn = aggregate && entityType === 'clients';
+ const aggregateOn = !!aggregate && AGGREGATABLE.has(entityType);
const baseEndpoint = `/api/v1/${entityType}/${entityId}/notes`;
const listEndpoint = aggregateOn ? `${baseEndpoint}?aggregate=true` : baseEndpoint;
const queryKey = [entityType, entityId, 'notes', aggregateOn ? 'aggregated' : 'own'];
@@ -107,10 +168,12 @@ export function NotesList({ entityType, entityId, currentUserId, aggregate }: No
function canEdit(note: Note): boolean {
if (note.authorId !== currentUserId) return false;
if (note.isLocked) return false;
- // Aggregated view: only client-level notes are editable in-place.
- // Notes from interests/yachts must be edited on their own page so
- // the right entity timeline records the change.
- if (aggregateOn && note.source && note.source !== 'client') return false;
+ // Aggregated view: only notes from THIS entity itself are editable
+ // in-place. Notes pulled in from related entities (e.g. interests
+ // surfaced under a client) must be edited on the source page so the
+ // owning entity's timeline records the change.
+ const selfSource = SELF_SOURCE[entityType];
+ if (aggregateOn && note.source && note.source !== selfSource) return false;
const elapsed = Date.now() - new Date(note.createdAt).getTime();
return elapsed < NOTE_EDIT_WINDOW_MS;
}
@@ -192,18 +255,17 @@ export function NotesList({ entityType, entityId, currentUserId, aggregate }: No
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
- {aggregateOn && note.source && note.source !== 'client' && note.sourceLabel && (
-
- {note.source === 'interest' ? 'Interest' : 'Yacht'} · {note.sourceLabel}
-
- )}
+ {aggregateOn &&
+ note.source &&
+ note.source !== SELF_SOURCE[entityType] &&
+ note.sourceLabel && (
+
+ {SOURCE_LABEL[note.source]} · {note.sourceLabel}
+
+ )}
{note.isLocked && }
{canEdit(note) && (
{getTimeRemaining(note)}
diff --git a/src/components/yachts/yacht-tabs.tsx b/src/components/yachts/yacht-tabs.tsx
index bb184943..99deaa22 100644
--- a/src/components/yachts/yacht-tabs.tsx
+++ b/src/components/yachts/yacht-tabs.tsx
@@ -343,7 +343,9 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions
{
id: 'notes',
label: 'Notes',
- content: ,
+ content: (
+
+ ),
},
{
id: 'activity',
diff --git a/src/lib/services/notes.service.ts b/src/lib/services/notes.service.ts
index 6888e139..f9650136 100644
--- a/src/lib/services/notes.service.ts
+++ b/src/lib/services/notes.service.ts
@@ -241,6 +241,343 @@ export async function listForClientAggregated(
return merged;
}
+/**
+ * Generic aggregated-note row used by the yacht / company /
+ * residential client aggregators. `source` identifies the kind of
+ * entity the note came from; `sourceLabel` is a human label (yacht
+ * name, company name, primary berth mooring, etc.) and `sourceId` is
+ * the raw FK so the UI can deep-link to the source page.
+ */
+export interface AggregatedNote {
+ id: string;
+ content: string;
+ mentions: string[] | null;
+ isLocked: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+ authorId: string;
+ authorName: string | null;
+ source:
+ | 'client'
+ | 'interest'
+ | 'yacht'
+ | 'company'
+ | 'residential_client'
+ | 'residential_interest';
+ sourceId: string;
+ sourceLabel: string;
+}
+
+/**
+ * Aggregated note timeline for a yacht. Unions yacht-level notes
+ * with notes attached to the current owner client (when ownership
+ * is polymorphic 'client') + every interest currently linked to
+ * this yacht. Company-owned yachts surface their owning company's
+ * notes via {@link listForCompanyAggregated} instead.
+ */
+export async function listForYachtAggregated(
+ portId: string,
+ yachtId: string,
+): Promise {
+ await verifyParentBelongsToPort('yachts', yachtId, portId);
+
+ const [yacht] = await db
+ .select({
+ id: yachts.id,
+ name: yachts.name,
+ ownerType: yachts.currentOwnerType,
+ ownerId: yachts.currentOwnerId,
+ })
+ .from(yachts)
+ .where(eq(yachts.id, yachtId))
+ .limit(1);
+ if (!yacht) throw new NotFoundError('Yacht');
+
+ const ownerClientId = yacht.ownerType === 'client' ? yacht.ownerId : null;
+ const [ownerClient] = ownerClientId
+ ? await db
+ .select({ id: clients.id, name: clients.fullName })
+ .from(clients)
+ .where(eq(clients.id, ownerClientId))
+ .limit(1)
+ : [];
+
+ 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 primaryBerthMap =
+ interestIds.length > 0
+ ? await (
+ await import('@/lib/services/interest-berths.service')
+ ).getPrimaryBerthsForInterests(interestIds)
+ : new Map();
+
+ const [yachtLevel, clientLevel, interestLevel] = await Promise.all([
+ db
+ .select({
+ id: yachtNotes.id,
+ content: yachtNotes.content,
+ mentions: yachtNotes.mentions,
+ isLocked: yachtNotes.isLocked,
+ createdAt: yachtNotes.createdAt,
+ updatedAt: yachtNotes.updatedAt,
+ authorId: yachtNotes.authorId,
+ authorName: userProfiles.displayName,
+ sourceId: yachtNotes.yachtId,
+ })
+ .from(yachtNotes)
+ .leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
+ .where(eq(yachtNotes.yachtId, yachtId)),
+ ownerClientId
+ ? db
+ .select({
+ id: clientNotes.id,
+ content: clientNotes.content,
+ mentions: clientNotes.mentions,
+ isLocked: clientNotes.isLocked,
+ createdAt: clientNotes.createdAt,
+ updatedAt: clientNotes.updatedAt,
+ authorId: clientNotes.authorId,
+ authorName: userProfiles.displayName,
+ sourceId: clientNotes.clientId,
+ })
+ .from(clientNotes)
+ .leftJoin(userProfiles, eq(userProfiles.userId, clientNotes.authorId))
+ .where(eq(clientNotes.clientId, ownerClientId))
+ : Promise.resolve([] as never[]),
+ interestIds.length > 0
+ ? db
+ .select({
+ id: interestNotes.id,
+ content: interestNotes.content,
+ mentions: interestNotes.mentions,
+ isLocked: interestNotes.isLocked,
+ createdAt: interestNotes.createdAt,
+ updatedAt: interestNotes.updatedAt,
+ authorId: interestNotes.authorId,
+ authorName: userProfiles.displayName,
+ sourceId: interestNotes.interestId,
+ })
+ .from(interestNotes)
+ .leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
+ .where(inArray(interestNotes.interestId, interestIds))
+ : Promise.resolve([] as never[]),
+ ]);
+
+ const merged: AggregatedNote[] = [
+ ...yachtLevel.map((n) => ({
+ ...n,
+ source: 'yacht' as const,
+ sourceLabel: yacht.name,
+ })),
+ ...clientLevel.map((n) => ({
+ ...n,
+ source: 'client' as const,
+ sourceLabel: ownerClient?.name ?? 'Owner',
+ })),
+ ...interestLevel.map((n) => ({
+ ...n,
+ source: 'interest' as const,
+ sourceLabel: primaryBerthMap.get(n.sourceId)?.mooringNumber ?? 'Interest',
+ })),
+ ];
+ merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
+ return merged;
+}
+
+/**
+ * Aggregated note timeline for a company. Unions company-level
+ * notes with notes attached to every yacht currently owned by the
+ * company (polymorphic ownership: `owner_type='company' AND
+ * owner_id=companyId`) + every interest currently linked to those
+ * yachts. Personal-side notes from individual company members are
+ * NOT included — they belong on the client's own dossier.
+ */
+export async function listForCompanyAggregated(
+ portId: string,
+ companyId: string,
+): Promise {
+ await verifyParentBelongsToPort('companies', companyId, portId);
+
+ const yachtRows = await db
+ .select({ id: yachts.id, name: yachts.name })
+ .from(yachts)
+ .where(
+ and(
+ eq(yachts.portId, portId),
+ eq(yachts.currentOwnerType, 'company'),
+ eq(yachts.currentOwnerId, companyId),
+ ),
+ );
+ const yachtIds = yachtRows.map((r) => r.id);
+ const yachtNameById = new Map(yachtRows.map((y) => [y.id, y.name]));
+
+ 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 primaryBerthMap =
+ interestIds.length > 0
+ ? await (
+ await import('@/lib/services/interest-berths.service')
+ ).getPrimaryBerthsForInterests(interestIds)
+ : new Map();
+
+ const [companyLevel, yachtLevel, interestLevel] = await Promise.all([
+ db
+ .select({
+ id: companyNotes.id,
+ content: companyNotes.content,
+ mentions: companyNotes.mentions,
+ isLocked: companyNotes.isLocked,
+ createdAt: companyNotes.createdAt,
+ updatedAt: companyNotes.updatedAt,
+ authorId: companyNotes.authorId,
+ authorName: userProfiles.displayName,
+ sourceId: companyNotes.companyId,
+ })
+ .from(companyNotes)
+ .leftJoin(userProfiles, eq(userProfiles.userId, companyNotes.authorId))
+ .where(eq(companyNotes.companyId, companyId)),
+ yachtIds.length > 0
+ ? db
+ .select({
+ id: yachtNotes.id,
+ content: yachtNotes.content,
+ mentions: yachtNotes.mentions,
+ isLocked: yachtNotes.isLocked,
+ createdAt: yachtNotes.createdAt,
+ updatedAt: yachtNotes.updatedAt,
+ authorId: yachtNotes.authorId,
+ authorName: userProfiles.displayName,
+ sourceId: yachtNotes.yachtId,
+ })
+ .from(yachtNotes)
+ .leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
+ .where(inArray(yachtNotes.yachtId, yachtIds))
+ : Promise.resolve([] as never[]),
+ interestIds.length > 0
+ ? db
+ .select({
+ id: interestNotes.id,
+ content: interestNotes.content,
+ mentions: interestNotes.mentions,
+ isLocked: interestNotes.isLocked,
+ createdAt: interestNotes.createdAt,
+ updatedAt: interestNotes.updatedAt,
+ authorId: interestNotes.authorId,
+ authorName: userProfiles.displayName,
+ sourceId: interestNotes.interestId,
+ })
+ .from(interestNotes)
+ .leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
+ .where(inArray(interestNotes.interestId, interestIds))
+ : Promise.resolve([] as never[]),
+ ]);
+
+ const merged: AggregatedNote[] = [
+ ...companyLevel.map((n) => ({
+ ...n,
+ source: 'company' as const,
+ sourceLabel: 'Company',
+ })),
+ ...yachtLevel.map((n) => ({
+ ...n,
+ source: 'yacht' as const,
+ sourceLabel: yachtNameById.get(n.sourceId) ?? 'Yacht',
+ })),
+ ...interestLevel.map((n) => ({
+ ...n,
+ source: 'interest' as const,
+ sourceLabel: primaryBerthMap.get(n.sourceId)?.mooringNumber ?? 'Interest',
+ })),
+ ];
+ merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
+ return merged;
+}
+
+/**
+ * Aggregated note timeline for a residential client. Unions own
+ * notes with notes attached to every residential interest the
+ * client has filed. Residential is single-tenant per port so no
+ * polymorphic ownership / company linkage applies here.
+ */
+export async function listForResidentialClientAggregated(
+ portId: string,
+ residentialClientId: string,
+): Promise {
+ await verifyParentBelongsToPort('residential_clients', residentialClientId, portId);
+
+ const interestRows = await db
+ .select({ id: residentialInterests.id })
+ .from(residentialInterests)
+ .where(
+ and(
+ eq(residentialInterests.residentialClientId, residentialClientId),
+ eq(residentialInterests.portId, portId),
+ ),
+ );
+ const interestIds = interestRows.map((r) => r.id);
+
+ const [clientLevel, interestLevel] = await Promise.all([
+ db
+ .select({
+ id: residentialClientNotes.id,
+ content: residentialClientNotes.content,
+ mentions: residentialClientNotes.mentions,
+ isLocked: residentialClientNotes.isLocked,
+ createdAt: residentialClientNotes.createdAt,
+ updatedAt: residentialClientNotes.updatedAt,
+ authorId: residentialClientNotes.authorId,
+ authorName: userProfiles.displayName,
+ sourceId: residentialClientNotes.residentialClientId,
+ })
+ .from(residentialClientNotes)
+ .leftJoin(userProfiles, eq(userProfiles.userId, residentialClientNotes.authorId))
+ .where(eq(residentialClientNotes.residentialClientId, residentialClientId)),
+ interestIds.length > 0
+ ? db
+ .select({
+ id: residentialInterestNotes.id,
+ content: residentialInterestNotes.content,
+ mentions: residentialInterestNotes.mentions,
+ isLocked: residentialInterestNotes.isLocked,
+ createdAt: residentialInterestNotes.createdAt,
+ updatedAt: residentialInterestNotes.updatedAt,
+ authorId: residentialInterestNotes.authorId,
+ authorName: userProfiles.displayName,
+ sourceId: residentialInterestNotes.residentialInterestId,
+ })
+ .from(residentialInterestNotes)
+ .leftJoin(userProfiles, eq(userProfiles.userId, residentialInterestNotes.authorId))
+ .where(inArray(residentialInterestNotes.residentialInterestId, interestIds))
+ : Promise.resolve([] as never[]),
+ ]);
+
+ const merged: AggregatedNote[] = [
+ ...clientLevel.map((n) => ({
+ ...n,
+ source: 'residential_client' as const,
+ sourceLabel: 'Resident',
+ })),
+ ...interestLevel.map((n) => ({
+ ...n,
+ source: 'residential_interest' as const,
+ sourceLabel: 'Inquiry',
+ })),
+ ];
+ merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
+ return merged;
+}
+
export async function listForEntity(portId: string, entityType: EntityType, entityId: string) {
await verifyParentBelongsToPort(entityType, entityId, portId);