From 90463269ceaf0c85f2d9269af33cf870277165cb Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 12:40:51 +0200 Subject: [PATCH] feat(api): yacht detail, patch, archive, transfer, history, autocomplete --- .../v1/yachts/[id]/ownership-history/route.ts | 16 + src/app/api/v1/yachts/[id]/route.ts | 49 +++ src/app/api/v1/yachts/[id]/transfer/route.ts | 24 ++ src/app/api/v1/yachts/autocomplete/route.ts | 20 + src/lib/services/yachts.service.ts | 11 + tests/integration/api/yachts-detail.test.ts | 352 ++++++++++++++++++ 6 files changed, 472 insertions(+) create mode 100644 src/app/api/v1/yachts/[id]/ownership-history/route.ts create mode 100644 src/app/api/v1/yachts/[id]/route.ts create mode 100644 src/app/api/v1/yachts/[id]/transfer/route.ts create mode 100644 src/app/api/v1/yachts/autocomplete/route.ts create mode 100644 tests/integration/api/yachts-detail.test.ts diff --git a/src/app/api/v1/yachts/[id]/ownership-history/route.ts b/src/app/api/v1/yachts/[id]/ownership-history/route.ts new file mode 100644 index 0000000..957ff15 --- /dev/null +++ b/src/app/api/v1/yachts/[id]/ownership-history/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { listOwnershipHistory } from '@/lib/services/yachts.service'; + +export const historyHandler: RouteHandler = async (req, ctx, params) => { + try { + const history = await listOwnershipHistory(params.id!, ctx.portId); + return NextResponse.json({ data: history }); + } catch (error) { + return errorResponse(error); + } +}; + +export const GET = withAuth(withPermission('yachts', 'view', historyHandler)); diff --git a/src/app/api/v1/yachts/[id]/route.ts b/src/app/api/v1/yachts/[id]/route.ts new file mode 100644 index 0000000..b145fe1 --- /dev/null +++ b/src/app/api/v1/yachts/[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 { getYachtById, updateYacht, archiveYacht } from '@/lib/services/yachts.service'; +import { updateYachtSchema } from '@/lib/validators/yachts'; + +export const getHandler: RouteHandler = async (req, ctx, params) => { + try { + const yacht = await getYachtById(params.id!, ctx.portId); + return NextResponse.json({ data: yacht }); + } catch (error) { + return errorResponse(error); + } +}; + +export const patchHandler: RouteHandler = async (req, ctx, params) => { + try { + const body = await parseBody(req, updateYachtSchema); + const updated = await updateYacht(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 archiveYacht(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('yachts', 'view', getHandler)); +export const PATCH = withAuth(withPermission('yachts', 'edit', patchHandler)); +export const DELETE = withAuth(withPermission('yachts', 'delete', deleteHandler)); diff --git a/src/app/api/v1/yachts/[id]/transfer/route.ts b/src/app/api/v1/yachts/[id]/transfer/route.ts new file mode 100644 index 0000000..1a89985 --- /dev/null +++ b/src/app/api/v1/yachts/[id]/transfer/route.ts @@ -0,0 +1,24 @@ +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 { transferOwnership } from '@/lib/services/yachts.service'; +import { transferOwnershipSchema } from '@/lib/validators/yachts'; + +export const transferHandler: RouteHandler = async (req, ctx, params) => { + try { + const body = await parseBody(req, transferOwnershipSchema); + const yacht = await transferOwnership(params.id!, ctx.portId, body, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: yacht }); + } catch (error) { + return errorResponse(error); + } +}; + +export const POST = withAuth(withPermission('yachts', 'transfer', transferHandler)); diff --git a/src/app/api/v1/yachts/autocomplete/route.ts b/src/app/api/v1/yachts/autocomplete/route.ts new file mode 100644 index 0000000..992bbdf --- /dev/null +++ b/src/app/api/v1/yachts/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/yachts.service'; + +export const autocompleteHandler: RouteHandler = async (req, ctx) => { + try { + const q = req.nextUrl.searchParams.get('q'); + if (!q) { + return NextResponse.json({ data: [] }); + } + const yachts = await autocomplete(ctx.portId, q); + return NextResponse.json({ data: yachts }); + } catch (error) { + return errorResponse(error); + } +}; + +export const GET = withAuth(withPermission('yachts', 'view', autocompleteHandler)); diff --git a/src/lib/services/yachts.service.ts b/src/lib/services/yachts.service.ts index 0679cd0..222cef6 100644 --- a/src/lib/services/yachts.service.ts +++ b/src/lib/services/yachts.service.ts @@ -318,6 +318,17 @@ export async function listYachtsForOwner( }); } +// ─── Ownership history ──────────────────────────────────────────────────────── + +export async function listOwnershipHistory(yachtId: string, portId: string) { + // First scope-check the yacht (throws NotFoundError if cross-tenant) + await getYachtById(yachtId, portId); + return await db.query.yachtOwnershipHistory.findMany({ + where: eq(yachtOwnershipHistory.yachtId, yachtId), + orderBy: (t, { desc }) => [desc(t.startDate)], + }); +} + // ─── Autocomplete ───────────────────────────────────────────────────────────── export async function autocomplete(portId: string, q: string) { diff --git a/tests/integration/api/yachts-detail.test.ts b/tests/integration/api/yachts-detail.test.ts new file mode 100644 index 0000000..6f5f2bc --- /dev/null +++ b/tests/integration/api/yachts-detail.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect } from 'vitest'; + +import { getHandler, patchHandler, deleteHandler } from '@/app/api/v1/yachts/[id]/route'; +import { transferHandler } from '@/app/api/v1/yachts/[id]/transfer/route'; +import { historyHandler } from '@/app/api/v1/yachts/[id]/ownership-history/route'; +import { autocompleteHandler } from '@/app/api/v1/yachts/autocomplete/route'; +import { withPermission } from '@/lib/api/helpers'; +import { db } from '@/lib/db'; +import { yachts } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester'; +import { + makePort, + makeClient, + makeCompany, + makeYacht, + makeFullPermissions, + makeSalesAgentPermissions, +} from '../../helpers/factories'; + +describe('GET /api/v1/yachts/[id]', () => { + it('returns the yacht for valid id + port', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'Detail Yacht', + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('GET', `http://localhost/api/v1/yachts/${yacht.id}`); + const res = await getHandler(req, ctx, { id: yacht.id }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.id).toBe(yacht.id); + expect(body.data.name).toBe('Detail Yacht'); + }); + + it('returns 404 for wrong port (tenant isolation)', async () => { + const portA = await makePort(); + const portB = await makePort(); + const client = await makeClient({ portId: portA.id }); + const yacht = await makeYacht({ + portId: portA.id, + ownerType: 'client', + ownerId: client.id, + }); + const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('GET', `http://localhost/api/v1/yachts/${yacht.id}`); + const res = await getHandler(req, ctx, { id: yacht.id }); + expect(res.status).toBe(404); + }); + + it('returns 404 for non-existent id', async () => { + const port = await makePort(); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('GET', 'http://localhost/api/v1/yachts/does-not-exist'); + const res = await getHandler(req, ctx, { id: 'does-not-exist' }); + expect(res.status).toBe(404); + }); +}); + +describe('PATCH /api/v1/yachts/[id]', () => { + it('updates allowed fields', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'Before', + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('PATCH', `http://localhost/api/v1/yachts/${yacht.id}`, { + body: { name: 'After', notes: 'updated notes' }, + }); + const res = await patchHandler(req, ctx, { id: yacht.id }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.name).toBe('After'); + expect(body.data.notes).toBe('updated notes'); + }); + + it('returns 400 when attempting to set currentOwnerId (defensive guard)', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + // Validator strips owner fields, so we need to bypass it to reach the service's defensive guard. + // Test the service layer defense by calling the handler with a payload that the validator + // would accept but which also contains an unknown field that matches the forbidden keys. + // Actually the validator just omits `owner` — additional keys `currentOwnerId` etc. pass + // through Zod's .partial() (which still omits unknown keys by default). + // Zod .strip() is default, so unknown keys are dropped: we assert on the service directly. + const { updateYacht } = await import('@/lib/services/yachts.service'); + await expect( + updateYacht( + yacht.id, + port.id, + { currentOwnerId: 'evil' } as unknown as Parameters[2], + { + userId: ctx.userId, + portId: port.id, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('returns 404 for cross-tenant', async () => { + const portA = await makePort(); + const portB = await makePort(); + const client = await makeClient({ portId: portA.id }); + const yacht = await makeYacht({ + portId: portA.id, + ownerType: 'client', + ownerId: client.id, + }); + const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('PATCH', `http://localhost/api/v1/yachts/${yacht.id}`, { + body: { name: 'hijack' }, + }); + const res = await patchHandler(req, ctx, { id: yacht.id }); + expect(res.status).toBe(404); + }); +}); + +describe('DELETE /api/v1/yachts/[id]', () => { + it('archives the yacht (returns 204)', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('DELETE', `http://localhost/api/v1/yachts/${yacht.id}`); + const res = await deleteHandler(req, ctx, { id: yacht.id }); + expect(res.status).toBe(204); + const [row] = await db.select().from(yachts).where(eq(yachts.id, yacht.id)); + expect(row?.archivedAt).not.toBeNull(); + }); + + it('returns 404 for cross-tenant', async () => { + const portA = await makePort(); + const portB = await makePort(); + const client = await makeClient({ portId: portA.id }); + const yacht = await makeYacht({ + portId: portA.id, + ownerType: 'client', + ownerId: client.id, + }); + const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('DELETE', `http://localhost/api/v1/yachts/${yacht.id}`); + const res = await deleteHandler(req, ctx, { id: yacht.id }); + expect(res.status).toBe(404); + }); +}); + +describe('POST /api/v1/yachts/[id]/transfer', () => { + it('transfers ownership (200) when caller has yachts.transfer', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const company = await makeCompany({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('POST', `http://localhost/api/v1/yachts/${yacht.id}/transfer`, { + body: { + newOwner: { type: 'company', id: company.id }, + effectiveDate: new Date().toISOString(), + transferReason: 'sale', + }, + }); + const res = await transferHandler(req, ctx, { id: yacht.id }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.currentOwnerType).toBe('company'); + expect(body.data.currentOwnerId).toBe(company.id); + }); + + it('returns 403 when caller lacks yachts.transfer (only has yachts.edit)', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const company = await makeCompany({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + }); + const gated = withPermission('yachts', 'transfer', transferHandler); + const ctx = makeMockCtx({ portId: port.id, permissions: makeSalesAgentPermissions() }); + const req = makeMockRequest('POST', `http://localhost/api/v1/yachts/${yacht.id}/transfer`, { + body: { + newOwner: { type: 'company', id: company.id }, + effectiveDate: new Date().toISOString(), + }, + }); + const res = await gated(req, ctx, { id: yacht.id }); + expect(res.status).toBe(403); + }); + + it('returns 400 when newOwner === currentOwner', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('POST', `http://localhost/api/v1/yachts/${yacht.id}/transfer`, { + body: { + newOwner: { type: 'client', id: client.id }, + effectiveDate: new Date().toISOString(), + }, + }); + const res = await transferHandler(req, ctx, { id: yacht.id }); + expect(res.status).toBe(400); + }); +}); + +describe('GET /api/v1/yachts/[id]/ownership-history', () => { + it('returns full history sorted by startDate DESC', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + const company = await makeCompany({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + }); + // Transfer to create a second history row + const ctxFull = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const transferReq = makeMockRequest( + 'POST', + `http://localhost/api/v1/yachts/${yacht.id}/transfer`, + { + body: { + newOwner: { type: 'company', id: company.id }, + effectiveDate: new Date().toISOString(), + transferReason: 'sale', + }, + }, + ); + const transferRes = await transferHandler(transferReq, ctxFull, { id: yacht.id }); + expect(transferRes.status).toBe(200); + + const req = makeMockRequest( + 'GET', + `http://localhost/api/v1/yachts/${yacht.id}/ownership-history`, + ); + const res = await historyHandler(req, ctxFull, { id: yacht.id }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toHaveLength(2); + // Sorted DESC by startDate — newest first + const firstStart = new Date(body.data[0].startDate).getTime(); + const secondStart = new Date(body.data[1].startDate).getTime(); + expect(firstStart).toBeGreaterThanOrEqual(secondStart); + }); + + it('returns 404 for cross-tenant yacht', async () => { + const portA = await makePort(); + const portB = await makePort(); + const client = await makeClient({ portId: portA.id }); + const yacht = await makeYacht({ + portId: portA.id, + ownerType: 'client', + ownerId: client.id, + }); + const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); + const req = makeMockRequest( + 'GET', + `http://localhost/api/v1/yachts/${yacht.id}/ownership-history`, + ); + const res = await historyHandler(req, ctx, { id: yacht.id }); + expect(res.status).toBe(404); + }); +}); + +describe('GET /api/v1/yachts/autocomplete', () => { + it('returns matching yachts by name', async () => { + const port = await makePort(); + const client = await makeClient({ portId: port.id }); + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'AutoCompleteMatch One', + }); + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'OtherName', + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest( + 'GET', + 'http://localhost/api/v1/yachts/autocomplete?q=AutoCompleteMatch', + ); + 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((y: { name: string }) => y.name.includes('AutoCompleteMatch'))).toBe( + true, + ); + }); + + it('returns empty array when q is missing', async () => { + const port = await makePort(); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + const req = makeMockRequest('GET', 'http://localhost/api/v1/yachts/autocomplete'); + const res = await autocompleteHandler(req, ctx, {}); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual([]); + }); + + it('is tenant-scoped', async () => { + const portA = await makePort(); + const portB = await makePort(); + const clientA = await makeClient({ portId: portA.id }); + await makeYacht({ + portId: portA.id, + ownerType: 'client', + ownerId: clientA.id, + name: 'TenantScopedYachtXYZ', + }); + const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); + const req = makeMockRequest( + 'GET', + 'http://localhost/api/v1/yachts/autocomplete?q=TenantScopedYachtXYZ', + ); + const res = await autocompleteHandler(req, ctx, {}); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual([]); + }); +});