feat(uat-batch): Group C Berth list features (3 new ships + 1 verified)
C20–C23 from the 2026-05-21 plan.
Shipped now:
C21 Dimensions ft/m column toggle persisted to user prefs.
`TablePreferences.dimensionUnit` ('ft' | 'm') added to the user-
profiles JSONB. `useTablePreferences` returns `dimensionUnit` +
`setDimensionUnit` alongside hidden/density. New
`getBerthColumns(unit)` factory rewrites the dimensions /
nominalBoatSize / waterDepth cells when ft is requested
(waterDepth converts on-the-fly from the canonical meters
column at 3.2808 ft/m). Berth-list toolbar gains a small
ft/m toggle button next to the density toggle.
C22 ft/m switching on Berth Requirements rows.
`interest-tabs.tsx` Berth-requirements section now honours
`interest.desiredLengthUnit`. Labels flip to "(m)" when set;
value reads from `desired*M` columns; on save, both the chosen-
unit and the canonical counterpart columns are PATCHed (3.28084
ratio) so downstream surfaces (recommender, EOI merge fields)
stay in lockstep. `InterestPatchField` widened with `desired*M`
variants.
C23 Berth list bulk-edit affordance.
New `POST /api/v1/berths/bulk` (mirror of /interests/bulk):
discriminated union of `change_status` / `change_tenure_type` /
`add_tag` / `remove_tag` / `archive`, 500-id cap, per-row
failure reporting, single `berths.edit` permission gate
(no separate `archive` perm exists on berths today). Status
mutations route through `updateBerthStatus` so under-offer /
sold transitions still trigger the primary interest_berths
auto-link + the rules-engine evaluation.
BerthList toolbar wires `bulkActions` on the DataTable —
Change status (Select dialog), Change tenure (permanent /
fixed-term), Add tag, Remove tag, Archive (destructive +
confirmation). Each dialog uses the same `bulkMutation` so
toast + cache-invalidation behaviour is consistent across
actions.
Already shipped (verified):
C20 Berth list rates / pricing valid columns hidden by default —
already in `BERTH_DEFAULT_HIDDEN`.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
148
src/app/api/v1/berths/bulk/route.ts
Normal file
148
src/app/api/v1/berths/bulk/route.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { berths, berthTags } from '@/lib/db/schema/berths';
|
||||
import {
|
||||
archiveBerth,
|
||||
updateBerth,
|
||||
updateBerthStatus,
|
||||
setBerthTags,
|
||||
} from '@/lib/services/berths.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
/**
|
||||
* Synchronous bulk endpoint for the berths list — mirrors the
|
||||
* /api/v1/interests/bulk shape so the rep-facing UX is consistent.
|
||||
*
|
||||
* Per-row loop with a 500-id cap. Bigger jobs belong on the BullMQ
|
||||
* `bulk` queue; the synchronous path gives reps an immediate per-row
|
||||
* failure list. Each row's mutation is independently transactional
|
||||
* (the underlying service helpers wrap their own writes).
|
||||
*/
|
||||
const bulkSchema = z.discriminatedUnion('action', [
|
||||
z.object({
|
||||
action: z.literal('change_status'),
|
||||
ids: z.array(z.string().min(1)).min(1).max(500),
|
||||
status: z.enum(['available', 'under_offer', 'sold']),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('change_tenure_type'),
|
||||
ids: z.array(z.string().min(1)).min(1).max(500),
|
||||
tenureType: z.enum(['permanent', 'fixed_term']),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('add_tag'),
|
||||
ids: z.array(z.string().min(1)).min(1).max(500),
|
||||
tagId: z.string().min(1),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('remove_tag'),
|
||||
ids: z.array(z.string().min(1)).min(1).max(500),
|
||||
tagId: z.string().min(1),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('archive'),
|
||||
ids: z.array(z.string().min(1)).min(1).max(500),
|
||||
reason: z.string().max(500).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
interface RowResult {
|
||||
id: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Berths share a single `edit` permission for non-price mutations (no
|
||||
// separate `archive` perm today — sales-manager + super-admin own all
|
||||
// edit paths).
|
||||
const PERMISSION_BY_ACTION: Record<
|
||||
z.infer<typeof bulkSchema>['action'],
|
||||
{ resource: 'berths'; action: 'edit' }
|
||||
> = {
|
||||
change_status: { resource: 'berths', action: 'edit' },
|
||||
change_tenure_type: { resource: 'berths', action: 'edit' },
|
||||
add_tag: { resource: 'berths', action: 'edit' },
|
||||
remove_tag: { resource: 'berths', action: 'edit' },
|
||||
archive: { resource: 'berths', action: 'edit' },
|
||||
};
|
||||
|
||||
export const POST = withAuth(async (req, ctx) => {
|
||||
let body: z.infer<typeof bulkSchema>;
|
||||
try {
|
||||
body = await parseBody(req, bulkSchema);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
|
||||
const perm = PERMISSION_BY_ACTION[body.action];
|
||||
const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.[perm.resource]?.[perm.action];
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const meta = {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
};
|
||||
|
||||
const results: RowResult[] = [];
|
||||
|
||||
for (const id of body.ids) {
|
||||
try {
|
||||
if (body.action === 'change_status') {
|
||||
// Status mutations go through the dedicated path so the under-
|
||||
// offer / sold transitions can auto-create the primary
|
||||
// interest_berths link + emit the rules-engine evaluation.
|
||||
await updateBerthStatus(
|
||||
id,
|
||||
ctx.portId,
|
||||
{ status: body.status, reason: 'Bulk status change' },
|
||||
meta,
|
||||
);
|
||||
} else if (body.action === 'change_tenure_type') {
|
||||
await updateBerth(id, ctx.portId, { tenureType: body.tenureType }, meta);
|
||||
} else if (body.action === 'archive') {
|
||||
await archiveBerth(id, ctx.portId, { reason: body.reason ?? '' }, meta);
|
||||
} else if (body.action === 'add_tag' || body.action === 'remove_tag') {
|
||||
const berth = await db.query.berths.findFirst({
|
||||
where: and(eq(berths.id, id), eq(berths.portId, ctx.portId)),
|
||||
});
|
||||
if (!berth) {
|
||||
results.push({ id, ok: false, error: 'Not found' });
|
||||
continue;
|
||||
}
|
||||
// Compose the new tag set, then re-write atomically.
|
||||
const currentTags = await db
|
||||
.select({ tagId: berthTags.tagId })
|
||||
.from(berthTags)
|
||||
.where(eq(berthTags.berthId, id));
|
||||
const currentIds = new Set(currentTags.map((t) => t.tagId));
|
||||
if (body.action === 'add_tag') currentIds.add(body.tagId);
|
||||
else currentIds.delete(body.tagId);
|
||||
await setBerthTags(id, ctx.portId, Array.from(currentIds), meta);
|
||||
}
|
||||
results.push({ id, ok: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
results.push({ id, ok: false, error: message });
|
||||
}
|
||||
}
|
||||
|
||||
const okCount = results.filter((r) => r.ok).length;
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
action: body.action,
|
||||
total: results.length,
|
||||
ok: okCount,
|
||||
failed: results.length - okCount,
|
||||
results,
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user