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: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;

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>;