From 15a79e7990fe5e8192f48aa174ae8fd534c0862e Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 12:07:58 +0200 Subject: [PATCH] feat(company-memberships): service + validators + tests Adds company-membership service with six operations (add, update, end, setPrimary, listByCompany, listByClient), the corresponding Zod validators, three socket events, and a unit-test suite covering the portId-scoping rules, the unique_cm_exact conflict path, and the atomic setPrimary transaction. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/company-memberships.service.ts | 311 +++++++++++++ src/lib/socket/events.ts | 16 + src/lib/validators/company-memberships.ts | 36 ++ .../unit/services/company-memberships.test.ts | 424 ++++++++++++++++++ tests/unit/validators.test.ts | 29 ++ 5 files changed, 816 insertions(+) create mode 100644 src/lib/services/company-memberships.service.ts create mode 100644 src/lib/validators/company-memberships.ts create mode 100644 tests/unit/services/company-memberships.test.ts diff --git a/src/lib/services/company-memberships.service.ts b/src/lib/services/company-memberships.service.ts new file mode 100644 index 0000000..dc9a3e8 --- /dev/null +++ b/src/lib/services/company-memberships.service.ts @@ -0,0 +1,311 @@ +import { and, desc, eq, isNull, ne } from 'drizzle-orm'; +import { db } from '@/lib/db'; +import { companies, companyMemberships } from '@/lib/db/schema/companies'; +import type { CompanyMembership } from '@/lib/db/schema/companies'; +import { clients } from '@/lib/db/schema/clients'; +import { withTransaction } from '@/lib/db/utils'; +import { createAuditLog } from '@/lib/audit'; +import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors'; +import { emitToRoom } from '@/lib/socket/server'; +import { diffEntity } from '@/lib/entity-diff'; +import type { + AddMembershipInput, + UpdateMembershipInput, + EndMembershipInput, +} from '@/lib/validators/company-memberships'; + +interface AuditMeta { + userId: string; + portId: string; + ipAddress: string; + userAgent: string; +} + +export type { CompanyMembership }; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Returns true if the error is a Postgres unique-violation (SQLSTATE 23505). + */ +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; +} + +/** + * Loads a membership row and verifies the joined company belongs to `portId`. + * Throws NotFoundError('Membership') if the row is missing or cross-tenant. + * + * Uses a JOIN to companies (memberships have no portId column — they inherit + * tenancy via the parent company). + */ +async function loadMembershipScoped( + membershipId: string, + portId: string, +): Promise { + const rows = await db + .select({ membership: companyMemberships }) + .from(companyMemberships) + .innerJoin(companies, eq(companies.id, companyMemberships.companyId)) + .where(and(eq(companyMemberships.id, membershipId), eq(companies.portId, portId))) + .limit(1); + + const row = rows[0]; + if (!row) throw new NotFoundError('Membership'); + return row.membership; +} + +// ─── Add ───────────────────────────────────────────────────────────────────── + +export async function addMembership( + companyId: string, + portId: string, + data: AddMembershipInput, + meta: AuditMeta, +): Promise { + // Verify the company exists in this port. + const company = await db.query.companies.findFirst({ + where: and(eq(companies.id, companyId), eq(companies.portId, portId)), + }); + if (!company) throw new ValidationError('company not found'); + + // Verify the client exists in this port. + const client = await db.query.clients.findFirst({ + where: and(eq(clients.id, data.clientId), eq(clients.portId, portId)), + }); + if (!client) throw new ValidationError('client not found'); + + try { + const [membership] = await db + .insert(companyMemberships) + .values({ + companyId, + clientId: data.clientId, + role: data.role, + roleDetail: data.roleDetail ?? null, + startDate: data.startDate, + isPrimary: data.isPrimary ?? false, + notes: data.notes ?? null, + }) + .returning(); + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'create', + entityType: 'company_membership', + entityId: membership!.id, + newValue: { + companyId: membership!.companyId, + clientId: membership!.clientId, + role: membership!.role, + startDate: membership!.startDate, + }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'company_membership:added', { + membershipId: membership!.id, + companyId: membership!.companyId, + clientId: membership!.clientId, + }); + + return membership!; + } catch (err) { + if (isUniqueViolation(err)) { + throw new ConflictError('membership already exists'); + } + throw err; + } +} + +// ─── Update ────────────────────────────────────────────────────────────────── + +export async function updateMembership( + membershipId: string, + portId: string, + data: UpdateMembershipInput, + meta: AuditMeta, +): Promise { + const existing = await loadMembershipScoped(membershipId, portId); + + const { diff } = diffEntity( + existing as unknown as Record, + data as Record, + ); + + const rows = await db + .update(companyMemberships) + .set({ ...data, updatedAt: new Date() }) + .where(eq(companyMemberships.id, membershipId)) + .returning(); + + const updated = rows[0]!; + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'update', + entityType: 'company_membership', + entityId: membershipId, + oldValue: diff as Record, + newValue: data as Record, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'company_membership:updated', { + membershipId, + changedFields: Object.keys(diff), + }); + + return updated; +} + +// ─── End (set endDate) ─────────────────────────────────────────────────────── + +export async function endMembership( + membershipId: string, + portId: string, + data: EndMembershipInput, + meta: AuditMeta, +): Promise { + const existing = await loadMembershipScoped(membershipId, portId); + + const rows = await db + .update(companyMemberships) + .set({ endDate: data.endDate, updatedAt: new Date() }) + .where(eq(companyMemberships.id, membershipId)) + .returning(); + + const updated = rows[0]!; + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'update', + entityType: 'company_membership', + entityId: membershipId, + oldValue: { endDate: existing.endDate }, + newValue: { endDate: updated.endDate }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'company_membership:ended', { + membershipId, + companyId: updated.companyId, + clientId: updated.clientId, + }); + + return updated; +} + +// ─── Set Primary (atomic) ──────────────────────────────────────────────────── + +/** + * Marks this membership as primary for its company, un-primary-ing any other + * memberships of the same company atomically. + */ +export async function setPrimary( + membershipId: string, + portId: string, + meta: AuditMeta, +): Promise { + // Tenant-scoped load (outside tx is fine — we re-read inside). + const existing = await loadMembershipScoped(membershipId, portId); + + return await withTransaction(async (tx) => { + await tx + .update(companyMemberships) + .set({ isPrimary: false, updatedAt: new Date() }) + .where( + and( + eq(companyMemberships.companyId, existing.companyId), + ne(companyMemberships.id, membershipId), + ), + ); + + const rows = await tx + .update(companyMemberships) + .set({ isPrimary: true, updatedAt: new Date() }) + .where(eq(companyMemberships.id, membershipId)) + .returning(); + + const updated = rows[0]!; + + void createAuditLog({ + userId: meta.userId, + portId, + action: 'update', + entityType: 'company_membership', + entityId: membershipId, + oldValue: { isPrimary: existing.isPrimary }, + newValue: { isPrimary: true }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + emitToRoom(`port:${portId}`, 'company_membership:updated', { + membershipId, + changedFields: ['isPrimary'], + }); + + return updated; + }); +} + +// ─── List by Company ───────────────────────────────────────────────────────── + +export async function listByCompany( + companyId: string, + portId: string, + opts?: { activeOnly?: boolean }, +): Promise { + const activeOnly = opts?.activeOnly ?? true; + + // Verify the company belongs to the port (prevents cross-tenant enumeration). + const company = await db.query.companies.findFirst({ + where: and(eq(companies.id, companyId), eq(companies.portId, portId)), + }); + if (!company) throw new NotFoundError('Company'); + + const conditions = [eq(companyMemberships.companyId, companyId)]; + if (activeOnly) conditions.push(isNull(companyMemberships.endDate)); + + return await db + .select() + .from(companyMemberships) + .where(and(...conditions)) + .orderBy(desc(companyMemberships.isPrimary), desc(companyMemberships.startDate)); +} + +// ─── List by Client ────────────────────────────────────────────────────────── + +export async function listByClient( + clientId: string, + portId: string, + opts?: { activeOnly?: boolean }, +): Promise { + const activeOnly = opts?.activeOnly ?? true; + + // Verify the client belongs to the port. + const client = await db.query.clients.findFirst({ + where: and(eq(clients.id, clientId), eq(clients.portId, portId)), + }); + if (!client) throw new NotFoundError('Client'); + + const conditions = [eq(companyMemberships.clientId, clientId)]; + if (activeOnly) conditions.push(isNull(companyMemberships.endDate)); + + return await db + .select() + .from(companyMemberships) + .where(and(...conditions)) + .orderBy(desc(companyMemberships.isPrimary), desc(companyMemberships.startDate)); +} diff --git a/src/lib/socket/events.ts b/src/lib/socket/events.ts index 284de3d..36fcf59 100644 --- a/src/lib/socket/events.ts +++ b/src/lib/socket/events.ts @@ -91,6 +91,22 @@ export interface ServerToClientEvents { 'company:updated': (payload: { companyId: string; changedFields: string[] }) => void; 'company:archived': (payload: { companyId: string }) => void; + // Company membership events + 'company_membership:added': (payload: { + membershipId: string; + companyId: string; + clientId: string; + }) => void; + 'company_membership:updated': (payload: { + membershipId: string; + changedFields: string[]; + }) => void; + 'company_membership:ended': (payload: { + membershipId: string; + companyId: string; + clientId: 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/company-memberships.ts b/src/lib/validators/company-memberships.ts new file mode 100644 index 0000000..870fdbf --- /dev/null +++ b/src/lib/validators/company-memberships.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +export const ROLES = [ + 'director', + 'officer', + 'broker', + 'representative', + 'legal_counsel', + 'employee', + 'shareholder', + 'other', +] as const; + +export const addMembershipSchema = z.object({ + clientId: z.string().min(1), + role: z.enum(ROLES), + roleDetail: z.string().optional(), + startDate: z.coerce.date(), + isPrimary: z.boolean().optional().default(false), + notes: z.string().optional(), +}); + +export const updateMembershipSchema = z.object({ + role: z.enum(ROLES).optional(), + roleDetail: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + isPrimary: z.boolean().optional(), +}); + +export const endMembershipSchema = z.object({ + endDate: z.coerce.date(), +}); + +export type AddMembershipInput = z.infer; +export type UpdateMembershipInput = z.infer; +export type EndMembershipInput = z.infer; diff --git a/tests/unit/services/company-memberships.test.ts b/tests/unit/services/company-memberships.test.ts new file mode 100644 index 0000000..6ad3855 --- /dev/null +++ b/tests/unit/services/company-memberships.test.ts @@ -0,0 +1,424 @@ +import { describe, it, expect } from 'vitest'; +import { + addMembership, + updateMembership, + endMembership, + setPrimary, + listByCompany, + listByClient, +} from '@/lib/services/company-memberships.service'; +import { makeCompany, makeClient, makePort, makeAuditMeta } from '../../helpers/factories'; +import { db } from '@/lib/db'; +import { companyMemberships } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; + +describe('company-memberships.service — addMembership', () => { + it('creates a membership for a valid client + company in the same port', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + const client = await makeClient({ portId: port.id }); + + const membership = await addMembership( + company.id, + port.id, + { + clientId: client.id, + role: 'director', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: port.id }), + ); + + expect(membership.id).toBeTruthy(); + expect(membership.companyId).toBe(company.id); + expect(membership.clientId).toBe(client.id); + expect(membership.role).toBe('director'); + expect(membership.endDate).toBeNull(); + }); + + it('throws ValidationError when company not found', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + + await expect( + addMembership( + 'nonexistent-company', + port.id, + { + clientId: client.id, + role: 'director', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: port.id }), + ), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it('throws ValidationError when client not found', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + + await expect( + addMembership( + company.id, + port.id, + { + clientId: 'nonexistent-client', + role: 'director', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: port.id }), + ), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it('throws ValidationError when client is in a different port (cross-tenant)', async () => { + const portA = await makePort(); + const portB = await makePort(); + const company = await makeCompany({ portId: portA.id }); + const clientInB = await makeClient({ portId: portB.id }); + + await expect( + addMembership( + company.id, + portA.id, + { + clientId: clientInB.id, + role: 'director', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: portA.id }), + ), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it('throws ConflictError when exact duplicate (companyId + clientId + role + startDate)', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + const client = await makeClient({ portId: port.id }); + + const startDate = new Date('2026-01-01'); + + await addMembership( + company.id, + port.id, + { clientId: client.id, role: 'director', startDate, isPrimary: false }, + makeAuditMeta({ portId: port.id }), + ); + + await expect( + addMembership( + company.id, + port.id, + { clientId: client.id, role: 'director', startDate, isPrimary: false }, + makeAuditMeta({ portId: port.id }), + ), + ).rejects.toBeInstanceOf(ConflictError); + }); +}); + +describe('company-memberships.service — updateMembership', () => { + it('updates fields', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + const client = await makeClient({ portId: port.id }); + + const membership = await addMembership( + company.id, + port.id, + { + clientId: client.id, + role: 'officer', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: port.id }), + ); + + const updated = await updateMembership( + membership.id, + port.id, + { role: 'director', notes: 'Promoted' }, + makeAuditMeta({ portId: port.id }), + ); + + expect(updated.role).toBe('director'); + expect(updated.notes).toBe('Promoted'); + }); + + it('throws NotFoundError for cross-tenant', async () => { + const portA = await makePort(); + const portB = await makePort(); + const company = await makeCompany({ portId: portB.id }); + const client = await makeClient({ portId: portB.id }); + + const membership = await addMembership( + company.id, + portB.id, + { + clientId: client.id, + role: 'director', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: portB.id }), + ); + + await expect( + updateMembership( + membership.id, + portA.id, + { notes: 'Hijack' }, + makeAuditMeta({ portId: portA.id }), + ), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); + +describe('company-memberships.service — setPrimary', () => { + it('sets only one membership as primary per company (atomic un-primary others)', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + const clientA = await makeClient({ portId: port.id }); + const clientB = await makeClient({ portId: port.id }); + const clientC = await makeClient({ portId: port.id }); + + const m1 = await addMembership( + company.id, + port.id, + { + clientId: clientA.id, + role: 'director', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: port.id }), + ); + const m2 = await addMembership( + company.id, + port.id, + { + clientId: clientB.id, + role: 'officer', + startDate: new Date('2026-02-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: port.id }), + ); + const m3 = await addMembership( + company.id, + port.id, + { + clientId: clientC.id, + role: 'broker', + startDate: new Date('2026-03-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: port.id }), + ); + + // Mark all primary via the service — only the last call should leave a + // single primary survivor (m3). + await setPrimary(m1.id, port.id, makeAuditMeta({ portId: port.id })); + await setPrimary(m2.id, port.id, makeAuditMeta({ portId: port.id })); + await setPrimary(m3.id, port.id, makeAuditMeta({ portId: port.id })); + + const rows = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.companyId, company.id)); + + const primaryRows = rows.filter((r) => r.isPrimary); + expect(primaryRows).toHaveLength(1); + expect(primaryRows[0]!.id).toBe(m3.id); + }); + + it('throws NotFoundError for cross-tenant membership', async () => { + const portA = await makePort(); + const portB = await makePort(); + const company = await makeCompany({ portId: portB.id }); + const client = await makeClient({ portId: portB.id }); + + const membership = await addMembership( + company.id, + portB.id, + { + clientId: client.id, + role: 'director', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: portB.id }), + ); + + await expect( + setPrimary(membership.id, portA.id, makeAuditMeta({ portId: portA.id })), + ).rejects.toBeInstanceOf(NotFoundError); + }); +}); + +describe('company-memberships.service — endMembership', () => { + it('sets endDate', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + const client = await makeClient({ portId: port.id }); + + const membership = await addMembership( + company.id, + port.id, + { + clientId: client.id, + role: 'director', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: port.id }), + ); + + const endDate = new Date('2026-06-30'); + const ended = await endMembership( + membership.id, + port.id, + { endDate }, + makeAuditMeta({ portId: port.id }), + ); + + expect(ended.endDate).not.toBeNull(); + expect(ended.endDate!.getTime()).toBe(endDate.getTime()); + }); +}); + +describe('company-memberships.service — listByCompany / listByClient', () => { + it('returns active memberships only by default', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + const clientA = await makeClient({ portId: port.id }); + const clientB = await makeClient({ portId: port.id }); + + const active = await addMembership( + company.id, + port.id, + { + clientId: clientA.id, + role: 'director', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: port.id }), + ); + const endedMembership = await addMembership( + company.id, + port.id, + { + clientId: clientB.id, + role: 'officer', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: port.id }), + ); + await endMembership( + endedMembership.id, + port.id, + { endDate: new Date('2026-03-01') }, + makeAuditMeta({ portId: port.id }), + ); + + const results = await listByCompany(company.id, port.id); + const ids = results.map((m) => m.id); + expect(ids).toContain(active.id); + expect(ids).not.toContain(endedMembership.id); + }); + + it('includes ended memberships when activeOnly=false', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + const client = await makeClient({ portId: port.id }); + + const membership = await addMembership( + company.id, + port.id, + { + clientId: client.id, + role: 'director', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: port.id }), + ); + await endMembership( + membership.id, + port.id, + { endDate: new Date('2026-02-01') }, + makeAuditMeta({ portId: port.id }), + ); + + const resultsActive = await listByCompany(company.id, port.id); + expect(resultsActive.map((m) => m.id)).not.toContain(membership.id); + + const resultsAll = await listByCompany(company.id, port.id, { activeOnly: false }); + expect(resultsAll.map((m) => m.id)).toContain(membership.id); + }); + + it('is tenant-scoped (listByCompany throws NotFoundError for cross-tenant company)', async () => { + const portA = await makePort(); + const portB = await makePort(); + const companyInB = await makeCompany({ portId: portB.id }); + + await expect(listByCompany(companyInB.id, portA.id)).rejects.toBeInstanceOf(NotFoundError); + }); + + it('is tenant-scoped (listByClient throws NotFoundError for cross-tenant client)', async () => { + const portA = await makePort(); + const portB = await makePort(); + const clientInB = await makeClient({ portId: portB.id }); + + await expect(listByClient(clientInB.id, portA.id)).rejects.toBeInstanceOf(NotFoundError); + }); + + it('listByClient returns active memberships only by default', async () => { + const port = await makePort(); + const companyA = await makeCompany({ portId: port.id }); + const companyB = await makeCompany({ portId: port.id }); + const client = await makeClient({ portId: port.id }); + + const active = await addMembership( + companyA.id, + port.id, + { + clientId: client.id, + role: 'director', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: port.id }), + ); + const endedMembership = await addMembership( + companyB.id, + port.id, + { + clientId: client.id, + role: 'officer', + startDate: new Date('2026-01-01'), + isPrimary: false, + }, + makeAuditMeta({ portId: port.id }), + ); + await endMembership( + endedMembership.id, + port.id, + { endDate: new Date('2026-03-01') }, + makeAuditMeta({ portId: port.id }), + ); + + const results = await listByClient(client.id, port.id); + const ids = results.map((m) => m.id); + expect(ids).toContain(active.id); + expect(ids).not.toContain(endedMembership.id); + }); +}); diff --git a/tests/unit/validators.test.ts b/tests/unit/validators.test.ts index 2d86fc5..ef1c57f 100644 --- a/tests/unit/validators.test.ts +++ b/tests/unit/validators.test.ts @@ -7,6 +7,7 @@ import { createWebhookSchema, updateWebhookSchema } from '@/lib/validators/webho import { createFieldSchema, updateFieldSchema } from '@/lib/validators/custom-fields'; import { createYachtSchema, transferOwnershipSchema } from '@/lib/validators/yachts'; import { createCompanySchema } from '@/lib/validators/companies'; +import { addMembershipSchema } from '@/lib/validators/company-memberships'; // ─── Client schemas ─────────────────────────────────────────────────────────── @@ -454,3 +455,31 @@ describe('createCompanySchema', () => { expect(result.success).toBe(true); }); }); + +// ─── Company membership schemas ────────────────────────────────────────────── + +describe('addMembershipSchema', () => { + const validInput = { + clientId: 'client-uuid-1', + role: 'director' as const, + startDate: '2026-01-01', + }; + + it('rejects missing clientId', () => { + const result = addMembershipSchema.safeParse({ + role: 'director', + startDate: '2026-01-01', + }); + expect(result.success).toBe(false); + }); + + it('rejects invalid role', () => { + const result = addMembershipSchema.safeParse({ ...validInput, role: 'janitor' }); + expect(result.success).toBe(false); + }); + + it('accepts minimal valid input', () => { + const result = addMembershipSchema.safeParse(validInput); + expect(result.success).toBe(true); + }); +});