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