Files
pn-new-crm/src/lib/services/interest-berths.service.ts
Matt 05e727f462 feat(uat-batch-18): interest-berths defaults + a11y loading/hint fixes
- `addInterestBerth` insert-time defaults now match the locked
  multi-berth EOI UX (queue B2):
    is_in_eoi_bundle: true   (was false)
    is_specific_interest: matches `isPrimary` (was always true)
  This means a newly-linked berth is covered by the EOI signature by
  default but the public map only shows the primary as "Under Offer"
  until the rep marks others isSpecificInterest. The two existing
  integration tests pass explicit values so they're unaffected.
- A11y: `set-password` form's password-requirements hint linked via
  aria-describedby so SR users hear the rules on focus.
- A11y: Loading fallbacks on set-password / portal/activate /
  supplemental-info wrapped in role="status" aria-live="polite" with
  sr-only "Loading" copy where only a spinner was visible.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:35:52 +02:00

375 lines
15 KiB
TypeScript

/**
* interest_berths junction helpers.
*
* The junction is the source of truth for which berths an interest is
* linked to. Callers should resolve "the berth for this deal" through
* `getPrimaryBerth(interestId)` rather than reading the legacy
* `interests.berth_id` column (slated for removal once every caller
* is migrated - see plan §3.4).
*
* Role-flag semantics (see plan §1):
* - is_primary : at most one row per interest. Templates,
* forms, and "the berth for this deal"
* UIs resolve through this row.
* - is_specific_interest : the berth shows as "Under Offer" on the
* public map. False = legal/EOI-only link.
* - is_in_eoi_bundle : covered by the interest's EOI signature.
*/
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interestBerths, interests, type InterestBerth } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { CodedError, ConflictError, NotFoundError } from '@/lib/errors';
import type { AuditMeta } from '@/lib/audit';
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
// ─── Reads ──────────────────────────────────────────────────────────────────
export interface PrimaryBerthRef {
berthId: string;
mooringNumber: string | null;
isInEoiBundle: boolean;
isSpecificInterest: boolean;
}
/**
* The primary berth for an interest, if any. Resolves the row marked
* `is_primary=true`; falls back to the most recently added berth row
* when no row is flagged primary (defensive — the unique partial index
* guarantees ≤1 primary, but reads should never throw on data drift).
*/
export async function getPrimaryBerth(interestId: string): Promise<PrimaryBerthRef | null> {
const rows = await db
.select({
berthId: interestBerths.berthId,
isPrimary: interestBerths.isPrimary,
isSpecificInterest: interestBerths.isSpecificInterest,
isInEoiBundle: interestBerths.isInEoiBundle,
addedAt: interestBerths.addedAt,
mooringNumber: berths.mooringNumber,
})
.from(interestBerths)
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
.where(eq(interestBerths.interestId, interestId))
.orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt));
const first = rows[0];
if (!first) return null;
return {
berthId: first.berthId,
mooringNumber: first.mooringNumber,
isInEoiBundle: first.isInEoiBundle,
isSpecificInterest: first.isSpecificInterest,
};
}
/**
* Map { interestId → primary berth ref } for a batch of interest ids.
* One round-trip; preferred for list pages over a per-row helper.
*/
export async function getPrimaryBerthsForInterests(
interestIds: string[],
): Promise<Map<string, PrimaryBerthRef>> {
if (interestIds.length === 0) return new Map();
const rows = await db
.select({
interestId: interestBerths.interestId,
berthId: interestBerths.berthId,
isPrimary: interestBerths.isPrimary,
isSpecificInterest: interestBerths.isSpecificInterest,
isInEoiBundle: interestBerths.isInEoiBundle,
addedAt: interestBerths.addedAt,
mooringNumber: berths.mooringNumber,
})
.from(interestBerths)
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
.where(inArray(interestBerths.interestId, interestIds))
.orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt));
const out = new Map<string, PrimaryBerthRef>();
for (const r of rows) {
if (out.has(r.interestId)) continue;
out.set(r.interestId, {
berthId: r.berthId,
mooringNumber: r.mooringNumber,
isInEoiBundle: r.isInEoiBundle,
isSpecificInterest: r.isSpecificInterest,
});
}
return out;
}
/** Berth metadata surfaced alongside each junction row by {@link listBerthsForInterest}.
* All berth-derived fields are nullable so an orphaned junction row (berth
* hard-deleted out from under the link) still renders rather than vanishing. */
export interface InterestBerthWithDetails extends InterestBerth {
mooringNumber: string | null;
area: string | null;
status: string | null;
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<InterestBerthWithDetails>> {
return db
.select({
id: interestBerths.id,
interestId: interestBerths.interestId,
berthId: interestBerths.berthId,
isPrimary: interestBerths.isPrimary,
isSpecificInterest: interestBerths.isSpecificInterest,
isInEoiBundle: interestBerths.isInEoiBundle,
eoiBypassReason: interestBerths.eoiBypassReason,
eoiBypassedBy: interestBerths.eoiBypassedBy,
eoiBypassedAt: interestBerths.eoiBypassedAt,
addedBy: interestBerths.addedBy,
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)
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
.where(eq(interestBerths.interestId, interestId))
.orderBy(desc(interestBerths.isPrimary), desc(interestBerths.addedAt));
}
/** All interest links for a single berth (used by the recommender + admin UI). */
export async function listInterestsForBerth(berthId: string): Promise<Array<InterestBerth>> {
return db
.select()
.from(interestBerths)
.where(eq(interestBerths.berthId, berthId))
.orderBy(desc(interestBerths.addedAt));
}
// ─── Writes ─────────────────────────────────────────────────────────────────
interface AddOrUpdateOpts {
isPrimary?: boolean;
isSpecificInterest?: boolean;
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;
}
/**
* Idempotently link a berth to an interest. If the row already exists,
* provided flags are merged; otherwise a fresh row is inserted.
*
* When `isPrimary=true` is requested, the previous primary (if any) is
* demoted in the same transaction so the unique partial index is never
* violated.
*/
export async function upsertInterestBerth(
interestId: string,
berthId: string,
opts: AddOrUpdateOpts = {},
): Promise<InterestBerth> {
// concurrency-auditor H-3: two concurrent setPrimaryBerth calls on
// the same interest hit `idx_interest_berths_one_primary` (partial
// unique on `is_primary=true`). The loser surfaced as a generic
// 500 because the 23505 wasn't translated. Catch and remap to a
// ConflictError so the UI gets a "another rep just changed the
// primary berth" toast instead.
try {
return await db.transaction(async (tx) => {
return upsertInterestBerthTx(tx, interestId, berthId, opts);
});
} catch (err) {
if (isPrimaryBerthConflict(err)) {
throw new ConflictError(
'Another rep changed the primary berth at the same time. Refresh and try again.',
);
}
throw err;
}
}
function isPrimaryBerthConflict(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false;
// postgres.js surfaces the constraint name in `constraint_name`.
const e = err as { code?: string; constraint_name?: string };
return e.code === '23505' && e.constraint_name === 'idx_interest_berths_one_primary';
}
/**
* Transaction-bound variant of {@link upsertInterestBerth}. Use this when the
* junction write must roll back together with another write (e.g. inserting
* the parent interest row in the same transaction).
*/
export async function upsertInterestBerthTx(
tx: DbOrTx,
interestId: string,
berthId: string,
opts: AddOrUpdateOpts = {},
): Promise<InterestBerth> {
// Cross-port guard. The junction is silently multi-port-shaped (it has
// no port_id of its own — it inherits via the FKs) so a caller wiring
// an interest from one port to a berth from another would corrupt the
// recommender + public-berth aggregates with phantom rows. We assert
// both rows live in the same port BEFORE inserting; if either side is
// missing, the FK constraint will surface that on insert.
const sides = await tx
.select({
interestPortId: interests.portId,
berthPortId: berths.portId,
})
.from(interests)
.innerJoin(berths, eq(berths.id, berthId))
.where(eq(interests.id, interestId))
.limit(1);
const side = sides[0];
if (side && side.interestPortId !== side.berthPortId) {
throw new CodedError('CROSS_PORT_LINK_REJECTED', {
internalMessage: `interest ${interestId} (port ${side.interestPortId}) ↔ berth ${berthId} (port ${side.berthPortId})`,
});
}
if (opts.isPrimary === true) {
await tx
.update(interestBerths)
.set({ isPrimary: false })
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.isPrimary, true)));
}
const setForUpdate: Partial<InterestBerth> = {};
if (opts.isPrimary !== undefined) setForUpdate.isPrimary = opts.isPrimary;
if (opts.isSpecificInterest !== undefined)
setForUpdate.isSpecificInterest = opts.isSpecificInterest;
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;
}
}
// EOI bundle UX (locked 2026-05-18): a deal's EOI typically covers
// every berth linked to the interest, but only the rep's "main"
// berth (the primary) should show "Under Offer" on the public map.
// The defaults below encode that workflow so reps don't have to
// tick boxes for the common case:
// • `is_in_eoi_bundle` defaults to TRUE for every newly-linked
// berth (rep unticks for the rare carve-out).
// • `is_specific_interest` defaults to TRUE only on the primary;
// non-primary rows default to FALSE so the public map doesn't
// light up extra berths.
const isPrimary = opts.isPrimary ?? false;
const [row] = await tx
.insert(interestBerths)
.values({
interestId,
berthId,
isPrimary,
isSpecificInterest: opts.isSpecificInterest ?? isPrimary,
isInEoiBundle: opts.isInEoiBundle ?? true,
addedBy: opts.addedBy,
notes: opts.notes,
eoiBypassReason: setForUpdate.eoiBypassReason ?? null,
eoiBypassedBy: setForUpdate.eoiBypassedBy ?? null,
eoiBypassedAt: setForUpdate.eoiBypassedAt ?? null,
})
.onConflictDoUpdate({
target: [interestBerths.interestId, interestBerths.berthId],
set: setForUpdate,
})
.returning();
// Auto-promote leadCategory: linking a specific berth means the interest
// is now anchored to a real piece of inventory, which is the definition
// of `specific_qualified`. Only bumps `general_interest` (or null) —
// never demotes `hot_lead` or anything else already past qualified.
const isSpecific = row?.isSpecificInterest ?? opts.isSpecificInterest ?? true;
if (isSpecific) {
await tx
.update(interests)
.set({ leadCategory: 'specific_qualified' })
.where(
and(eq(interests.id, interestId), inArray(interests.leadCategory, ['general_interest'])),
);
// Separately handle the NULL case (Drizzle's `inArray` can't include null).
await tx.execute(
sql`UPDATE interests SET lead_category = 'specific_qualified' WHERE id = ${interestId} AND lead_category IS NULL`,
);
}
return row!;
}
/** Promote a single berth to primary for the interest. Demotes any prior primary. */
export async function setPrimaryBerth(interestId: string, berthId: string): Promise<void> {
await upsertInterestBerth(interestId, berthId, { isPrimary: true });
}
/** Remove a berth from an interest.
*
* `portId` is required for cross-port defense — `upsertInterestBerth`
* and `setPrimaryBerth` both verify the interest + berth share the
* caller's port before mutation, but the original `removeInterestBerth`
* issued a delete keyed only by (interestId, berthId), so a future
* caller that omitted its own port check could delete a junction row
* across tenants. This now mirrors the cross-check used by upsert.
*/
export async function removeInterestBerth(
interestId: string,
berthId: string,
portId: string,
meta?: AuditMeta,
): Promise<void> {
// Verify both the interest and the berth belong to the caller's
// port before issuing the delete. A tenant boundary breach would
// otherwise be a single misrouted call away.
const [interestRow, berthRow] = await Promise.all([
db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
}),
db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
}),
]);
if (!interestRow || !berthRow) {
throw new NotFoundError('interest or berth');
}
await db
.delete(interestBerths)
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
// G-C4: fire the berth_unlinked berth-rule. Default mode is 'off' so this
// is a silent no-op unless an admin opted in via system_settings.berth_rules.
// Dynamic import avoids a static cycle: berth-rules-engine imports this file
// (getPrimaryBerth). meta is optional so older callers that haven't been
// threaded through can still call this without triggering the rule.
if (meta) {
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
void evaluateRule('berth_unlinked', interestId, portId, meta);
}
}