From 037f2544e8cd79b697042aaedf8e008a7c510cf6 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 12:02:08 +0200 Subject: [PATCH] feat(companies): service + validators + unit tests --- src/lib/services/companies.service.ts | 299 ++++++++++++++++++++++++++ src/lib/socket/events.ts | 5 + src/lib/validators/companies.ts | 26 +++ tests/unit/services/companies.test.ts | 259 ++++++++++++++++++++++ tests/unit/validators.test.ts | 39 ++++ 5 files changed, 628 insertions(+) create mode 100644 src/lib/services/companies.service.ts create mode 100644 src/lib/validators/companies.ts create mode 100644 tests/unit/services/companies.test.ts diff --git a/src/lib/services/companies.service.ts b/src/lib/services/companies.service.ts new file mode 100644 index 0000000..afb2e5a --- /dev/null +++ b/src/lib/services/companies.service.ts @@ -0,0 +1,299 @@ +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; + } + }); +} diff --git a/src/lib/socket/events.ts b/src/lib/socket/events.ts index 6f6068f..284de3d 100644 --- a/src/lib/socket/events.ts +++ b/src/lib/socket/events.ts @@ -86,6 +86,11 @@ export interface ServerToClientEvents { newOwner: { type: 'client' | 'company'; id: string }; }) => void; + // Company events + 'company:created': (payload: { companyId: string }) => void; + 'company:updated': (payload: { companyId: string; changedFields: string[] }) => void; + 'company:archived': (payload: { companyId: string }) => void; + // Document events 'document:created': (payload: { documentId: string; type?: string; interestId?: string }) => void; 'document:updated': (payload: { documentId: string; changedFields?: string[] }) => void; diff --git a/src/lib/validators/companies.ts b/src/lib/validators/companies.ts new file mode 100644 index 0000000..3e83401 --- /dev/null +++ b/src/lib/validators/companies.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { baseListQuerySchema } from '@/lib/api/route-helpers'; + +export const createCompanySchema = z.object({ + name: z.string().min(1).max(200), + legalName: z.string().optional(), + taxId: z.string().optional(), + registrationNumber: z.string().optional(), + incorporationCountry: z.string().optional(), + incorporationDate: z.coerce.date().optional(), + status: z.enum(['active', 'dissolved']).optional().default('active'), + billingEmail: z.string().email().optional(), + notes: z.string().optional(), + tagIds: z.array(z.string()).optional().default([]), +}); + +export const updateCompanySchema = createCompanySchema.partial().omit({ tagIds: true }); + +export const listCompaniesSchema = baseListQuerySchema.extend({ + status: z.enum(['active', 'dissolved']).optional(), + search: z.string().optional(), +}); + +export type CreateCompanyInput = z.infer; +export type UpdateCompanyInput = z.infer; +export type ListCompaniesInput = z.infer; diff --git a/tests/unit/services/companies.test.ts b/tests/unit/services/companies.test.ts new file mode 100644 index 0000000..60c21c3 --- /dev/null +++ b/tests/unit/services/companies.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect } from 'vitest'; +import { + createCompany, + updateCompany, + archiveCompany, + listCompanies, + autocomplete, + upsertByName, + getCompanyById, +} from '@/lib/services/companies.service'; +import { makeCompany, makePort, makeAuditMeta } from '../../helpers/factories'; +import { db } from '@/lib/db'; +import { companies } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { ConflictError, NotFoundError } from '@/lib/errors'; + +describe('companies.service — createCompany', () => { + it('creates a company with minimal required fields', async () => { + const port = await makePort(); + + const company = await createCompany( + port.id, + { name: 'Aegean Holdings' }, + makeAuditMeta({ portId: port.id }), + ); + + expect(company.id).toBeTruthy(); + expect(company.name).toBe('Aegean Holdings'); + expect(company.status).toBe('active'); + expect(company.portId).toBe(port.id); + }); + + it('rejects duplicate name case-insensitively (ConflictError)', async () => { + const port = await makePort(); + await createCompany(port.id, { name: 'Aegean Holdings' }, makeAuditMeta({ portId: port.id })); + + await expect( + createCompany(port.id, { name: 'AEGEAN HOLDINGS' }, makeAuditMeta({ portId: port.id })), + ).rejects.toBeInstanceOf(ConflictError); + }); + + it('allows same name in different ports (tenant isolation)', async () => { + const portA = await makePort(); + const portB = await makePort(); + + const a = await createCompany( + portA.id, + { name: 'Shared Name Co' }, + makeAuditMeta({ portId: portA.id }), + ); + const b = await createCompany( + portB.id, + { name: 'Shared Name Co' }, + makeAuditMeta({ portId: portB.id }), + ); + + expect(a.id).not.toBe(b.id); + expect(a.portId).toBe(portA.id); + expect(b.portId).toBe(portB.id); + }); +}); + +describe('companies.service — upsertByName', () => { + it('returns existing company on case-insensitive match', async () => { + const port = await makePort(); + const original = await createCompany( + port.id, + { name: 'Poseidon Maritime' }, + makeAuditMeta({ portId: port.id }), + ); + + const result = await upsertByName( + port.id, + 'POSEIDON maritime', + makeAuditMeta({ portId: port.id }), + ); + + expect(result.id).toBe(original.id); + }); + + it('creates a new company when name not found', async () => { + const port = await makePort(); + + const result = await upsertByName(port.id, 'Brand New Co', makeAuditMeta({ portId: port.id })); + + expect(result.id).toBeTruthy(); + expect(result.name).toBe('Brand New Co'); + expect(result.portId).toBe(port.id); + }); + + it('is tenant-scoped (same name in different ports creates distinct rows)', async () => { + const portA = await makePort(); + const portB = await makePort(); + + const a = await upsertByName(portA.id, 'Cross Tenant Co', makeAuditMeta({ portId: portA.id })); + const b = await upsertByName(portB.id, 'Cross Tenant Co', makeAuditMeta({ portId: portB.id })); + + expect(a.id).not.toBe(b.id); + expect(a.portId).toBe(portA.id); + expect(b.portId).toBe(portB.id); + }); +}); + +describe('companies.service — updateCompany', () => { + it('updates fields', async () => { + const port = await makePort(); + const company = await makeCompany({ + portId: port.id, + overrides: { name: 'Original Name', notes: 'Old notes' }, + }); + + const updated = await updateCompany( + company.id, + port.id, + { name: 'New Name', notes: 'New notes' }, + makeAuditMeta({ portId: port.id }), + ); + + expect(updated.name).toBe('New Name'); + expect(updated.notes).toBe('New notes'); + + const [row] = await db.select().from(companies).where(eq(companies.id, company.id)); + expect(row!.name).toBe('New Name'); + }); + + it('throws NotFoundError for cross-tenant', async () => { + const portA = await makePort(); + const portB = await makePort(); + const companyInB = await makeCompany({ portId: portB.id }); + + await expect( + updateCompany( + companyInB.id, + portA.id, + { name: 'Hijack' }, + makeAuditMeta({ portId: portA.id }), + ), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); + +describe('companies.service — archiveCompany', () => { + it('sets archivedAt to a non-null timestamp', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + + await archiveCompany(company.id, port.id, makeAuditMeta({ portId: port.id })); + + const [row] = await db.select().from(companies).where(eq(companies.id, company.id)); + expect(row!.archivedAt).not.toBeNull(); + }); + + it('throws NotFoundError for missing or cross-tenant', async () => { + const portA = await makePort(); + const portB = await makePort(); + const companyInB = await makeCompany({ portId: portB.id }); + + await expect( + archiveCompany(companyInB.id, portA.id, makeAuditMeta({ portId: portA.id })), + ).rejects.toBeInstanceOf(NotFoundError); + + await expect( + archiveCompany('nonexistent-id', portA.id, makeAuditMeta({ portId: portA.id })), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); + +describe('companies.service — listCompanies', () => { + it('is tenant-scoped', async () => { + const portA = await makePort(); + const portB = await makePort(); + await makeCompany({ portId: portA.id, overrides: { name: 'In A' } }); + await makeCompany({ portId: portB.id, overrides: { name: 'In B' } }); + + const result = await listCompanies(portA.id, { + page: 1, + limit: 20, + order: 'desc', + includeArchived: false, + }); + expect(result.data.some((c) => c.name === 'In A')).toBe(true); + expect(result.data.some((c) => c.name === 'In B')).toBe(false); + }); + + it('filters by status', async () => { + const port = await makePort(); + await makeCompany({ + portId: port.id, + overrides: { name: 'Active Co', status: 'active' }, + }); + await makeCompany({ + portId: port.id, + overrides: { name: 'Dissolved Co', status: 'dissolved' }, + }); + + const result = await listCompanies(port.id, { + page: 1, + limit: 20, + order: 'desc', + includeArchived: false, + status: 'dissolved', + }); + expect(result.data.map((c) => c.name)).toContain('Dissolved Co'); + expect(result.data.map((c) => c.name)).not.toContain('Active Co'); + }); + + it('searches by name (ILIKE)', async () => { + const port = await makePort(); + await makeCompany({ portId: port.id, overrides: { name: 'Aegean Shipping' } }); + await makeCompany({ portId: port.id, overrides: { name: 'Pacific Marine' } }); + + const result = await listCompanies(port.id, { + page: 1, + limit: 20, + order: 'desc', + includeArchived: false, + search: 'aegean', + }); + expect(result.data.map((c) => c.name)).toContain('Aegean Shipping'); + expect(result.data.map((c) => c.name)).not.toContain('Pacific Marine'); + }); +}); + +describe('companies.service — autocomplete', () => { + it('matches by name', async () => { + const port = await makePort(); + await makeCompany({ portId: port.id, overrides: { name: 'Phoenix Ltd' } }); + + const result = await autocomplete(port.id, 'phoe'); + expect(result.some((c) => c.name === 'Phoenix Ltd')).toBe(true); + }); + + it('is tenant-scoped', async () => { + const portA = await makePort(); + const portB = await makePort(); + await makeCompany({ portId: portA.id, overrides: { name: 'Only-A Co' } }); + + const result = await autocomplete(portB.id, 'only-a'); + expect(result).toHaveLength(0); + }); +}); + +describe('companies.service — getCompanyById', () => { + it('returns the company when same tenant', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + + const result = await getCompanyById(company.id, port.id); + expect(result.id).toBe(company.id); + }); + + it('throws NotFoundError for cross-tenant', async () => { + const portA = await makePort(); + const portB = await makePort(); + const companyInB = await makeCompany({ portId: portB.id }); + + await expect(getCompanyById(companyInB.id, portA.id)).rejects.toBeInstanceOf(NotFoundError); + }); +}); diff --git a/tests/unit/validators.test.ts b/tests/unit/validators.test.ts index 8c4caea..2d86fc5 100644 --- a/tests/unit/validators.test.ts +++ b/tests/unit/validators.test.ts @@ -6,6 +6,7 @@ import { createInvoiceSchema } from '@/lib/validators/invoices'; import { createWebhookSchema, updateWebhookSchema } from '@/lib/validators/webhooks'; import { createFieldSchema, updateFieldSchema } from '@/lib/validators/custom-fields'; import { createYachtSchema, transferOwnershipSchema } from '@/lib/validators/yachts'; +import { createCompanySchema } from '@/lib/validators/companies'; // ─── Client schemas ─────────────────────────────────────────────────────────── @@ -415,3 +416,41 @@ describe('transferOwnershipSchema', () => { expect(result.success).toBe(true); }); }); + +// ─── Company schemas ────────────────────────────────────────────────────────── + +describe('createCompanySchema', () => { + it('rejects empty name', () => { + const result = createCompanySchema.safeParse({ name: '' }); + expect(result.success).toBe(false); + }); + + it('rejects invalid billingEmail', () => { + const result = createCompanySchema.safeParse({ + name: 'Aegean Holdings', + billingEmail: 'not-an-email', + }); + expect(result.success).toBe(false); + }); + + it('accepts minimal valid input', () => { + const result = createCompanySchema.safeParse({ name: 'Aegean Holdings' }); + expect(result.success).toBe(true); + }); + + it('accepts full valid input', () => { + const result = createCompanySchema.safeParse({ + name: 'Aegean Holdings', + legalName: 'Aegean Holdings Ltd.', + taxId: 'GR123456789', + registrationNumber: 'REG-001', + incorporationCountry: 'GR', + incorporationDate: '2010-04-15', + status: 'active', + billingEmail: 'billing@aegean.example', + notes: 'Longtime customer', + tagIds: ['tag-1', 'tag-2'], + }); + expect(result.success).toBe(true); + }); +});