import { and, count, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { companies, companyMemberships, companyTags } from '@/lib/db/schema/companies'; import type { Company } from '@/lib/db/schema/companies'; import { yachts } from '@/lib/db/schema/yachts'; import { withTransaction } from '@/lib/db/utils'; import { buildListQuery } from '@/lib/db/query-builder'; import { createAuditLog } from '@/lib/audit'; import { NotFoundError, ConflictError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { diffEntity } from '@/lib/entity-diff'; import type { z } from 'zod'; import type { createCompanySchema, UpdateCompanyInput, ListCompaniesInput, } from '@/lib/validators/companies'; type CreateCompanyInput = z.input; interface AuditMeta { userId: string; portId: string; ipAddress: string; userAgent: string; } export type { Company }; // ─── Helpers ───────────────────────────────────────────────────────────────── /** * Returns true if the error is a Postgres unique-violation (SQLSTATE 23505). * We check a few shapes because the exact object depends on the driver. */ function isUniqueViolation(err: unknown): boolean { if (!err || typeof err !== 'object') return false; const e = err as { code?: unknown; cause?: { code?: unknown } }; if (e.code === '23505') return true; if (e.cause && typeof e.cause === 'object' && e.cause.code === '23505') return true; return false; } // ─── Create ────────────────────────────────────────────────────────────────── export async function createCompany(portId: string, data: CreateCompanyInput, meta: AuditMeta) { // Pre-check (case-insensitive) for friendlier ConflictError; the partial unique // index `idx_companies_name_unique ON companies(portId, lower(name))` is the // authoritative guard and caught below as defense-in-depth. const existing = await db.query.companies.findFirst({ where: and(eq(companies.portId, portId), sql`lower(${companies.name}) = lower(${data.name})`), }); if (existing) { throw new ConflictError('company name already exists'); } try { return await withTransaction(async (tx) => { const [company] = await tx .insert(companies) .values({ portId, name: data.name, legalName: data.legalName ?? null, taxId: data.taxId ?? null, registrationNumber: data.registrationNumber ?? null, incorporationCountryIso: data.incorporationCountryIso ?? null, incorporationSubdivisionIso: data.incorporationSubdivisionIso ?? null, incorporationDate: data.incorporationDate ?? null, status: data.status ?? 'active', billingEmail: data.billingEmail ?? null, notes: data.notes ?? null, }) .returning(); const tagIds = data.tagIds ?? []; if (tagIds.length > 0) { await tx .insert(companyTags) .values(tagIds.map((tagId) => ({ companyId: company!.id, tagId }))); } void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'company', entityId: company!.id, newValue: { name: company!.name, status: company!.status }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'company:created', { companyId: company!.id }); return company!; }); } catch (err) { if (isUniqueViolation(err)) { throw new ConflictError('company name already exists'); } throw err; } } // ─── Get ───────────────────────────────────────────────────────────────────── export async function getCompanyById(id: string, portId: string) { const company = await db.query.companies.findFirst({ where: and(eq(companies.id, id), eq(companies.portId, portId)), with: { tags: { with: { tag: true } }, }, }); if (!company) throw new NotFoundError('Company'); const { tags: tagJoins, ...rest } = company as typeof company & { tags: Array<{ tag: { id: string; name: string; color: string } }>; }; return { ...rest, tags: tagJoins.map((t) => t.tag), }; } // ─── Update ────────────────────────────────────────────────────────────────── export async function updateCompany( id: string, portId: string, data: UpdateCompanyInput, meta: AuditMeta, ) { const existing = await db.query.companies.findFirst({ where: eq(companies.id, id), }); if (!existing || existing.portId !== portId) { throw new NotFoundError('Company'); } const { diff } = diffEntity( existing as unknown as Record, data as Record, ); let updated: Company | undefined; try { const rows = await db .update(companies) .set({ ...data, updatedAt: new Date() }) .where(and(eq(companies.id, id), eq(companies.portId, portId))) .returning(); updated = rows[0]; } catch (err) { if (isUniqueViolation(err)) { throw new ConflictError('company name already exists'); } throw err; } void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'company', entityId: id, oldValue: diff as Record, newValue: data as Record, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'company:updated', { companyId: id, changedFields: Object.keys(diff), }); return updated!; } // ─── Archive ───────────────────────────────────────────────────────────────── export async function archiveCompany(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.companies.findFirst({ where: eq(companies.id, id), }); if (!existing || existing.portId !== portId) { throw new NotFoundError('Company'); } // NOTE: bypassing the shared `softDelete(...)` util: it sets the raw column key // `archived_at`, which Drizzle does not recognise (the JS key is `archivedAt`) // and therefore emits an empty SET clause. Until the utility is fixed, do the // update inline. (See Task 2.3 for context.) await db .update(companies) .set({ archivedAt: new Date() }) .where(and(eq(companies.id, id), eq(companies.portId, portId))); void createAuditLog({ userId: meta.userId, portId, action: 'archive', entityType: 'company', entityId: id, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'company:archived', { companyId: id }); } // ─── List ──────────────────────────────────────────────────────────────────── export async function listCompanies(portId: string, query: ListCompaniesInput) { const { page, limit, sort, order, search, includeArchived, status } = query; const filters = []; if (status) filters.push(eq(companies.status, status)); let sortColumn: typeof companies.name | typeof companies.createdAt | typeof companies.updatedAt = companies.updatedAt; if (sort === 'name') sortColumn = companies.name; else if (sort === 'createdAt') sortColumn = companies.createdAt; const result = await buildListQuery({ table: companies, portIdColumn: companies.portId, portId, idColumn: companies.id, updatedAtColumn: companies.updatedAt, searchColumns: [companies.name, companies.legalName, companies.taxId], searchTerm: search, filters, sort: sort ? { column: sortColumn, direction: order } : undefined, page, pageSize: limit, includeArchived, archivedAtColumn: companies.archivedAt, }); if (result.data.length === 0) return result; const ids = result.data.map((r) => r.id); const [memberCounts, yachtCounts] = await Promise.all([ db .select({ companyId: companyMemberships.companyId, count: count() }) .from(companyMemberships) .where(and(inArray(companyMemberships.companyId, ids), isNull(companyMemberships.endDate))) .groupBy(companyMemberships.companyId), db .select({ ownerId: yachts.currentOwnerId, count: count() }) .from(yachts) .where( and( eq(yachts.portId, portId), eq(yachts.currentOwnerType, 'company'), inArray(yachts.currentOwnerId, ids), isNull(yachts.archivedAt), ), ) .groupBy(yachts.currentOwnerId), ]); const memberCountMap = new Map(memberCounts.map((r) => [r.companyId, r.count])); const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count])); return { ...result, data: result.data.map((row) => ({ ...row, memberCount: memberCountMap.get(row.id) ?? 0, yachtCount: yachtCountMap.get(row.id) ?? 0, })), }; } // ─── Autocomplete ──────────────────────────────────────────────────────────── export async function autocomplete(portId: string, q: string) { const pattern = `%${q}%`; return await db .select() .from(companies) .where( and( eq(companies.portId, portId), or(ilike(companies.name, pattern), ilike(companies.legalName, pattern)), ), ) .limit(10); } // ─── Upsert by name (find-or-create) ───────────────────────────────────────── /** * Find-or-create a company by (portId, lower(name)). NOT a Postgres UPSERT. * * Runs a case-insensitive SELECT scoped by portId; if found, returns it. * Otherwise inserts a new row with the provided `name` verbatim. A concurrent * insert that hits the partial unique index (23505) is re-raised as * ConflictError for the caller to retry if desired. */ export async function upsertByName(portId: string, name: string, meta: AuditMeta) { return await withTransaction(async (tx) => { const existing = await tx.query.companies.findFirst({ where: and(eq(companies.portId, portId), sql`lower(${companies.name}) = lower(${name})`), }); if (existing) return existing; try { const [company] = await tx .insert(companies) .values({ portId, name, status: 'active', }) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'company', entityId: company!.id, newValue: { name: company!.name, status: company!.status }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'company:created', { companyId: company!.id }); return company!; } catch (err) { if (isUniqueViolation(err)) { throw new ConflictError('company name already exists'); } throw err; } }); } export async function setCompanyTags( companyId: string, portId: string, tagIds: string[], meta: AuditMeta, ) { const company = await db.query.companies.findFirst({ where: eq(companies.id, companyId) }); if (!company || company.portId !== portId) throw new NotFoundError('Company'); await db.delete(companyTags).where(eq(companyTags.companyId, companyId)); if (tagIds.length > 0) { await db.insert(companyTags).values(tagIds.map((tagId) => ({ companyId, tagId }))); } void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'company', entityId: companyId, newValue: { tagIds }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'company:updated', { companyId, changedFields: ['tags'] }); }