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>;
|
||||
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 { createYachtSchema, transferOwnershipSchema } from '@/lib/validators/yachts';
|
||||
import { createCompanySchema } from '@/lib/validators/companies';
|
||||
import { addMembershipSchema } from '@/lib/validators/company-memberships';
|
||||
|
||||
// ─── Client schemas ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -454,3 +455,31 @@ describe('createCompanySchema', () => {
|
||||
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