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:
311
src/lib/services/company-memberships.service.ts
Normal file
311
src/lib/services/company-memberships.service.ts
Normal 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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
36
src/lib/validators/company-memberships.ts
Normal file
36
src/lib/validators/company-memberships.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user