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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -169,17 +169,6 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||
<InlineEditableField
|
||||
variant="textarea"
|
||||
value={company.notes}
|
||||
onSave={save('notes')}
|
||||
emptyText="No notes - click to add"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
@@ -236,7 +225,12 @@ export function getCompanyTabs({
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
content: (
|
||||
<NotesList entityType="companies" entityId={companyId} currentUserId={currentUserId} />
|
||||
<NotesList
|
||||
entityType="companies"
|
||||
entityId={companyId}
|
||||
currentUserId={currentUserId}
|
||||
aggregate
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -120,6 +120,7 @@ export function getResidentialClientTabs({
|
||||
entityType="residential_clients"
|
||||
entityId={clientId}
|
||||
currentUserId={currentUserId}
|
||||
aggregate
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -156,14 +156,6 @@ function OverviewTab({
|
||||
onSave={save('preferences')}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Initial brief">
|
||||
<InlineEditableField
|
||||
variant="textarea"
|
||||
value={interest.notes}
|
||||
onSave={save('notes')}
|
||||
emptyText="-"
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<NotesEntityType, NoteSource | null> = {
|
||||
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<NotesEntityType> = new Set([
|
||||
'clients',
|
||||
'yachts',
|
||||
'companies',
|
||||
'residential_clients',
|
||||
]);
|
||||
|
||||
const SOURCE_BADGE_CLASS: Record<NoteSource, string> = {
|
||||
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<NoteSource, string> = {
|
||||
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<string, number> = { client: 0, interest: 1, yacht: 2 };
|
||||
const sourceOrder: Record<string, number> = {
|
||||
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
|
||||
<span className="text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
{aggregateOn && note.source && note.source !== 'client' && note.sourceLabel && (
|
||||
<span
|
||||
className={
|
||||
note.source === 'interest'
|
||||
? 'inline-flex items-center rounded-full bg-blue-100 text-blue-900 px-1.5 py-0.5 text-[10px] font-medium'
|
||||
: 'inline-flex items-center rounded-full bg-emerald-100 text-emerald-900 px-1.5 py-0.5 text-[10px] font-medium'
|
||||
}
|
||||
title={`From ${note.source}`}
|
||||
>
|
||||
{note.source === 'interest' ? 'Interest' : 'Yacht'} · {note.sourceLabel}
|
||||
</span>
|
||||
)}
|
||||
{aggregateOn &&
|
||||
note.source &&
|
||||
note.source !== SELF_SOURCE[entityType] &&
|
||||
note.sourceLabel && (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${SOURCE_BADGE_CLASS[note.source]}`}
|
||||
title={`From ${note.source}`}
|
||||
>
|
||||
{SOURCE_LABEL[note.source]} · {note.sourceLabel}
|
||||
</span>
|
||||
)}
|
||||
{note.isLocked && <Lock className="h-3 w-3 text-muted-foreground" />}
|
||||
{canEdit(note) && (
|
||||
<span className="text-xs text-muted-foreground">{getTimeRemaining(note)}</span>
|
||||
|
||||
@@ -343,7 +343,9 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
content: <NotesList entityType="yachts" entityId={yachtId} currentUserId={currentUserId} />,
|
||||
content: (
|
||||
<NotesList entityType="yachts" entityId={yachtId} currentUserId={currentUserId} aggregate />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user