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