diff --git a/src/app/api/v1/companies/[id]/route.ts b/src/app/api/v1/companies/[id]/route.ts new file mode 100644 index 0000000..9b8e547 --- /dev/null +++ b/src/app/api/v1/companies/[id]/route.ts @@ -0,0 +1,49 @@ +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 { getCompanyById, updateCompany, archiveCompany } from '@/lib/services/companies.service'; +import { updateCompanySchema } from '@/lib/validators/companies'; + +export const getHandler: RouteHandler = async (req, ctx, params) => { + try { + const company = await getCompanyById(params.id!, ctx.portId); + return NextResponse.json({ data: company }); + } catch (error) { + return errorResponse(error); + } +}; + +export const patchHandler: RouteHandler = async (req, ctx, params) => { + try { + const body = await parseBody(req, updateCompanySchema); + const updated = await updateCompany(params.id!, 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 { + await archiveCompany(params.id!, ctx.portId, { + 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 GET = withAuth(withPermission('companies', 'view', getHandler)); +export const PATCH = withAuth(withPermission('companies', 'edit', patchHandler)); +export const DELETE = withAuth(withPermission('companies', 'delete', deleteHandler)); diff --git a/src/app/api/v1/companies/autocomplete/route.ts b/src/app/api/v1/companies/autocomplete/route.ts new file mode 100644 index 0000000..070112f --- /dev/null +++ b/src/app/api/v1/companies/autocomplete/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { autocomplete } from '@/lib/services/companies.service'; + +export const autocompleteHandler: RouteHandler = async (req, ctx) => { + try { + const q = req.nextUrl.searchParams.get('q'); + if (!q) { + return NextResponse.json({ data: [] }); + } + const companies = await autocomplete(ctx.portId, q); + return NextResponse.json({ data: companies }); + } catch (error) { + return errorResponse(error); + } +}; + +export const GET = withAuth(withPermission('companies', 'view', autocompleteHandler)); diff --git a/src/app/api/v1/companies/route.ts b/src/app/api/v1/companies/route.ts new file mode 100644 index 0000000..747dfa6 --- /dev/null +++ b/src/app/api/v1/companies/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; +import { parseQuery, parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { listCompanies, createCompany } from '@/lib/services/companies.service'; +import { listCompaniesSchema, createCompanySchema } from '@/lib/validators/companies'; + +export const listHandler: RouteHandler = async (req, ctx) => { + try { + const query = parseQuery(req, listCompaniesSchema); + const result = await listCompanies(ctx.portId, query); + const { page, limit } = query; + const totalPages = Math.ceil(result.total / limit); + return NextResponse.json({ + data: result.data, + pagination: { + page, + pageSize: limit, + total: result.total, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }, + }); + } catch (error) { + return errorResponse(error); + } +}; + +export const createHandler: RouteHandler = async (req, ctx) => { + try { + const body = await parseBody(req, createCompanySchema); + const company = await createCompany(ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: company }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } +}; + +export const GET = withAuth(withPermission('companies', 'view', listHandler)); +export const POST = withAuth(withPermission('companies', 'create', createHandler)); diff --git a/tests/integration/api/companies.test.ts b/tests/integration/api/companies.test.ts new file mode 100644 index 0000000..5b1ca76 --- /dev/null +++ b/tests/integration/api/companies.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect } from 'vitest'; + +import { listHandler, createHandler } from '@/app/api/v1/companies/route'; +import { getHandler, patchHandler, deleteHandler } from '@/app/api/v1/companies/[id]/route'; +import { autocompleteHandler } from '@/app/api/v1/companies/autocomplete/route'; +import { db } from '@/lib/db'; +import { companies } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester'; +import { makePort, makeCompany, makeFullPermissions } from '../../helpers/factories'; + +describe('POST /api/v1/companies', () => { + it('creates a company and returns 201', async () => { + const port = await makePort(); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const name = `Acme Corp ${Math.random().toString(36).slice(2, 8)}`; + const req = makeMockRequest('POST', 'http://localhost/api/v1/companies', { + body: { name }, + }); + const res = await createHandler(req, ctx, {}); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.data.name).toBe(name); + expect(body.data.portId).toBe(port.id); + expect(body.data.status).toBe('active'); + }); + + it('returns 409 (ConflictError) on duplicate name case-insensitive in same port', async () => { + const port = await makePort(); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const name = `DupeCo ${Math.random().toString(36).slice(2, 8)}`; + + const req1 = makeMockRequest('POST', 'http://localhost/api/v1/companies', { + body: { name }, + }); + const res1 = await createHandler(req1, ctx, {}); + expect(res1.status).toBe(201); + + // Same name with different case — should still conflict. + const req2 = makeMockRequest('POST', 'http://localhost/api/v1/companies', { + body: { name: name.toUpperCase() }, + }); + const res2 = await createHandler(req2, ctx, {}); + expect(res2.status).toBe(409); + }); + + it('returns 400 on missing name', async () => { + const port = await makePort(); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('POST', 'http://localhost/api/v1/companies', { + body: {}, + }); + const res = await createHandler(req, ctx, {}); + expect(res.status).toBe(400); + }); + + it('allows same name in different port', async () => { + const portA = await makePort(); + const portB = await makePort(); + const name = `SharedName ${Math.random().toString(36).slice(2, 8)}`; + + const ctxA = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() }); + const resA = await createHandler( + makeMockRequest('POST', 'http://localhost/api/v1/companies', { body: { name } }), + ctxA, + {}, + ); + expect(resA.status).toBe(201); + + const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); + const resB = await createHandler( + makeMockRequest('POST', 'http://localhost/api/v1/companies', { body: { name } }), + ctxB, + {}, + ); + expect(resB.status).toBe(201); + }); +}); + +describe('GET /api/v1/companies', () => { + it('returns tenant-scoped companies with pagination shape', async () => { + const port = await makePort(); + await makeCompany({ portId: port.id, overrides: { name: 'ListedCo' } }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest( + 'GET', + 'http://localhost/api/v1/companies?page=1&limit=20&order=desc', + ); + const res = await listHandler(req, ctx, {}); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.some((c: { name: string }) => c.name === 'ListedCo')).toBe(true); + expect(body.pagination.page).toBe(1); + expect(body.pagination.pageSize).toBe(20); + expect(typeof body.pagination.total).toBe('number'); + expect(typeof body.pagination.totalPages).toBe('number'); + expect(typeof body.pagination.hasNextPage).toBe('boolean'); + expect(typeof body.pagination.hasPreviousPage).toBe('boolean'); + }); + + it('filters by status', async () => { + const port = await makePort(); + await makeCompany({ + portId: port.id, + overrides: { name: 'ActiveCo', status: 'active' }, + }); + await makeCompany({ + portId: port.id, + overrides: { name: 'DissolvedCo', status: 'dissolved' }, + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest( + 'GET', + 'http://localhost/api/v1/companies?page=1&limit=20&order=desc&status=dissolved', + ); + const res = await listHandler(req, ctx, {}); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.every((c: { status: string }) => c.status === 'dissolved')).toBe(true); + expect(body.data.some((c: { name: string }) => c.name === 'DissolvedCo')).toBe(true); + expect(body.data.some((c: { name: string }) => c.name === 'ActiveCo')).toBe(false); + }); +}); + +describe('GET /api/v1/companies/[id]', () => { + it('returns company for valid id + port', async () => { + const port = await makePort(); + const company = await makeCompany({ + portId: port.id, + overrides: { name: 'DetailCo' }, + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('GET', `http://localhost/api/v1/companies/${company.id}`); + const res = await getHandler(req, ctx, { id: company.id }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.id).toBe(company.id); + expect(body.data.name).toBe('DetailCo'); + }); + + it('returns 404 for 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}`); + const res = await getHandler(req, ctx, { id: company.id }); + expect(res.status).toBe(404); + }); +}); + +describe('PATCH /api/v1/companies/[id]', () => { + it('updates allowed fields', async () => { + const port = await makePort(); + const company = await makeCompany({ + portId: port.id, + overrides: { name: 'BeforeCo' }, + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('PATCH', `http://localhost/api/v1/companies/${company.id}`, { + body: { name: 'AfterCo', notes: 'updated notes' }, + }); + const res = await patchHandler(req, ctx, { id: company.id }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.name).toBe('AfterCo'); + expect(body.data.notes).toBe('updated notes'); + }); + + it('returns 404 for 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('PATCH', `http://localhost/api/v1/companies/${company.id}`, { + body: { name: 'hijack' }, + }); + const res = await patchHandler(req, ctx, { id: company.id }); + expect(res.status).toBe(404); + }); +}); + +describe('DELETE /api/v1/companies/[id]', () => { + it('archives the company (204)', async () => { + const port = await makePort(); + const company = await makeCompany({ portId: port.id }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('DELETE', `http://localhost/api/v1/companies/${company.id}`); + const res = await deleteHandler(req, ctx, { id: company.id }); + expect(res.status).toBe(204); + const [row] = await db.select().from(companies).where(eq(companies.id, company.id)); + expect(row?.archivedAt).not.toBeNull(); + }); +}); + +describe('GET /api/v1/companies/autocomplete', () => { + it('returns matching companies by name', async () => { + const port = await makePort(); + await makeCompany({ + portId: port.id, + overrides: { name: 'AutoCompleteCoMatch One' }, + }); + await makeCompany({ + portId: port.id, + overrides: { name: 'OtherCompanyName' }, + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest( + 'GET', + 'http://localhost/api/v1/companies/autocomplete?q=AutoCompleteCoMatch', + ); + const res = await autocompleteHandler(req, ctx, {}); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.length).toBeGreaterThanOrEqual(1); + expect(body.data.every((c: { name: string }) => c.name.includes('AutoCompleteCoMatch'))).toBe( + true, + ); + }); + + it('returns empty array when q missing', async () => { + const port = await makePort(); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('GET', 'http://localhost/api/v1/companies/autocomplete'); + const res = await autocompleteHandler(req, ctx, {}); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual([]); + }); +});