feat(api): berth reservations (create pending + lifecycle PATCH)

Add Task 3.6 routes:

- POST /api/v1/berths/:id/reservations — creates a pending reservation;
  the URL berthId is authoritative and any body-supplied berthId is
  ignored.
- GET /api/v1/berths/:id/reservations — list filtered by URL berthId.
- GET /api/v1/berth-reservations/:id — fetch scoped to tenant.
- PATCH /api/v1/berth-reservations/:id — action-based dispatch
  (activate | end | cancel) via a discriminated union. Because the
  required permission depends on the action, PATCH is wrapped with
  withAuth only and calls requirePermission inside the handler.
- DELETE /api/v1/berth-reservations/:id — alias for cancel (204).

Cross-tenant berths return 404 on both POST and GET via an explicit
pre-check.

Tests cover happy paths, invalid transitions, 404/400/403 cases, the
URL-vs-body berthId precedence, and per-action permission gating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-24 12:55:12 +02:00
parent aca45fb1b2
commit a78f653f5a
3 changed files with 671 additions and 0 deletions

View File

@@ -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));

View File

@@ -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<void> {
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));