fix(build): extract route.ts handlers to handlers.ts (CLAUDE.md convention)

8 API route files were exporting handler functions directly from route.ts,
which Next.js 15 rejects with "$NAME is not a valid Route export field".
Per CLAUDE.md convention, service-tested handler functions live in sibling
handlers.ts files and route.ts only re-exports the GET/POST/etc. wrapped
in withAuth(withPermission(...)).

Discovered during the mobile-foundation Task 24 build validation; the route
files predate this branch but the build was never re-run on data-model.

Files:
- berth-reservations/[id], companies/autocomplete, companies/[id]/members
  + nested mid/set-primary, yachts/autocomplete, yachts/[id]/transfer,
  yachts/[id]/ownership-history
- Integration tests updated to import from handlers.ts (companies,
  memberships, reservations, yachts-detail)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-29 15:14:40 +02:00
parent 0e9c24e222
commit d0540dca55
20 changed files with 309 additions and 285 deletions

View File

@@ -0,0 +1,107 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { 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);
}
};

View File

@@ -1,110 +1,6 @@
import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { z } from 'zod';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; import { getHandler, patchHandler, deleteHandler } from './handlers';
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)); export const GET = withAuth(withPermission('reservations', 'view', getHandler));
// PATCH cannot use `withPermission` wrapper — the required permission depends // PATCH cannot use `withPermission` wrapper — the required permission depends

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { 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);
}
};

View File

@@ -1,50 +1,6 @@
import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; import { patchHandler, deleteHandler } from './handlers';
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 PATCH = withAuth(withPermission('memberships', 'manage', patchHandler));
export const DELETE = withAuth(withPermission('memberships', 'manage', deleteHandler)); export const DELETE = withAuth(withPermission('memberships', 'manage', deleteHandler));

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { 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);
}
};

View File

@@ -1,21 +1,5 @@
import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; import { setPrimaryHandler } from './handlers';
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)); export const POST = withAuth(withPermission('memberships', 'manage', setPrimaryHandler));

View File

@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { 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);
}
};

View File

@@ -1,43 +1,6 @@
import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { z } from 'zod';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; import { listHandler, createHandler } from './handlers';
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 GET = withAuth(withPermission('memberships', 'view', listHandler));
export const POST = withAuth(withPermission('memberships', 'manage', createHandler)); export const POST = withAuth(withPermission('memberships', 'manage', createHandler));

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { 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);
}
};

View File

@@ -1,20 +1,5 @@
import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; import { autocompleteHandler } from './handlers';
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)); export const GET = withAuth(withPermission('companies', 'view', autocompleteHandler));

View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { 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);
}
};

View File

@@ -1,16 +1,5 @@
import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; import { historyHandler } from './handlers';
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)); export const GET = withAuth(withPermission('yachts', 'view', historyHandler));

View File

@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { 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);
}
};

View File

@@ -1,24 +1,5 @@
import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; import { transferHandler } from './handlers';
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)); export const POST = withAuth(withPermission('yachts', 'transfer', transferHandler));

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { 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);
}
};

View File

@@ -1,20 +1,5 @@
import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers'; import { autocompleteHandler } from './handlers';
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)); export const GET = withAuth(withPermission('yachts', 'view', autocompleteHandler));

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { listHandler, createHandler } from '@/app/api/v1/companies/handlers'; import { listHandler, createHandler } from '@/app/api/v1/companies/handlers';
import { getHandler, patchHandler, deleteHandler } from '@/app/api/v1/companies/[id]/handlers'; import { getHandler, patchHandler, deleteHandler } from '@/app/api/v1/companies/[id]/handlers';
import { autocompleteHandler } from '@/app/api/v1/companies/autocomplete/route'; import { autocompleteHandler } from '@/app/api/v1/companies/autocomplete/handlers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { companies } from '@/lib/db/schema'; import { companies } from '@/lib/db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';

View File

@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { listHandler, createHandler } from '@/app/api/v1/companies/[id]/members/route'; import { listHandler, createHandler } from '@/app/api/v1/companies/[id]/members/handlers';
import { patchHandler, deleteHandler } from '@/app/api/v1/companies/[id]/members/[mid]/route'; import { patchHandler, deleteHandler } from '@/app/api/v1/companies/[id]/members/[mid]/handlers';
import { setPrimaryHandler } from '@/app/api/v1/companies/[id]/members/[mid]/set-primary/route'; import { setPrimaryHandler } from '@/app/api/v1/companies/[id]/members/[mid]/set-primary/handlers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { companyMemberships } from '@/lib/db/schema'; import { companyMemberships } from '@/lib/db/schema';
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester'; import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';

View File

@@ -9,7 +9,7 @@ import {
getHandler as getReservationHandler, getHandler as getReservationHandler,
patchHandler as patchReservationHandler, patchHandler as patchReservationHandler,
deleteHandler as deleteReservationHandler, deleteHandler as deleteReservationHandler,
} from '@/app/api/v1/berth-reservations/[id]/route'; } from '@/app/api/v1/berth-reservations/[id]/handlers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { berthReservations } from '@/lib/db/schema/reservations'; import { berthReservations } from '@/lib/db/schema/reservations';
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester'; import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';

View File

@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { getHandler, patchHandler, deleteHandler } from '@/app/api/v1/yachts/[id]/handlers'; import { getHandler, patchHandler, deleteHandler } from '@/app/api/v1/yachts/[id]/handlers';
import { transferHandler } from '@/app/api/v1/yachts/[id]/transfer/route'; import { transferHandler } from '@/app/api/v1/yachts/[id]/transfer/handlers';
import { historyHandler } from '@/app/api/v1/yachts/[id]/ownership-history/route'; import { historyHandler } from '@/app/api/v1/yachts/[id]/ownership-history/handlers';
import { autocompleteHandler } from '@/app/api/v1/yachts/autocomplete/route'; import { autocompleteHandler } from '@/app/api/v1/yachts/autocomplete/handlers';
import { withPermission } from '@/lib/api/helpers'; import { withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { yachts } from '@/lib/db/schema'; import { yachts } from '@/lib/db/schema';