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

@@ -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);

View File

@@ -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);

View File

@@ -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);