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) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-24 12:07:58 +02:00
parent 037f2544e8
commit 15a79e7990
5 changed files with 816 additions and 0 deletions

View File

@@ -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<CompanyMembership> {
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<CompanyMembership> {
// 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<CompanyMembership> {
const existing = await loadMembershipScoped(membershipId, portId);
const { diff } = diffEntity(
existing as unknown as Record<string, unknown>,
data as Record<string, unknown>,
);
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<string, unknown>,
newValue: data as Record<string, unknown>,
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<CompanyMembership> {
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<CompanyMembership> {
// 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<CompanyMembership[]> {
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<CompanyMembership[]> {
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));
}

View File

@@ -91,6 +91,22 @@ export interface ServerToClientEvents {
'company:updated': (payload: { companyId: string; changedFields: string[] }) => void; 'company:updated': (payload: { companyId: string; changedFields: string[] }) => void;
'company:archived': (payload: { companyId: 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 events
'document:created': (payload: { documentId: string; type?: string; interestId?: string }) => void; 'document:created': (payload: { documentId: string; type?: string; interestId?: string }) => void;
'document:updated': (payload: { documentId: string; changedFields?: string[] }) => void; 'document:updated': (payload: { documentId: string; changedFields?: string[] }) => void;

View File

@@ -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<typeof addMembershipSchema>;
export type UpdateMembershipInput = z.infer<typeof updateMembershipSchema>;
export type EndMembershipInput = z.infer<typeof endMembershipSchema>;

View File

@@ -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);
});
});

View File

@@ -7,6 +7,7 @@ import { createWebhookSchema, updateWebhookSchema } from '@/lib/validators/webho
import { createFieldSchema, updateFieldSchema } from '@/lib/validators/custom-fields'; import { createFieldSchema, updateFieldSchema } from '@/lib/validators/custom-fields';
import { createYachtSchema, transferOwnershipSchema } from '@/lib/validators/yachts'; import { createYachtSchema, transferOwnershipSchema } from '@/lib/validators/yachts';
import { createCompanySchema } from '@/lib/validators/companies'; import { createCompanySchema } from '@/lib/validators/companies';
import { addMembershipSchema } from '@/lib/validators/company-memberships';
// ─── Client schemas ─────────────────────────────────────────────────────────── // ─── Client schemas ───────────────────────────────────────────────────────────
@@ -454,3 +455,31 @@ describe('createCompanySchema', () => {
expect(result.success).toBe(true); 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);
});
});