From aca45fb1b28c0ab33a69f4ecb3b8813393377aeb Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 12:49:10 +0200 Subject: [PATCH] feat(api): company memberships (add/update/end/set-primary) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v1/companies/[id]/members/[mid]/route.ts | 50 ++ .../[id]/members/[mid]/set-primary/route.ts | 21 + .../api/v1/companies/[id]/members/route.ts | 43 ++ tests/integration/api/memberships.test.ts | 434 ++++++++++++++++++ 4 files changed, 548 insertions(+) create mode 100644 src/app/api/v1/companies/[id]/members/[mid]/route.ts create mode 100644 src/app/api/v1/companies/[id]/members/[mid]/set-primary/route.ts create mode 100644 src/app/api/v1/companies/[id]/members/route.ts create mode 100644 tests/integration/api/memberships.test.ts diff --git a/src/app/api/v1/companies/[id]/members/[mid]/route.ts b/src/app/api/v1/companies/[id]/members/[mid]/route.ts new file mode 100644 index 0000000..bf854f7 --- /dev/null +++ b/src/app/api/v1/companies/[id]/members/[mid]/route.ts @@ -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)); diff --git a/src/app/api/v1/companies/[id]/members/[mid]/set-primary/route.ts b/src/app/api/v1/companies/[id]/members/[mid]/set-primary/route.ts new file mode 100644 index 0000000..287c9a2 --- /dev/null +++ b/src/app/api/v1/companies/[id]/members/[mid]/set-primary/route.ts @@ -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)); diff --git a/src/app/api/v1/companies/[id]/members/route.ts b/src/app/api/v1/companies/[id]/members/route.ts new file mode 100644 index 0000000..88cb847 --- /dev/null +++ b/src/app/api/v1/companies/[id]/members/route.ts @@ -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)); diff --git a/tests/integration/api/memberships.test.ts b/tests/integration/api/memberships.test.ts new file mode 100644 index 0000000..fd201ba --- /dev/null +++ b/tests/integration/api/memberships.test.ts @@ -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); + }); +});