diff --git a/src/app/api/v1/berth-reservations/[id]/route.ts b/src/app/api/v1/berth-reservations/[id]/route.ts new file mode 100644 index 0000000..6b0d042 --- /dev/null +++ b/src/app/api/v1/berth-reservations/[id]/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { requirePermission } from '@/lib/auth/permissions'; +import { errorResponse } from '@/lib/errors'; +import { + activate, + cancel, + endReservation, + getById, +} from '@/lib/services/berth-reservations.service'; + +// ─── PATCH body schema (action-based discriminated union) ──────────────────── + +const patchBodySchema = z.discriminatedUnion('action', [ + z.object({ + action: z.literal('activate'), + contractFileId: z.string().optional(), + effectiveDate: z.coerce.date().optional(), + }), + z.object({ + action: z.literal('end'), + endDate: z.coerce.date(), + notes: z.string().optional(), + }), + z.object({ + action: z.literal('cancel'), + reason: z.string().optional(), + }), +]); + +// ─── Handlers ──────────────────────────────────────────────────────────────── + +export const getHandler: RouteHandler = async (_req, ctx, params) => { + try { + const reservation = await getById(params.id!, ctx.portId); + return NextResponse.json({ data: reservation }); + } catch (error) { + return errorResponse(error); + } +}; + +export const patchHandler: RouteHandler = async (req, ctx, params) => { + try { + const body = await parseBody(req, patchBodySchema); + const meta = { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }; + + if (body.action === 'activate') { + requirePermission(ctx, 'reservations', 'activate'); + const result = await activate( + params.id!, + ctx.portId, + { + contractFileId: body.contractFileId, + effectiveDate: body.effectiveDate, + }, + meta, + ); + return NextResponse.json({ data: result }); + } + + if (body.action === 'end') { + // `end` is lifecycle progression; same privilege as activate. + requirePermission(ctx, 'reservations', 'activate'); + const result = await endReservation( + params.id!, + ctx.portId, + { endDate: body.endDate, notes: body.notes }, + meta, + ); + return NextResponse.json({ data: result }); + } + + // action === 'cancel' + requirePermission(ctx, 'reservations', 'cancel'); + const result = await cancel(params.id!, ctx.portId, { reason: body.reason }, meta); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } +}; + +export const deleteHandler: RouteHandler = async (_req, ctx, params) => { + try { + await cancel( + 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('reservations', 'view', getHandler)); +// PATCH cannot use `withPermission` wrapper — the required permission depends +// on the `action` field in the body. `requirePermission` is called inside the +// handler after the body is parsed. +export const PATCH = withAuth(patchHandler); +export const DELETE = withAuth(withPermission('reservations', 'cancel', deleteHandler)); diff --git a/src/app/api/v1/berths/[id]/reservations/route.ts b/src/app/api/v1/berths/[id]/reservations/route.ts new file mode 100644 index 0000000..e54027a --- /dev/null +++ b/src/app/api/v1/berths/[id]/reservations/route.ts @@ -0,0 +1,72 @@ +import { and, eq } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; +import { parseBody, parseQuery } from '@/lib/api/route-helpers'; +import { db } from '@/lib/db'; +import { berths } from '@/lib/db/schema/berths'; +import { NotFoundError, errorResponse } from '@/lib/errors'; +import { createPending, listReservations } from '@/lib/services/berth-reservations.service'; +import { createPendingSchema, listReservationsSchema } from '@/lib/validators/reservations'; + +// URL berthId is authoritative; make body berthId optional (ignored anyway). +const createPendingBodySchema = createPendingSchema + .omit({ berthId: true }) + .extend({ berthId: createPendingSchema.shape.berthId.optional() }); + +async function assertBerthInPort(berthId: string, portId: string): Promise { + const berth = await db.query.berths.findFirst({ + where: and(eq(berths.id, berthId), eq(berths.portId, portId)), + }); + if (!berth) throw new NotFoundError('Berth'); +} + +export const listHandler: RouteHandler = async (req, ctx, params) => { + try { + await assertBerthInPort(params.id!, ctx.portId); + + const query = parseQuery(req, listReservationsSchema); + // URL berthId is authoritative; override any client-supplied value. + const result = await listReservations(ctx.portId, { ...query, berthId: params.id! }); + 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, params) => { + try { + await assertBerthInPort(params.id!, ctx.portId); + + const body = await parseBody(req, createPendingBodySchema); + // URL berthId is authoritative; any body-supplied berthId is ignored. + const reservation = await createPending( + ctx.portId, + { ...body, berthId: params.id! }, + { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + ); + return NextResponse.json({ data: reservation }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } +}; + +export const GET = withAuth(withPermission('reservations', 'view', listHandler)); +export const POST = withAuth(withPermission('reservations', 'create', createHandler)); diff --git a/tests/integration/api/reservations.test.ts b/tests/integration/api/reservations.test.ts new file mode 100644 index 0000000..0277b0e --- /dev/null +++ b/tests/integration/api/reservations.test.ts @@ -0,0 +1,485 @@ +import { describe, it, expect } from 'vitest'; +import { eq } from 'drizzle-orm'; + +import { + createHandler as createReservationHandler, + listHandler as listReservationsHandler, +} from '@/app/api/v1/berths/[id]/reservations/route'; +import { + getHandler as getReservationHandler, + patchHandler as patchReservationHandler, + deleteHandler as deleteReservationHandler, +} from '@/app/api/v1/berth-reservations/[id]/route'; +import { db } from '@/lib/db'; +import { berthReservations } from '@/lib/db/schema/reservations'; +import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester'; +import { + makeBerth, + makeClient, + makeFullPermissions, + makePort, + makeSalesAgentPermissions, + makeYacht, +} from '../../helpers/factories'; + +// ─── POST /api/v1/berths/[id]/reservations ─────────────────────────────────── + +describe('POST /api/v1/berths/[id]/reservations', () => { + it('creates pending reservation (201)', async () => { + const port = await makePort(); + const berth = await makeBerth({ portId: port.id }); + 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/berths/${berth.id}/reservations`, { + body: { + clientId: client.id, + yachtId: yacht.id, + startDate: new Date().toISOString(), + }, + }); + const res = await createReservationHandler(req, ctx, { id: berth.id }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.data.berthId).toBe(berth.id); + expect(body.data.clientId).toBe(client.id); + expect(body.data.yachtId).toBe(yacht.id); + expect(body.data.status).toBe('pending'); + }); + + it('returns 400 when yacht does not belong to reservation client', async () => { + const port = await makePort(); + const berth = await makeBerth({ portId: port.id }); + const ownerClient = await makeClient({ portId: port.id }); + const otherClient = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: ownerClient.id, + }); + const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() }); + + const req = makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, { + body: { + clientId: otherClient.id, + yachtId: yacht.id, + startDate: new Date().toISOString(), + }, + }); + const res = await createReservationHandler(req, ctx, { id: berth.id }); + expect(res.status).toBe(400); + }); + + it('returns 404 when berth is cross-tenant', async () => { + const portA = await makePort(); + const portB = await makePort(); + const berthA = await makeBerth({ portId: portA.id }); + const client = await makeClient({ portId: portB.id }); + const yacht = await makeYacht({ + portId: portB.id, + ownerType: 'client', + ownerId: client.id, + }); + // Caller is scoped to portB but the URL berth lives in portA. + const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); + + const req = makeMockRequest( + 'POST', + `http://localhost/api/v1/berths/${berthA.id}/reservations`, + { + body: { + clientId: client.id, + yachtId: yacht.id, + startDate: new Date().toISOString(), + }, + }, + ); + const res = await createReservationHandler(req, ctxB, { id: berthA.id }); + expect(res.status).toBe(404); + }); + + it('ignores berthId from body, uses URL param instead', async () => { + const port = await makePort(); + const urlBerth = await makeBerth({ portId: port.id }); + const bodyBerth = await makeBerth({ portId: port.id }); + 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/berths/${urlBerth.id}/reservations`, + { + body: { + berthId: bodyBerth.id, + clientId: client.id, + yachtId: yacht.id, + startDate: new Date().toISOString(), + }, + }, + ); + const res = await createReservationHandler(req, ctx, { id: urlBerth.id }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.data.berthId).toBe(urlBerth.id); + expect(body.data.berthId).not.toBe(bodyBerth.id); + }); +}); + +// ─── GET /api/v1/berths/[id]/reservations ──────────────────────────────────── + +describe('GET /api/v1/berths/[id]/reservations', () => { + it('returns reservations filtered by that berth', async () => { + const port = await makePort(); + const berthA = await makeBerth({ portId: port.id }); + const berthB = await makeBerth({ portId: port.id }); + 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() }); + + // Create a reservation for berthA. + await createReservationHandler( + makeMockRequest('POST', `http://localhost/api/v1/berths/${berthA.id}/reservations`, { + body: { + clientId: client.id, + yachtId: yacht.id, + startDate: new Date().toISOString(), + }, + }), + ctx, + { id: berthA.id }, + ); + + // Create a reservation for berthB. + await createReservationHandler( + makeMockRequest('POST', `http://localhost/api/v1/berths/${berthB.id}/reservations`, { + body: { + clientId: client.id, + yachtId: yacht.id, + startDate: new Date().toISOString(), + }, + }), + ctx, + { id: berthB.id }, + ); + + const res = await listReservationsHandler( + makeMockRequest('GET', `http://localhost/api/v1/berths/${berthA.id}/reservations`), + ctx, + { id: berthA.id }, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.length).toBe(1); + expect(body.data[0].berthId).toBe(berthA.id); + }); +}); + +// ─── GET /api/v1/berth-reservations/[id] ───────────────────────────────────── + +describe('GET /api/v1/berth-reservations/[id]', () => { + it('returns the reservation', async () => { + const port = await makePort(); + const berth = await makeBerth({ portId: port.id }); + 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 createRes = await createReservationHandler( + makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, { + body: { + clientId: client.id, + yachtId: yacht.id, + startDate: new Date().toISOString(), + }, + }), + ctx, + { id: berth.id }, + ); + const reservation = (await createRes.json()).data; + + const res = await getReservationHandler( + makeMockRequest('GET', `http://localhost/api/v1/berth-reservations/${reservation.id}`), + ctx, + { id: reservation.id }, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.id).toBe(reservation.id); + }); + + it('returns 404 for cross-tenant', async () => { + const portA = await makePort(); + const portB = await makePort(); + const berth = await makeBerth({ portId: portA.id }); + const client = await makeClient({ portId: portA.id }); + const yacht = await makeYacht({ + portId: portA.id, + ownerType: 'client', + ownerId: client.id, + }); + const ctxA = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() }); + + const createRes = await createReservationHandler( + makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, { + body: { + clientId: client.id, + yachtId: yacht.id, + startDate: new Date().toISOString(), + }, + }), + ctxA, + { id: berth.id }, + ); + const reservation = (await createRes.json()).data; + + const ctxB = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() }); + const res = await getReservationHandler( + makeMockRequest('GET', `http://localhost/api/v1/berth-reservations/${reservation.id}`), + ctxB, + { id: reservation.id }, + ); + expect(res.status).toBe(404); + }); +}); + +// ─── PATCH /api/v1/berth-reservations/[id] ─────────────────────────────────── + +describe('PATCH /api/v1/berth-reservations/[id]', () => { + async function seedReservation() { + const port = await makePort(); + const berth = await makeBerth({ portId: port.id }); + 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 createRes = await createReservationHandler( + makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, { + body: { + clientId: client.id, + yachtId: yacht.id, + startDate: new Date().toISOString(), + }, + }), + ctx, + { id: berth.id }, + ); + const reservation = (await createRes.json()).data; + return { port, berth, client, yacht, ctx, reservation }; + } + + it('activate: pending → active (200)', async () => { + const { ctx, reservation } = await seedReservation(); + const res = await patchReservationHandler( + makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { + body: { action: 'activate' }, + }), + ctx, + { id: reservation.id }, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.status).toBe('active'); + }); + + it('end: active → ended (200)', async () => { + const { ctx, reservation } = await seedReservation(); + + // First activate. + await patchReservationHandler( + makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { + body: { action: 'activate' }, + }), + ctx, + { id: reservation.id }, + ); + + // Then end. + const endDate = new Date('2027-01-01T00:00:00.000Z'); + const res = await patchReservationHandler( + makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { + body: { + action: 'end', + endDate: endDate.toISOString(), + notes: 'tenant moved', + }, + }), + ctx, + { id: reservation.id }, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.status).toBe('ended'); + expect(new Date(body.data.endDate).toISOString()).toBe(endDate.toISOString()); + }); + + it('cancel: pending → cancelled (200)', async () => { + const { ctx, reservation } = await seedReservation(); + const res = await patchReservationHandler( + makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { + body: { action: 'cancel', reason: 'client changed mind' }, + }), + ctx, + { id: reservation.id }, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.status).toBe('cancelled'); + }); + + it('returns 400 on invalid transition (ended → activate)', async () => { + const { ctx, reservation } = await seedReservation(); + + // pending → active. + await patchReservationHandler( + makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { + body: { action: 'activate' }, + }), + ctx, + { id: reservation.id }, + ); + // active → ended. + await patchReservationHandler( + makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { + body: { + action: 'end', + endDate: new Date().toISOString(), + }, + }), + ctx, + { id: reservation.id }, + ); + + // ended → activate should fail. + const res = await patchReservationHandler( + makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { + body: { action: 'activate' }, + }), + ctx, + { id: reservation.id }, + ); + expect(res.status).toBe(400); + }); + + it('returns 400 on invalid body shape (action missing)', async () => { + const { ctx, reservation } = await seedReservation(); + const res = await patchReservationHandler( + makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { + body: { notes: 'noop' }, + }), + ctx, + { id: reservation.id }, + ); + expect(res.status).toBe(400); + }); + + it('returns 403 when caller lacks reservations.activate for activate action', async () => { + const { port, reservation } = await seedReservation(); + // Viewer-like permissions: no activate. + const ctx = makeMockCtx({ + portId: port.id, + permissions: { + ...makeSalesAgentPermissions(), + reservations: { view: true, create: true, activate: false, cancel: true }, + }, + }); + const res = await patchReservationHandler( + makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { + body: { action: 'activate' }, + }), + ctx, + { id: reservation.id }, + ); + expect(res.status).toBe(403); + }); + + it('returns 403 when caller lacks reservations.cancel for cancel action', async () => { + const { port, reservation } = await seedReservation(); + // Sales agent — has activate but NOT cancel. + const ctx = makeMockCtx({ + portId: port.id, + permissions: makeSalesAgentPermissions(), + }); + const res = await patchReservationHandler( + makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { + body: { action: 'cancel', reason: 'test' }, + }), + ctx, + { id: reservation.id }, + ); + expect(res.status).toBe(403); + + // But activate succeeds with the same permissions set. + const activateRes = await patchReservationHandler( + makeMockRequest('PATCH', `http://localhost/api/v1/berth-reservations/${reservation.id}`, { + body: { action: 'activate' }, + }), + ctx, + { id: reservation.id }, + ); + expect(activateRes.status).toBe(200); + }); +}); + +// ─── DELETE /api/v1/berth-reservations/[id] ────────────────────────────────── + +describe('DELETE /api/v1/berth-reservations/[id]', () => { + it('cancels the reservation (204)', async () => { + const port = await makePort(); + const berth = await makeBerth({ portId: port.id }); + 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 createRes = await createReservationHandler( + makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, { + body: { + clientId: client.id, + yachtId: yacht.id, + startDate: new Date().toISOString(), + }, + }), + ctx, + { id: berth.id }, + ); + const reservation = (await createRes.json()).data; + + const delRes = await deleteReservationHandler( + makeMockRequest('DELETE', `http://localhost/api/v1/berth-reservations/${reservation.id}`), + ctx, + { id: reservation.id }, + ); + expect(delRes.status).toBe(204); + + const [row] = await db + .select() + .from(berthReservations) + .where(eq(berthReservations.id, reservation.id)); + expect(row?.status).toBe('cancelled'); + }); +});