feat(recommender): API endpoint + interest-detail panel + add-to-interest dialog
This commit is contained in:
72
src/app/api/v1/interests/[id]/berths/route.ts
Normal file
72
src/app/api/v1/interests/[id]/berths/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { db } from '@/lib/db';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { upsertInterestBerth } from '@/lib/services/interest-berths.service';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
|
||||
const addBerthSchema = z.object({
|
||||
berthId: z.string().min(1),
|
||||
/** Drives the public-map "Under Offer" sub-status. See plan §5.4. */
|
||||
isSpecificInterest: z.boolean(),
|
||||
});
|
||||
|
||||
// POST /api/v1/interests/[id]/berths — link a berth (non-primary) to an interest.
|
||||
export const POST = withAuth(
|
||||
withPermission('interests', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, addBerthSchema);
|
||||
const interestId = params.id!;
|
||||
|
||||
// Tenant scope: interest must belong to this port.
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: eq(interests.id, interestId),
|
||||
});
|
||||
if (!interest || interest.portId !== ctx.portId) {
|
||||
throw new NotFoundError('Interest');
|
||||
}
|
||||
|
||||
// Tenant scope: berth must belong to this port (never trust a client-
|
||||
// supplied id to cross port boundaries — plan §14.10).
|
||||
const berth = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.id, body.berthId), eq(berths.portId, ctx.portId)),
|
||||
});
|
||||
if (!berth) {
|
||||
throw new ValidationError('berthId not found in this port');
|
||||
}
|
||||
|
||||
const link = await upsertInterestBerth(interestId, body.berthId, {
|
||||
isSpecificInterest: body.isSpecificInterest,
|
||||
addedBy: ctx.userId,
|
||||
});
|
||||
|
||||
void createAuditLog({
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
action: 'update',
|
||||
entityType: 'interest',
|
||||
entityId: interestId,
|
||||
newValue: { berthId: body.berthId, isSpecificInterest: body.isSpecificInterest },
|
||||
metadata: { type: 'berth_added_to_interest' },
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${ctx.portId}`, 'interest:berthLinked', {
|
||||
interestId,
|
||||
berthId: body.berthId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: link }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
44
src/app/api/v1/interests/[id]/recommend-berths/route.ts
Normal file
44
src/app/api/v1/interests/[id]/recommend-berths/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { recommendBerths } from '@/lib/services/berth-recommender.service';
|
||||
|
||||
/**
|
||||
* POST body — mirrors `RecommendBerthsArgs` minus the `interestId` (route
|
||||
* param) and `portId` (resolved from the auth context — never trust a
|
||||
* client-supplied port, plan §14.10).
|
||||
*/
|
||||
const recommendBerthsSchema = z.object({
|
||||
topN: z.number().int().min(1).max(999).optional(),
|
||||
maxOversizePct: z.number().min(0).max(1000).optional(),
|
||||
showLateStage: z.boolean().optional(),
|
||||
amenityFilters: z
|
||||
.object({
|
||||
minPowerCapacityKw: z.number().min(0).optional(),
|
||||
requiredVoltage: z.number().int().min(0).optional(),
|
||||
requiredAccess: z.string().min(1).optional(),
|
||||
requiredMooringType: z.string().min(1).optional(),
|
||||
requiredCleatCapacity: z.string().min(1).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// POST /api/v1/interests/[id]/recommend-berths
|
||||
export const POST = withAuth(
|
||||
withPermission('interests', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, recommendBerthsSchema);
|
||||
const data = await recommendBerths({
|
||||
interestId: params.id!,
|
||||
portId: ctx.portId,
|
||||
...body,
|
||||
});
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user