import { eq, and, desc } 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 { userProfiles } from '@/lib/db/schema/users'; import { 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'; // ─── Helpers ───────────────────────────────────────────────────────────────── async function verifyParentBelongsToPort( entityType: EntityType, entityId: string, portId: string, ): Promise { if (entityType === 'clients') { const client = await db .select({ id: clients.id }) .from(clients) .where(and(eq(clients.id, entityId), eq(clients.portId, portId))) .limit(1); if (!client.length) throw new NotFoundError('Client'); } else { const interest = await db .select({ id: interests.id }) .from(interests) .where(and(eq(interests.id, entityId), eq(interests.portId, portId))) .limit(1); if (!interest.length) throw new NotFoundError('Interest'); } } // ─── Service ───────────────────────────────────────────────────────────────── export async function listForEntity( portId: string, entityType: EntityType, entityId: string, ) { await verifyParentBelongsToPort(entityType, entityId, portId); if (entityType === 'clients') { const rows = await 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)); return rows; } else { const rows = await 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)); return rows; } } export async function create( portId: string, entityType: EntityType, entityId: string, authorId: string, data: CreateNoteInput, ) { await verifyParentBelongsToPort(entityType, entityId, portId); if (entityType === 'clients') { const [note] = await db .insert(clientNotes) .values({ clientId: entityId, authorId, content: data.content }) .returning(); if (!note) throw new Error('Insert failed'); 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 }; } else { const [note] = await db .insert(interestNotes) .values({ interestId: entityId, authorId, content: data.content }) .returning(); if (!note) throw new Error('Insert failed'); 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 }; } } export async function update( portId: string, entityType: EntityType, entityId: string, noteId: string, data: UpdateNoteInput, ) { await verifyParentBelongsToPort(entityType, entityId, portId); 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 }; } else { 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 === '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; } else { 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; } }