feat(uat-batch): Groups R + T — Documenso list + deferred bugs

R62, T64, T65 from the 2026-05-21 plan. U66 deferred with reasoning.

Shipped:
  R62  Documenso-first templates (list endpoint + admin route).
       New `listTemplates(portId)` in documenso-client paginates
       through every visible template on the configured instance
       (5-page cap at 100/page = 500 templates which comfortably
       covers every observed Documenso deploy). Handles v1 + v2
       endpoint shapes; normalises to `{ id, name }` summaries.
       New `GET /api/v1/admin/documenso/templates` route exposes
       the list to the admin UI (gated on `admin.manage_settings`).
       Powers the upcoming admin template picker — the field-mapping
       editor + sync-now button + per-template badges stay as the
       picker-UI follow-up. Data path is in place; UI surface
       lands in a dedicated PR alongside the field-mapping editor.

  T64  Duplicate E17 + missing partial unique index. Migration 0082
       deduplicates any existing (port_id, mooring_number) collisions
       by archiving all but the canonical row (prefers price-bearing
       rows, then earliest-created; archived rows carry an explicit
       `archive_reason` noting the migration). Adds partial unique
       index `uniq_berths_port_mooring_active` on (port_id,
       mooring_number) WHERE archived_at IS NULL so archived
       moorings can be reissued but live duplicates can't be
       created in the first place. Migration applied to dev DB.

  T65  Stage-advance gate. `changeInterestStage` now blocks any
       non-override transition into eoi / reservation / deposit_paid
       / contract when the primary berth has no price (NULL or 0)
       — these stages all render the price in templates / merge
       fields and a $0 generation is a real production gotcha.
       Override path (sales-manager fix) stays open and records
       the reason in audit log per the existing override-reason
       gate.

Deferred:
  U66  EOI bundle UX rework (10-14h) — multi-berth picker inside
       the EOI generate dialog. Schema (`interest_berths.isInEoiBundle`)
       and the rendered bundle-range preview row both exist; the
       remaining work is the picker UI + re-deriving merge tokens
       per selection state. Best done as a focused session with
       Documenso-side verification.

Verified: tsc clean, vitest 1454/1454, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 23:52:57 +02:00
parent c14f80a4f7
commit aa1f5d2835
4 changed files with 139 additions and 0 deletions

View File

@@ -974,6 +974,28 @@ export async function changeInterestStage(
);
}
// T65 (Bucket 4 bug #2): block advancing past Qualified onto a
// primary berth whose `price` is NULL. EOI / Reservation / Deposit /
// Contract docs all render the price in templates / merge fields and
// generating a $0 contract is a real production gotcha. Allow the
// skip when `override` is true so a sales-manager fix path stays
// open (recorded with a reason in audit log).
const PRICED_STAGES = new Set(['eoi', 'reservation', 'deposit_paid', 'contract']);
if (!data.override && PRICED_STAGES.has(data.pipelineStage)) {
const primaryBerth = await db
.select({ price: berths.price })
.from(interestBerths)
.innerJoin(berths, eq(interestBerths.berthId, berths.id))
.where(and(eq(interestBerths.interestId, id), eq(interestBerths.isPrimary, true)))
.limit(1);
const price = primaryBerth[0]?.price;
if (!price || parseFloat(String(price)) === 0) {
throw new ValidationError(
`Primary berth has no price set. Set the berth price before advancing past Qualified, or override with a reason.`,
);
}
}
const oldStage = existing.pipelineStage;
const [updated] = await db