feat(bulk-berths): 2-step wizard for new-port setup
Step 5 per PRE-DEPLOY-PLAN § 1.4.13.
Service: bulkAddBerths(portId, inputs, meta) — input-level dedup
catches in-batch duplicates, then a single SELECT against existing
port rows rejects with ConflictError on first collision. All inserts
in one round-trip; audit log + realtime alert.
Validator: bulkAddBerthsSchema with min(1) max(500) per call.
Route: POST /api/v1/berths/bulk-add gated on berths.create.
Wizard UI (/[portSlug]/admin/berths/bulk-add):
Step 1 — dock letter A-E, range start+end mooring numbers, tenure
default. Generates N empty rows.
Step 2 — editable table with per-row dimensions / pontoon / pricing.
"Apply to all" inputs in the header row copy a value down every
row at once (covers the "every row is 40ft × 15ft at €125k" case
in two clicks). Per-row remove button.
Drag-fill deferred. Server-side mooring uniqueness check is canonical;
client-side dedup is a pre-flight courtesy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -594,6 +594,80 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
|
||||
return berth!;
|
||||
}
|
||||
|
||||
// ─── Bulk add ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function bulkAddBerths(
|
||||
portId: string,
|
||||
inputs: CreateBerthInput[],
|
||||
meta: AuditMeta,
|
||||
): Promise<{ inserted: number; ids: string[] }> {
|
||||
// Input-level dedup: catch fat-finger duplicates in the wizard before
|
||||
// hitting the unique index.
|
||||
const seenMoorings = new Set<string>();
|
||||
for (const row of inputs) {
|
||||
if (seenMoorings.has(row.mooringNumber)) {
|
||||
throw new ConflictError(`Duplicate mooring number "${row.mooringNumber}" in input`);
|
||||
}
|
||||
seenMoorings.add(row.mooringNumber);
|
||||
}
|
||||
|
||||
const moorings = inputs.map((r) => r.mooringNumber);
|
||||
const existing = await db
|
||||
.select({ mooringNumber: berths.mooringNumber })
|
||||
.from(berths)
|
||||
.where(and(eq(berths.portId, portId), inArray(berths.mooringNumber, moorings)));
|
||||
if (existing.length > 0) {
|
||||
throw new ConflictError(
|
||||
`Mooring numbers already exist in this port: ${existing.map((r) => r.mooringNumber).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const defaultCurrency = await getPortBerthsDefaultCurrency(portId);
|
||||
const values = inputs.map((row) => ({
|
||||
portId,
|
||||
mooringNumber: row.mooringNumber,
|
||||
area: row.area,
|
||||
status: row.status ?? 'available',
|
||||
lengthFt: row.lengthFt?.toString(),
|
||||
lengthM: row.lengthM?.toString(),
|
||||
widthFt: row.widthFt?.toString(),
|
||||
widthM: row.widthM?.toString(),
|
||||
draftFt: row.draftFt?.toString(),
|
||||
draftM: row.draftM?.toString(),
|
||||
price: row.price?.toString(),
|
||||
priceCurrency: row.priceCurrency ?? defaultCurrency,
|
||||
tenureType: row.tenureType ?? 'permanent',
|
||||
mooringType: row.mooringType,
|
||||
powerCapacity: row.powerCapacity?.toString(),
|
||||
voltage: row.voltage?.toString(),
|
||||
access: row.access,
|
||||
bowFacing: row.bowFacing,
|
||||
sidePontoon: row.sidePontoon,
|
||||
}));
|
||||
|
||||
const inserted = await db.insert(berths).values(values).returning({ id: berths.id });
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'berth',
|
||||
entityId: 'bulk',
|
||||
newValue: { count: inserted.length, mooringNumbers: moorings },
|
||||
metadata: { type: 'bulk_add' },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'system:alert', {
|
||||
alertType: 'berth:bulk_created',
|
||||
message: `${inserted.length} berths added`,
|
||||
severity: 'info',
|
||||
});
|
||||
|
||||
return { inserted: inserted.length, ids: inserted.map((r) => r.id) };
|
||||
}
|
||||
|
||||
// ─── Delete ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
|
||||
|
||||
Reference in New Issue
Block a user