import { eq, and, desc, inArray } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clientNotes, clients } from '@/lib/db/schema/clients'; import { interestNotes, interests } from '@/lib/db/schema/interests'; import { yachtNotes, yachts } from '@/lib/db/schema/yachts'; import { companyNotes, companies } from '@/lib/db/schema/companies'; import { residentialClients, residentialClientNotes, residentialInterests, residentialInterestNotes, } from '@/lib/db/schema/residential'; import { userProfiles } from '@/lib/db/schema/users'; import { CodedError, NotFoundError, ValidationError } from '@/lib/errors'; import type { CreateNoteInput, UpdateNoteInput } from '@/lib/validators/notes'; const EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes type EntityType = | 'clients' | 'interests' | 'yachts' | 'companies' | 'residential_clients' | 'residential_interests'; // ─── Helpers ───────────────────────────────────────────────────────────────── async function verifyParentBelongsToPort( entityType: EntityType, entityId: string, portId: string, ): Promise { if (entityType === 'clients') { const r = await db .select({ id: clients.id }) .from(clients) .where(and(eq(clients.id, entityId), eq(clients.portId, portId))) .limit(1); if (!r.length) throw new NotFoundError('Client'); } else if (entityType === 'interests') { const r = await db .select({ id: interests.id }) .from(interests) .where(and(eq(interests.id, entityId), eq(interests.portId, portId))) .limit(1); if (!r.length) throw new NotFoundError('Interest'); } else if (entityType === 'yachts') { const r = await db .select({ id: yachts.id }) .from(yachts) .where(and(eq(yachts.id, entityId), eq(yachts.portId, portId))) .limit(1); if (!r.length) throw new NotFoundError('Yacht'); } else if (entityType === 'companies') { const r = await db .select({ id: companies.id }) .from(companies) .where(and(eq(companies.id, entityId), eq(companies.portId, portId))) .limit(1); if (!r.length) throw new NotFoundError('Company'); } else if (entityType === 'residential_clients') { const r = await db .select({ id: residentialClients.id }) .from(residentialClients) .where(and(eq(residentialClients.id, entityId), eq(residentialClients.portId, portId))) .limit(1); if (!r.length) throw new NotFoundError('Residential client'); } else { const r = await db .select({ id: residentialInterests.id }) .from(residentialInterests) .where(and(eq(residentialInterests.id, entityId), eq(residentialInterests.portId, portId))) .limit(1); if (!r.length) throw new NotFoundError('Residential interest'); } } // Helper to centralise the per-entity table dispatch — keeps the CRUD // branches below from each having their own switch. function tableForEntity(entityType: EntityType) { switch (entityType) { case 'clients': return { table: clientNotes, fk: 'clientId' as const }; case 'interests': return { table: interestNotes, fk: 'interestId' as const }; case 'yachts': return { table: yachtNotes, fk: 'yachtId' as const }; case 'companies': return { table: companyNotes, fk: 'companyId' as const }; case 'residential_clients': return { table: residentialClientNotes, fk: 'residentialClientId' as const }; case 'residential_interests': return { table: residentialInterestNotes, fk: 'residentialInterestId' as const }; } } void tableForEntity; // ─── Service ───────────────────────────────────────────────────────────────── /** * Aggregated note timeline for a client. Unions client-level notes * with notes attached to ANY of the client's interests + directly- * owned yachts (polymorphic ownership: `owner_type='client' AND * owner_id=clientId`). Each row carries source metadata so the UI * can show "from interest E17" or "from yacht Sea Breeze" badges * and offer a "Group by source" view alongside chronological. * * Company-owned yachts the client is a member of are excluded — * those are properly the company's notes, not the client's. */ export interface AggregatedClientNote { id: string; content: string; mentions: string[] | null; isLocked: boolean; createdAt: Date; updatedAt: Date; authorId: string; authorName: string | null; source: 'client' | 'interest' | 'yacht'; /** Origin entity id — interest_id / yacht_id / client_id. */ sourceId: string; /** Human label for the source (interest's berth mooring, yacht * name, or "Client" for client-level). */ sourceLabel: string; } export async function listForClientAggregated( portId: string, clientId: string, ): Promise { await verifyParentBelongsToPort('clients', clientId, portId); // Collect interest + yacht ids upfront so the note-table queries // can be IN-list filtered. const [interestRows, yachtRows] = await Promise.all([ db .select({ id: interests.id }) .from(interests) .where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))), db .select({ id: yachts.id, name: yachts.name }) .from(yachts) .where( and( eq(yachts.portId, portId), eq(yachts.currentOwnerType, 'client'), eq(yachts.currentOwnerId, clientId), ), ), ]); const interestIds = interestRows.map((r) => r.id); const yachtIds = yachtRows.map((r) => r.id); const yachtNameById = new Map(yachtRows.map((y) => [y.id, y.name])); // Resolve each interest's primary-berth mooring for the source // label. Cheap single round-trip via the existing junction helper. const primaryBerthMap = interestIds.length > 0 ? await ( await import('@/lib/services/interest-berths.service') ).getPrimaryBerthsForInterests(interestIds) : new Map(); // Three parallel reads against the per-entity note tables; merged // in JS rather than via UNION because each table has a different // FK column name and Drizzle's UNION syntax forces matching shapes. const [clientLevel, interestLevel, yachtLevel] = await Promise.all([ 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, clientId)), 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[]), 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[]), ]); const merged: AggregatedClientNote[] = [ ...clientLevel.map((n) => ({ ...n, source: 'client' as const, sourceLabel: 'Client', })), ...interestLevel.map((n) => ({ ...n, source: 'interest' as const, sourceLabel: primaryBerthMap.get(n.sourceId)?.mooringNumber ?? 'Interest', })), ...yachtLevel.map((n) => ({ ...n, source: 'yacht' as const, sourceLabel: yachtNameById.get(n.sourceId) ?? 'Yacht', })), ]; 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); if (entityType === 'clients') { return db .select({ id: clientNotes.id, clientId: clientNotes.clientId, authorId: clientNotes.authorId, content: clientNotes.content, mentions: clientNotes.mentions, isLocked: clientNotes.isLocked, createdAt: clientNotes.createdAt, updatedAt: clientNotes.updatedAt, authorName: userProfiles.displayName, }) .from(clientNotes) .leftJoin(userProfiles, eq(userProfiles.userId, clientNotes.authorId)) .where(eq(clientNotes.clientId, entityId)) .orderBy(desc(clientNotes.createdAt)); } else if (entityType === 'interests') { return db .select({ id: interestNotes.id, interestId: interestNotes.interestId, authorId: interestNotes.authorId, content: interestNotes.content, mentions: interestNotes.mentions, isLocked: interestNotes.isLocked, createdAt: interestNotes.createdAt, updatedAt: interestNotes.updatedAt, authorName: userProfiles.displayName, }) .from(interestNotes) .leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId)) .where(eq(interestNotes.interestId, entityId)) .orderBy(desc(interestNotes.createdAt)); } else if (entityType === 'yachts') { return db .select({ id: yachtNotes.id, yachtId: yachtNotes.yachtId, authorId: yachtNotes.authorId, content: yachtNotes.content, mentions: yachtNotes.mentions, isLocked: yachtNotes.isLocked, createdAt: yachtNotes.createdAt, updatedAt: yachtNotes.updatedAt, authorName: userProfiles.displayName, }) .from(yachtNotes) .leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId)) .where(eq(yachtNotes.yachtId, entityId)) .orderBy(desc(yachtNotes.createdAt)); } else if (entityType === 'companies') { return db .select({ id: companyNotes.id, companyId: companyNotes.companyId, authorId: companyNotes.authorId, content: companyNotes.content, mentions: companyNotes.mentions, isLocked: companyNotes.isLocked, createdAt: companyNotes.createdAt, updatedAt: companyNotes.createdAt, authorName: userProfiles.displayName, }) .from(companyNotes) .leftJoin(userProfiles, eq(userProfiles.userId, companyNotes.authorId)) .where(eq(companyNotes.companyId, entityId)) .orderBy(desc(companyNotes.createdAt)); } else if (entityType === 'residential_clients') { return db .select({ id: residentialClientNotes.id, residentialClientId: residentialClientNotes.residentialClientId, authorId: residentialClientNotes.authorId, content: residentialClientNotes.content, mentions: residentialClientNotes.mentions, isLocked: residentialClientNotes.isLocked, createdAt: residentialClientNotes.createdAt, updatedAt: residentialClientNotes.updatedAt, authorName: userProfiles.displayName, }) .from(residentialClientNotes) .leftJoin(userProfiles, eq(userProfiles.userId, residentialClientNotes.authorId)) .where(eq(residentialClientNotes.residentialClientId, entityId)) .orderBy(desc(residentialClientNotes.createdAt)); } else { return db .select({ id: residentialInterestNotes.id, residentialInterestId: residentialInterestNotes.residentialInterestId, authorId: residentialInterestNotes.authorId, content: residentialInterestNotes.content, mentions: residentialInterestNotes.mentions, isLocked: residentialInterestNotes.isLocked, createdAt: residentialInterestNotes.createdAt, updatedAt: residentialInterestNotes.updatedAt, authorName: userProfiles.displayName, }) .from(residentialInterestNotes) .leftJoin(userProfiles, eq(userProfiles.userId, residentialInterestNotes.authorId)) .where(eq(residentialInterestNotes.residentialInterestId, entityId)) .orderBy(desc(residentialInterestNotes.createdAt)); } } export async function create( portId: string, entityType: EntityType, entityId: string, authorId: string, data: CreateNoteInput, ) { await verifyParentBelongsToPort(entityType, entityId, portId); if (entityType === 'yachts') { const [note] = await db .insert(yachtNotes) .values({ yachtId: entityId, authorId, content: data.content }) .returning(); if (!note) throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Yacht note insert returned no row', }); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) .where(eq(userProfiles.userId, authorId)) .limit(1); return { ...note, authorName: profile[0]?.displayName ?? null }; } if (entityType === 'companies') { const [note] = await db .insert(companyNotes) .values({ companyId: entityId, authorId, content: data.content }) .returning(); if (!note) throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Company note insert returned no row', }); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) .where(eq(userProfiles.userId, authorId)) .limit(1); return { ...note, authorName: profile[0]?.displayName ?? null, updatedAt: note.createdAt }; } if (entityType === 'clients') { const [note] = await db .insert(clientNotes) .values({ clientId: entityId, authorId, content: data.content }) .returning(); if (!note) throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Client note insert returned no row', }); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) .where(eq(userProfiles.userId, authorId)) .limit(1); const authorName = profile[0]?.displayName ?? null; // Fire mention notifications (fire-and-forget) if (note.mentions && note.mentions.length > 0) { for (const mentionedUserId of note.mentions) { void import('@/lib/services/notifications.service').then(({ createNotification }) => createNotification({ portId, userId: mentionedUserId, type: 'mention', title: 'You were mentioned in a note', description: `${authorName ?? 'Someone'} mentioned you in a note`, link: `/clients/${entityId}`, entityType: 'client', entityId, dedupeKey: `note:${note.id}:mention:${mentionedUserId}`, }), ); } } return { ...note, authorName }; } if (entityType === 'interests') { const [note] = await db .insert(interestNotes) .values({ interestId: entityId, authorId, content: data.content }) .returning(); if (!note) throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Interest note insert returned no row', }); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) .where(eq(userProfiles.userId, authorId)) .limit(1); const authorName = profile[0]?.displayName ?? null; // Fire mention notifications (fire-and-forget) if (note.mentions && note.mentions.length > 0) { for (const mentionedUserId of note.mentions) { void import('@/lib/services/notifications.service').then(({ createNotification }) => createNotification({ portId, userId: mentionedUserId, type: 'mention', title: 'You were mentioned in a note', description: `${authorName ?? 'Someone'} mentioned you in a note`, link: `/interests/${entityId}`, entityType: 'interest', entityId, dedupeKey: `note:${note.id}:mention:${mentionedUserId}`, }), ); } } return { ...note, authorName }; } if (entityType === 'residential_clients') { const [note] = await db .insert(residentialClientNotes) .values({ residentialClientId: entityId, authorId, content: data.content }) .returning(); if (!note) throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Residential client note insert returned no row', }); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) .where(eq(userProfiles.userId, authorId)) .limit(1); return { ...note, authorName: profile[0]?.displayName ?? null }; } if (entityType === 'residential_interests') { const [note] = await db .insert(residentialInterestNotes) .values({ residentialInterestId: entityId, authorId, content: data.content }) .returning(); if (!note) throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Residential interest note insert returned no row', }); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) .where(eq(userProfiles.userId, authorId)) .limit(1); return { ...note, authorName: profile[0]?.displayName ?? null }; } throw new CodedError('INTERNAL', { internalMessage: `Unsupported entityType: ${entityType as string}`, }); } export async function update( portId: string, entityType: EntityType, entityId: string, noteId: string, data: UpdateNoteInput, ) { await verifyParentBelongsToPort(entityType, entityId, portId); if (entityType === 'yachts') { const [existing] = await db .select() .from(yachtNotes) .where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId))) .limit(1); if (!existing) throw new NotFoundError('Note'); if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) { throw new ValidationError('Note edit window has expired (15 minutes)'); } const [updated] = await db .update(yachtNotes) .set({ content: data.content, updatedAt: new Date() }) .where(eq(yachtNotes.id, noteId)) .returning(); if (!updated) throw new NotFoundError('Note'); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) .where(eq(userProfiles.userId, updated.authorId)) .limit(1); return { ...updated, authorName: profile[0]?.displayName ?? null }; } if (entityType === 'companies') { const [existing] = await db .select() .from(companyNotes) .where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId))) .limit(1); if (!existing) throw new NotFoundError('Note'); if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) { throw new ValidationError('Note edit window has expired (15 minutes)'); } const [updated] = await db .update(companyNotes) .set({ content: data.content }) .where(eq(companyNotes.id, noteId)) .returning(); if (!updated) throw new NotFoundError('Note'); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) .where(eq(userProfiles.userId, updated.authorId)) .limit(1); return { ...updated, authorName: profile[0]?.displayName ?? null, updatedAt: updated.createdAt, }; } if (entityType === 'clients') { const [existing] = await db .select() .from(clientNotes) .where(and(eq(clientNotes.id, noteId), eq(clientNotes.clientId, entityId))) .limit(1); if (!existing) throw new NotFoundError('Note'); if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) { throw new ValidationError('Note edit window has expired (15 minutes)'); } const [updated] = await db .update(clientNotes) .set({ content: data.content, updatedAt: new Date() }) .where(eq(clientNotes.id, noteId)) .returning(); if (!updated) throw new NotFoundError('Note'); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) .where(eq(userProfiles.userId, updated.authorId)) .limit(1); return { ...updated, authorName: profile[0]?.displayName ?? null }; } if (entityType === 'residential_clients') { const [existing] = await db .select() .from(residentialClientNotes) .where( and( eq(residentialClientNotes.id, noteId), eq(residentialClientNotes.residentialClientId, entityId), ), ) .limit(1); if (!existing) throw new NotFoundError('Note'); if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) { throw new ValidationError('Note edit window has expired (15 minutes)'); } const [updated] = await db .update(residentialClientNotes) .set({ content: data.content, updatedAt: new Date() }) .where(eq(residentialClientNotes.id, noteId)) .returning(); if (!updated) throw new NotFoundError('Note'); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) .where(eq(userProfiles.userId, updated.authorId)) .limit(1); return { ...updated, authorName: profile[0]?.displayName ?? null }; } if (entityType === 'residential_interests') { const [existing] = await db .select() .from(residentialInterestNotes) .where( and( eq(residentialInterestNotes.id, noteId), eq(residentialInterestNotes.residentialInterestId, entityId), ), ) .limit(1); if (!existing) throw new NotFoundError('Note'); if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) { throw new ValidationError('Note edit window has expired (15 minutes)'); } const [updated] = await db .update(residentialInterestNotes) .set({ content: data.content, updatedAt: new Date() }) .where(eq(residentialInterestNotes.id, noteId)) .returning(); if (!updated) throw new NotFoundError('Note'); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) .where(eq(userProfiles.userId, updated.authorId)) .limit(1); return { ...updated, authorName: profile[0]?.displayName ?? null }; } // Default: interests (the marina-side, not residential) { const [existing] = await db .select() .from(interestNotes) .where(and(eq(interestNotes.id, noteId), eq(interestNotes.interestId, entityId))) .limit(1); if (!existing) throw new NotFoundError('Note'); if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) { throw new ValidationError('Note edit window has expired (15 minutes)'); } const [updated] = await db .update(interestNotes) .set({ content: data.content, updatedAt: new Date() }) .where(eq(interestNotes.id, noteId)) .returning(); if (!updated) throw new NotFoundError('Note'); const profile = await db .select({ displayName: userProfiles.displayName }) .from(userProfiles) .where(eq(userProfiles.userId, updated.authorId)) .limit(1); return { ...updated, authorName: profile[0]?.displayName ?? null }; } } export async function deleteNote( portId: string, entityType: EntityType, entityId: string, noteId: string, ) { await verifyParentBelongsToPort(entityType, entityId, portId); if (entityType === 'yachts') { const [existing] = await db .select() .from(yachtNotes) .where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId))) .limit(1); if (!existing) throw new NotFoundError('Note'); if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) { throw new ValidationError('Note edit window has expired (15 minutes)'); } await db.delete(yachtNotes).where(eq(yachtNotes.id, noteId)); return existing; } if (entityType === 'companies') { const [existing] = await db .select() .from(companyNotes) .where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId))) .limit(1); if (!existing) throw new NotFoundError('Note'); if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) { throw new ValidationError('Note edit window has expired (15 minutes)'); } await db.delete(companyNotes).where(eq(companyNotes.id, noteId)); return existing; } if (entityType === 'clients') { const [existing] = await db .select() .from(clientNotes) .where(and(eq(clientNotes.id, noteId), eq(clientNotes.clientId, entityId))) .limit(1); if (!existing) throw new NotFoundError('Note'); if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) { throw new ValidationError('Note edit window has expired (15 minutes)'); } await db.delete(clientNotes).where(eq(clientNotes.id, noteId)); return existing; } if (entityType === 'residential_clients') { const [existing] = await db .select() .from(residentialClientNotes) .where( and( eq(residentialClientNotes.id, noteId), eq(residentialClientNotes.residentialClientId, entityId), ), ) .limit(1); if (!existing) throw new NotFoundError('Note'); if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) { throw new ValidationError('Note edit window has expired (15 minutes)'); } await db.delete(residentialClientNotes).where(eq(residentialClientNotes.id, noteId)); return existing; } if (entityType === 'residential_interests') { const [existing] = await db .select() .from(residentialInterestNotes) .where( and( eq(residentialInterestNotes.id, noteId), eq(residentialInterestNotes.residentialInterestId, entityId), ), ) .limit(1); if (!existing) throw new NotFoundError('Note'); if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) { throw new ValidationError('Note edit window has expired (15 minutes)'); } await db.delete(residentialInterestNotes).where(eq(residentialInterestNotes.id, noteId)); return existing; } // Default: interests { const [existing] = await db .select() .from(interestNotes) .where(and(eq(interestNotes.id, noteId), eq(interestNotes.interestId, entityId))) .limit(1); if (!existing) throw new NotFoundError('Note'); if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) { throw new ValidationError('Note edit window has expired (15 minutes)'); } await db.delete(interestNotes).where(eq(interestNotes.id, noteId)); return existing; } }