feat(notes): aggregate-on-read for yachts, companies, residential clients

Extends the listForClientAggregated pattern to three new symmetric
helpers in notes.service so the Notes tab on yacht / company /
residential-client detail pages surfaces the full timeline (own notes
+ related-entity notes) instead of just rows on the entity itself.

  - listForYachtAggregated: yacht own + owner client (when ownership
    is polymorphic 'client') + linked interest notes.
  - listForCompanyAggregated: company own + company-owned yacht notes
    + interests linked to those yachts.
  - listForResidentialClientAggregated: own + residential interests.

Generalises NotesList so aggregate=true works for all four entity
types via SELF_SOURCE / AGGREGATABLE / SOURCE_BADGE_CLASS / SOURCE_LABEL
maps; cross-source notes render with a coloured chip and are read-only
(rep edits on the source entity's page so the right timeline records
the change).

Wires ?aggregate=true into the yacht / company / residential-client
notes routes; the yacht / company / residential-client tabs now pass
aggregate. Drops the legacy single-textarea spots on the companies
overview tab and the residential-interest "Initial brief" row in
favour of the threaded feed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 18:36:05 +02:00
parent 43191659e6
commit 20ee2c1dcf
9 changed files with 456 additions and 59 deletions

View File

@@ -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<AggregatedNote[]> {
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<string, { mooringNumber: string }>();
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<AggregatedNote[]> {
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<string, { mooringNumber: string }>();
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<AggregatedNote[]> {
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);