import { and, eq, ilike, or, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { companies, companyTags } from '@/lib/db/schema/companies'; import type { Company } from '@/lib/db/schema/companies'; 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, incorporationCountry: data.incorporationCountry ?? 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)), }); if (!company) throw new NotFoundError('Company'); return company; } // ─── 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, }); return result; } // ─── 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; } }); }