import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { residentialClients, residentialInterests } from '@/lib/db/schema/residential'; import { createAuditLog } from '@/lib/audit'; import { NotFoundError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { buildListQuery } from '@/lib/db/query-builder'; import { diffEntity } from '@/lib/entity-diff'; import { softDelete, restore } from '@/lib/db/utils'; import type { CreateResidentialClientInput, CreateResidentialInterestInput, ListResidentialClientsInput, ListResidentialInterestsInput, UpdateResidentialClientInput, UpdateResidentialInterestInput, } from '@/lib/validators/residential'; interface AuditMeta { userId: string; portId: string; ipAddress: string; userAgent: string; } // ─── Residential clients ───────────────────────────────────────────────────── export async function listResidentialClients(portId: string, query: ListResidentialClientsInput) { const { page, limit, sort, order, search, includeArchived, status, source } = query; const filters = []; if (status) filters.push(eq(residentialClients.status, status)); if (source) filters.push(eq(residentialClients.source, source)); return buildListQuery({ table: residentialClients, portIdColumn: residentialClients.portId, portId, idColumn: residentialClients.id, updatedAtColumn: residentialClients.updatedAt, filters, sort: sort ? { column: (residentialClients[sort as keyof typeof residentialClients] as never) ?? residentialClients.updatedAt, direction: order ?? 'desc', } : undefined, page, pageSize: limit, searchColumns: [ residentialClients.fullName, residentialClients.email, residentialClients.phone, residentialClients.placeOfResidence, ], searchTerm: search, includeArchived, archivedAtColumn: residentialClients.archivedAt, }); } export async function getResidentialClientById(id: string, portId: string) { const client = await db.query.residentialClients.findFirst({ where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)), }); if (!client) throw new NotFoundError('Residential client'); const interests = await db.query.residentialInterests.findMany({ where: eq(residentialInterests.residentialClientId, id), orderBy: (t, { desc }) => [desc(t.updatedAt)], }); return { ...client, interests }; } export async function createResidentialClient( portId: string, data: CreateResidentialClientInput, meta: AuditMeta, ) { const [row] = await db .insert(residentialClients) .values({ portId, ...data }) .returning(); if (!row) throw new Error('Failed to create residential client'); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'residential_client', entityId: row.id, newValue: { fullName: row.fullName, email: row.email ?? undefined }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_client:created', { id: row.id }); return row; } export async function updateResidentialClient( id: string, portId: string, data: UpdateResidentialClientInput, meta: AuditMeta, ) { const before = await db.query.residentialClients.findFirst({ where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)), }); if (!before) throw new NotFoundError('Residential client'); const [updated] = await db .update(residentialClients) .set({ ...data, updatedAt: new Date() }) .where(and(eq(residentialClients.id, id), eq(residentialClients.portId, portId))) .returning(); if (!updated) throw new NotFoundError('Residential client'); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'residential_client', entityId: id, oldValue: diffEntity(before, updated) as Record, newValue: data as Record, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_client:updated', { id }); return updated; } export async function archiveResidentialClient(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.residentialClients.findFirst({ where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)), }); if (!existing) throw new NotFoundError('Residential client'); await softDelete(residentialClients, residentialClients.id, id); void createAuditLog({ userId: meta.userId, portId, action: 'archive', entityType: 'residential_client', entityId: id, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_client:archived', { id }); } export async function restoreResidentialClient(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.residentialClients.findFirst({ where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)), }); if (!existing) throw new NotFoundError('Residential client'); await restore(residentialClients, residentialClients.id, id); void createAuditLog({ userId: meta.userId, portId, action: 'restore', entityType: 'residential_client', entityId: id, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_client:restored', { id }); } // ─── Residential interests ─────────────────────────────────────────────────── export async function listResidentialInterests( portId: string, query: ListResidentialInterestsInput, ) { const { page, limit, sort, order, search, includeArchived, pipelineStage, assignedTo, residentialClientId, } = query; const filters = []; if (pipelineStage) filters.push(eq(residentialInterests.pipelineStage, pipelineStage)); if (assignedTo) filters.push(eq(residentialInterests.assignedTo, assignedTo)); if (residentialClientId) filters.push(eq(residentialInterests.residentialClientId, residentialClientId)); return buildListQuery({ table: residentialInterests, portIdColumn: residentialInterests.portId, portId, idColumn: residentialInterests.id, updatedAtColumn: residentialInterests.updatedAt, filters, sort: sort ? { column: (residentialInterests[sort as keyof typeof residentialInterests] as never) ?? residentialInterests.updatedAt, direction: order ?? 'desc', } : undefined, page, pageSize: limit, searchColumns: [residentialInterests.notes, residentialInterests.preferences], searchTerm: search, includeArchived, archivedAtColumn: residentialInterests.archivedAt, }); } export async function getResidentialInterestById(id: string, portId: string) { const interest = await db.query.residentialInterests.findFirst({ where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)), }); if (!interest) throw new NotFoundError('Residential interest'); const client = await db.query.residentialClients.findFirst({ where: eq(residentialClients.id, interest.residentialClientId), }); return { ...interest, client }; } export async function createResidentialInterest( portId: string, data: CreateResidentialInterestInput, meta: AuditMeta, ) { // Validate the residential client belongs to this port — prevents // cross-port linking. const client = await db.query.residentialClients.findFirst({ where: and( eq(residentialClients.id, data.residentialClientId), eq(residentialClients.portId, portId), ), }); if (!client) throw new NotFoundError('Residential client'); const [row] = await db .insert(residentialInterests) .values({ portId, ...data }) .returning(); if (!row) throw new Error('Failed to create residential interest'); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'residential_interest', entityId: row.id, newValue: { residentialClientId: row.residentialClientId, pipelineStage: row.pipelineStage }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_interest:created', { id: row.id }); return row; } export async function updateResidentialInterest( id: string, portId: string, data: UpdateResidentialInterestInput, meta: AuditMeta, ) { const before = await db.query.residentialInterests.findFirst({ where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)), }); if (!before) throw new NotFoundError('Residential interest'); const [updated] = await db .update(residentialInterests) .set({ ...data, updatedAt: new Date() }) .where(and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId))) .returning(); if (!updated) throw new NotFoundError('Residential interest'); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'residential_interest', entityId: id, oldValue: diffEntity(before, updated) as Record, newValue: data as Record, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_interest:updated', { id }); return updated; } export async function archiveResidentialInterest(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.residentialInterests.findFirst({ where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)), }); if (!existing) throw new NotFoundError('Residential interest'); await softDelete(residentialInterests, residentialInterests.id, id); void createAuditLog({ userId: meta.userId, portId, action: 'archive', entityType: 'residential_interest', entityId: id, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'residential_interest:archived', { id }); }