feat(api): company memberships (add/update/end/set-primary)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
50
src/app/api/v1/companies/[id]/members/[mid]/route.ts
Normal file
50
src/app/api/v1/companies/[id]/members/[mid]/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { endMembership, updateMembership } from '@/lib/services/company-memberships.service';
|
||||
import { endMembershipSchema, updateMembershipSchema } from '@/lib/validators/company-memberships';
|
||||
|
||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateMembershipSchema);
|
||||
const updated = await updateMembership(params.mid!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
let endDate = new Date();
|
||||
const text = await req.text();
|
||||
if (text.length > 0) {
|
||||
const parsed = endMembershipSchema.parse(JSON.parse(text));
|
||||
endDate = parsed.endDate;
|
||||
}
|
||||
await endMembership(
|
||||
params.mid!,
|
||||
ctx.portId,
|
||||
{ endDate },
|
||||
{
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
);
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const PATCH = withAuth(withPermission('memberships', 'manage', patchHandler));
|
||||
export const DELETE = withAuth(withPermission('memberships', 'manage', deleteHandler));
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { setPrimary } from '@/lib/services/company-memberships.service';
|
||||
|
||||
export const setPrimaryHandler: RouteHandler = async (_req, ctx, params) => {
|
||||
try {
|
||||
const membership = await setPrimary(params.mid!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: membership });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = withAuth(withPermission('memberships', 'manage', setPrimaryHandler));
|
||||
43
src/app/api/v1/companies/[id]/members/route.ts
Normal file
43
src/app/api/v1/companies/[id]/members/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { addMembership, listByCompany } from '@/lib/services/company-memberships.service';
|
||||
import { addMembershipSchema } from '@/lib/validators/company-memberships';
|
||||
|
||||
const listQuerySchema = z.object({
|
||||
activeOnly: z
|
||||
.enum(['true', 'false'])
|
||||
.transform((v) => v === 'true')
|
||||
.default('true'),
|
||||
});
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const { activeOnly } = parseQuery(req, listQuerySchema);
|
||||
const memberships = await listByCompany(params.id!, ctx.portId, { activeOnly });
|
||||
return NextResponse.json({ data: memberships });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, addMembershipSchema);
|
||||
const membership = await addMembership(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: membership }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const GET = withAuth(withPermission('memberships', 'view', listHandler));
|
||||
export const POST = withAuth(withPermission('memberships', 'manage', createHandler));
|
||||
434
tests/integration/api/memberships.test.ts
Normal file
434
tests/integration/api/memberships.test.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { listHandler, createHandler } from '@/app/api/v1/companies/[id]/members/route';
|
||||
import { patchHandler, deleteHandler } from '@/app/api/v1/companies/[id]/members/[mid]/route';
|
||||
import { setPrimaryHandler } from '@/app/api/v1/companies/[id]/members/[mid]/set-primary/route';
|
||||
import { db } from '@/lib/db';
|
||||
import { companyMemberships } from '@/lib/db/schema';
|
||||
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
|
||||
import { makeClient, makeCompany, makeFullPermissions, makePort } from '../../helpers/factories';
|
||||
|
||||
describe('GET /api/v1/companies/[id]/members', () => {
|
||||
it('returns active memberships for the company', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
const client1 = await makeClient({ portId: port.id });
|
||||
const client2 = await makeClient({ portId: port.id });
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
|
||||
await createHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body: {
|
||||
clientId: client1.id,
|
||||
role: 'director',
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
await createHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body: {
|
||||
clientId: client2.id,
|
||||
role: 'officer',
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
|
||||
const req = makeMockRequest('GET', `http://localhost/api/v1/companies/${company.id}/members`);
|
||||
const res = await listHandler(req, ctx, { id: company.id });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(body.data.length).toBe(2);
|
||||
expect(body.data.every((m: { endDate: string | null }) => m.endDate === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('includes ended when activeOnly=false', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
const client1 = await makeClient({ portId: port.id });
|
||||
const client2 = await makeClient({ portId: port.id });
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
|
||||
// Active membership.
|
||||
await createHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body: {
|
||||
clientId: client1.id,
|
||||
role: 'director',
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
|
||||
// Create then end another membership.
|
||||
const createRes = await createHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body: {
|
||||
clientId: client2.id,
|
||||
role: 'officer',
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
const createdBody = await createRes.json();
|
||||
const toEndId = createdBody.data.id as string;
|
||||
|
||||
const delRes = await deleteHandler(
|
||||
makeMockRequest(
|
||||
'DELETE',
|
||||
`http://localhost/api/v1/companies/${company.id}/members/${toEndId}`,
|
||||
),
|
||||
ctx,
|
||||
{ id: company.id, mid: toEndId },
|
||||
);
|
||||
expect(delRes.status).toBe(204);
|
||||
|
||||
// Default — active only.
|
||||
const activeOnlyRes = await listHandler(
|
||||
makeMockRequest('GET', `http://localhost/api/v1/companies/${company.id}/members`),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
const activeBody = await activeOnlyRes.json();
|
||||
expect(activeBody.data.length).toBe(1);
|
||||
|
||||
// activeOnly=false.
|
||||
const allRes = await listHandler(
|
||||
makeMockRequest(
|
||||
'GET',
|
||||
`http://localhost/api/v1/companies/${company.id}/members?activeOnly=false`,
|
||||
),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
const allBody = await allRes.json();
|
||||
expect(allBody.data.length).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 404 when company does not exist or cross-tenant', async () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
const company = await makeCompany({ portId: portA.id });
|
||||
const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() });
|
||||
const req = makeMockRequest('GET', `http://localhost/api/v1/companies/${company.id}/members`);
|
||||
const res = await listHandler(req, ctx, { id: company.id });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/companies/[id]/members', () => {
|
||||
it('adds a membership (201)', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
const client = await makeClient({ portId: port.id });
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
const req = makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
role: 'director',
|
||||
startDate: new Date().toISOString(),
|
||||
isPrimary: true,
|
||||
},
|
||||
});
|
||||
const res = await createHandler(req, ctx, { id: company.id });
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.data.companyId).toBe(company.id);
|
||||
expect(body.data.clientId).toBe(client.id);
|
||||
expect(body.data.role).toBe('director');
|
||||
expect(body.data.isPrimary).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 400 when clientId not found or cross-tenant', async () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
const company = await makeCompany({ portId: portA.id });
|
||||
const clientOtherPort = await makeClient({ portId: portB.id });
|
||||
const ctx = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() });
|
||||
const req = makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body: {
|
||||
clientId: clientOtherPort.id,
|
||||
role: 'director',
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
const res = await createHandler(req, ctx, { id: company.id });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 409 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 ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
const startDate = new Date().toISOString();
|
||||
const body = {
|
||||
clientId: client.id,
|
||||
role: 'director' as const,
|
||||
startDate,
|
||||
};
|
||||
|
||||
const first = await createHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body,
|
||||
}),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
expect(first.status).toBe(201);
|
||||
|
||||
const dupe = await createHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body,
|
||||
}),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
expect(dupe.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/companies/[id]/members/[mid]', () => {
|
||||
it('updates membership fields', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
const client = await makeClient({ portId: port.id });
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
|
||||
const createRes = await createHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
role: 'director',
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
const created = (await createRes.json()).data;
|
||||
|
||||
const patchRes = await patchHandler(
|
||||
makeMockRequest(
|
||||
'PATCH',
|
||||
`http://localhost/api/v1/companies/${company.id}/members/${created.id}`,
|
||||
{
|
||||
body: {
|
||||
role: 'officer',
|
||||
notes: 'promoted',
|
||||
},
|
||||
},
|
||||
),
|
||||
ctx,
|
||||
{ id: company.id, mid: created.id },
|
||||
);
|
||||
expect(patchRes.status).toBe(200);
|
||||
const body = await patchRes.json();
|
||||
expect(body.data.role).toBe('officer');
|
||||
expect(body.data.notes).toBe('promoted');
|
||||
});
|
||||
|
||||
it('returns 404 for cross-tenant membership', async () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
const company = await makeCompany({ portId: portA.id });
|
||||
const client = await makeClient({ portId: portA.id });
|
||||
const ctxA = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() });
|
||||
|
||||
const createRes = await createHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
role: 'director',
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
ctxA,
|
||||
{ id: company.id },
|
||||
);
|
||||
const created = (await createRes.json()).data;
|
||||
|
||||
const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() });
|
||||
const patchRes = await patchHandler(
|
||||
makeMockRequest(
|
||||
'PATCH',
|
||||
`http://localhost/api/v1/companies/${company.id}/members/${created.id}`,
|
||||
{ body: { role: 'officer' } },
|
||||
),
|
||||
ctxB,
|
||||
{ id: company.id, mid: created.id },
|
||||
);
|
||||
expect(patchRes.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/companies/[id]/members/[mid]', () => {
|
||||
it('sets endDate to now when no body provided (204)', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
const client = await makeClient({ portId: port.id });
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
|
||||
const createRes = await createHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
role: 'director',
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
const created = (await createRes.json()).data;
|
||||
|
||||
const before = new Date();
|
||||
const delRes = await deleteHandler(
|
||||
makeMockRequest(
|
||||
'DELETE',
|
||||
`http://localhost/api/v1/companies/${company.id}/members/${created.id}`,
|
||||
),
|
||||
ctx,
|
||||
{ id: company.id, mid: created.id },
|
||||
);
|
||||
expect(delRes.status).toBe(204);
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(eq(companyMemberships.id, created.id));
|
||||
expect(row?.endDate).not.toBeNull();
|
||||
expect(row!.endDate!.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000);
|
||||
});
|
||||
|
||||
it('sets endDate from body when provided', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
const client = await makeClient({ portId: port.id });
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
|
||||
const createRes = await createHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
role: 'director',
|
||||
startDate: new Date('2025-01-01').toISOString(),
|
||||
},
|
||||
}),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
const created = (await createRes.json()).data;
|
||||
|
||||
const explicitEnd = new Date('2026-06-01T00:00:00.000Z');
|
||||
const delRes = await deleteHandler(
|
||||
makeMockRequest(
|
||||
'DELETE',
|
||||
`http://localhost/api/v1/companies/${company.id}/members/${created.id}`,
|
||||
{ body: { endDate: explicitEnd.toISOString() } },
|
||||
),
|
||||
ctx,
|
||||
{ id: company.id, mid: created.id },
|
||||
);
|
||||
expect(delRes.status).toBe(204);
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(eq(companyMemberships.id, created.id));
|
||||
expect(row?.endDate?.toISOString()).toBe(explicitEnd.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/companies/[id]/members/[mid]/set-primary', () => {
|
||||
it('sets only one membership as primary per company', async () => {
|
||||
const port = await makePort();
|
||||
const company = await makeCompany({ portId: port.id });
|
||||
const client1 = await makeClient({ portId: port.id });
|
||||
const client2 = await makeClient({ portId: port.id });
|
||||
const client3 = await makeClient({ portId: port.id });
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
|
||||
// M1: primary from the start.
|
||||
const m1Res = await createHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body: {
|
||||
clientId: client1.id,
|
||||
role: 'director',
|
||||
startDate: new Date().toISOString(),
|
||||
isPrimary: true,
|
||||
},
|
||||
}),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
const m1 = (await m1Res.json()).data;
|
||||
|
||||
// M2, M3 — not primary.
|
||||
const m2Res = await createHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body: {
|
||||
clientId: client2.id,
|
||||
role: 'officer',
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
const m2 = (await m2Res.json()).data;
|
||||
|
||||
const m3Res = await createHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/companies/${company.id}/members`, {
|
||||
body: {
|
||||
clientId: client3.id,
|
||||
role: 'employee',
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
ctx,
|
||||
{ id: company.id },
|
||||
);
|
||||
const m3 = (await m3Res.json()).data;
|
||||
|
||||
// Promote M2.
|
||||
const setPrimRes = await setPrimaryHandler(
|
||||
makeMockRequest(
|
||||
'POST',
|
||||
`http://localhost/api/v1/companies/${company.id}/members/${m2.id}/set-primary`,
|
||||
),
|
||||
ctx,
|
||||
{ id: company.id, mid: m2.id },
|
||||
);
|
||||
expect(setPrimRes.status).toBe(200);
|
||||
const setPrimBody = await setPrimRes.json();
|
||||
expect(setPrimBody.data.id).toBe(m2.id);
|
||||
expect(setPrimBody.data.isPrimary).toBe(true);
|
||||
|
||||
// Verify DB state: only M2 is primary.
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(companyMemberships)
|
||||
.where(eq(companyMemberships.companyId, company.id));
|
||||
|
||||
const primary = rows.filter((r) => r.isPrimary);
|
||||
expect(primary.length).toBe(1);
|
||||
expect(primary[0]!.id).toBe(m2.id);
|
||||
|
||||
const m1Row = rows.find((r) => r.id === m1.id);
|
||||
expect(m1Row?.isPrimary).toBe(false);
|
||||
const m3Row = rows.find((r) => r.id === m3.id);
|
||||
expect(m3Row?.isPrimary).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user