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: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;
|
||||||
|
|||||||
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>;
|
||||||
424
tests/unit/services/company-memberships.test.ts
Normal file
424
tests/unit/services/company-memberships.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user