feat(interests): linked berths list with role-flag toggles + EOI bypass
Implements plan §5.5: a per-interest "Linked berths" panel mounted above the
recommender on the interest detail Overview tab. Each junction row exposes
the role-flag controls reps need to manage the M:M `interest_berths` link
without the legacy single-berth flow.
UI (`src/components/interests/linked-berths-list.tsx`)
* Rows ordered with primary first; mooring number links to /berths/[id], with
area + a status pill (available/under_offer/sold) and a "Primary" chip.
* "Specifically pitching" Switch (writes `is_specific_interest`) with the
consequence text from §1: "This berth will appear as under interest on the
public map" / "This berth is hidden from the public map".
* "Mark in EOI bundle" Switch (writes `is_in_eoi_bundle`).
* "Set as primary" button when the row isn't primary - the existing
`upsertInterestBerth` helper demotes the prior primary in the same tx.
* "Bypass EOI for this berth" with reason textarea, ONLY rendered when the
parent interest's `eoiStatus === 'signed'`. Writes the bypass triple
(`eoi_bypass_reason`, `eoi_bypassed_by` = caller, `eoi_bypassed_at` = now);
also supports clearing.
* Remove-from-interest action gated by a confirmation dialog.
API (`src/app/api/v1/interests/[id]/berths/...`)
* `GET /` - list endpoint returning `listBerthsForInterest` plus the parent
interest's `eoiStatus` in `meta.eoiStatus` so the UI can decide whether to
show the bypass control.
* `PATCH /[berthId]` - partial update of the junction row's flags + bypass
fields. Server-side guard: rejects bypass writes when `eoiStatus !==
'signed'` (defence in depth - never trust the UI to gate this).
* `DELETE /[berthId]` - calls `removeInterestBerth`.
* The existing POST stays unchanged. All routes wrapped with
`withAuth(withPermission('interests', view|edit, ...))`. portId from ctx;
cross-port reads/writes return 404 for enumeration prevention (§14.10).
Service changes (`src/lib/services/interest-berths.service.ts`)
* `upsertInterestBerth` now accepts `eoiBypassReason` (tri-state: omit = no
change, non-empty = record, null = clear) and `eoiBypassedBy`. The bypass
triple moves as a unit, with `eoi_bypassed_at` stamped server-side.
* `listBerthsForInterest` now returns berth detail (area, status, dimensions)
alongside the junction row, typed as `InterestBerthWithDetails`.
Socket: added `interest:berthLinkUpdated` event for live UI refreshes.
Tests: 18 new integration tests in `tests/integration/api/interest-berths.test.ts`
covering happy paths, primary-demotion in same tx, bypass write/clear, the
"requires signed EOI" guard, cross-port 404s, missing-link 404s, empty-body
400, and viewer 403 through the permission gate.
This commit is contained in:
@@ -99,10 +99,20 @@ export async function getPrimaryBerthsForInterests(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Berth metadata surfaced alongside each junction row by {@link listBerthsForInterest}. */
|
||||
export interface InterestBerthWithDetails extends InterestBerth {
|
||||
mooringNumber: string | null;
|
||||
area: string | null;
|
||||
status: string;
|
||||
lengthFt: string | null;
|
||||
widthFt: string | null;
|
||||
draftFt: string | null;
|
||||
}
|
||||
|
||||
/** All berth links for a single interest, ordered with primary first. */
|
||||
export async function listBerthsForInterest(
|
||||
interestId: string,
|
||||
): Promise<Array<InterestBerth & { mooringNumber: string | null }>> {
|
||||
): Promise<Array<InterestBerthWithDetails>> {
|
||||
return db
|
||||
.select({
|
||||
id: interestBerths.id,
|
||||
@@ -118,6 +128,11 @@ export async function listBerthsForInterest(
|
||||
addedAt: interestBerths.addedAt,
|
||||
notes: interestBerths.notes,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
area: berths.area,
|
||||
status: berths.status,
|
||||
lengthFt: berths.lengthFt,
|
||||
widthFt: berths.widthFt,
|
||||
draftFt: berths.draftFt,
|
||||
})
|
||||
.from(interestBerths)
|
||||
.innerJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||
@@ -142,6 +157,15 @@ interface AddOrUpdateOpts {
|
||||
isInEoiBundle?: boolean;
|
||||
addedBy?: string;
|
||||
notes?: string;
|
||||
/**
|
||||
* EOI bypass fields. Set `eoiBypassReason` to a non-empty string to record
|
||||
* that the berth's own EOI is waived (the parent interest's primary EOI
|
||||
* covers it), or to `null` to clear the bypass and re-require it.
|
||||
* `eoiBypassedBy` should be the acting user id; the timestamp is stamped
|
||||
* server-side.
|
||||
*/
|
||||
eoiBypassReason?: string | null;
|
||||
eoiBypassedBy?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +210,19 @@ export async function upsertInterestBerthTx(
|
||||
if (opts.isInEoiBundle !== undefined) setForUpdate.isInEoiBundle = opts.isInEoiBundle;
|
||||
if (opts.addedBy !== undefined) setForUpdate.addedBy = opts.addedBy;
|
||||
if (opts.notes !== undefined) setForUpdate.notes = opts.notes;
|
||||
// Bypass fields move as a unit — either we set all three to record a bypass
|
||||
// or clear all three. Touching the reason field decides which.
|
||||
if (opts.eoiBypassReason !== undefined) {
|
||||
if (opts.eoiBypassReason && opts.eoiBypassReason.trim().length > 0) {
|
||||
setForUpdate.eoiBypassReason = opts.eoiBypassReason;
|
||||
setForUpdate.eoiBypassedBy = opts.eoiBypassedBy ?? null;
|
||||
setForUpdate.eoiBypassedAt = new Date();
|
||||
} else {
|
||||
setForUpdate.eoiBypassReason = null;
|
||||
setForUpdate.eoiBypassedBy = null;
|
||||
setForUpdate.eoiBypassedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = await tx
|
||||
.insert(interestBerths)
|
||||
@@ -197,6 +234,9 @@ export async function upsertInterestBerthTx(
|
||||
isInEoiBundle: opts.isInEoiBundle ?? false,
|
||||
addedBy: opts.addedBy,
|
||||
notes: opts.notes,
|
||||
eoiBypassReason: setForUpdate.eoiBypassReason ?? null,
|
||||
eoiBypassedBy: setForUpdate.eoiBypassedBy ?? null,
|
||||
eoiBypassedAt: setForUpdate.eoiBypassedAt ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [interestBerths.interestId, interestBerths.berthId],
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface ServerToClientEvents {
|
||||
}) => void;
|
||||
'interest:berthLinked': (payload: { interestId: string; berthId: string }) => void;
|
||||
'interest:berthUnlinked': (payload: { interestId: string; berthId: string }) => void;
|
||||
'interest:berthLinkUpdated': (payload: { interestId: string; berthId: string }) => void;
|
||||
'interest:archived': (payload: { interestId: string }) => void;
|
||||
'interest:outcomeSet': (payload: {
|
||||
interestId: string;
|
||||
|
||||
Reference in New Issue
Block a user