From aa1f5d283536a770ca6ab6a5b749a18287cc654c Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 23:52:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(uat-batch):=20Groups=20R=20+=20T=20?= =?UTF-8?q?=E2=80=94=20Documenso=20list=20+=20deferred=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../api/v1/admin/documenso/templates/route.ts | 30 ++++++++++++ .../migrations/0082_berth_mooring_unique.sql | 48 +++++++++++++++++++ src/lib/services/documenso-client.ts | 39 +++++++++++++++ src/lib/services/interests.service.ts | 22 +++++++++ 4 files changed, 139 insertions(+) create mode 100644 src/app/api/v1/admin/documenso/templates/route.ts create mode 100644 src/lib/db/migrations/0082_berth_mooring_unique.sql diff --git a/src/app/api/v1/admin/documenso/templates/route.ts b/src/app/api/v1/admin/documenso/templates/route.ts new file mode 100644 index 00000000..ef581ff7 --- /dev/null +++ b/src/app/api/v1/admin/documenso/templates/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { listTemplates } from '@/lib/services/documenso-client'; + +/** + * GET /api/v1/admin/documenso/templates + * + * Lists every Documenso template visible to the configured API key + * for the calling port. Drives the "Documenso-first templates" admin + * picker (R62) — reps see real template names instead of having to + * type numeric IDs. + * + * Gated on `admin.manage_settings` since the data exposed is essentially + * the same surface area as the Documenso settings page itself. + * + * Response shape: `{ data: Array<{ id, name }> }`. Cached client-side + * by the picker for ~5 minutes. + */ +export const GET = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx) => { + try { + const templates = await listTemplates(ctx.portId); + return NextResponse.json({ data: templates }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/lib/db/migrations/0082_berth_mooring_unique.sql b/src/lib/db/migrations/0082_berth_mooring_unique.sql new file mode 100644 index 00000000..b96bd0fc --- /dev/null +++ b/src/lib/db/migrations/0082_berth_mooring_unique.sql @@ -0,0 +1,48 @@ +-- 2026-05-21: enforce unique mooring numbers per port at the DB level. +-- +-- The canonical mooring regex is `^[A-Z]+\d+$` (e.g. `A1`, `B12`) and +-- the UI gates on uniqueness, but no DB-level constraint existed — +-- which led to a duplicate E17 row in port-nimara surfaced during UAT. +-- Partial unique index lets archived rows reuse a mooring (an old A1 +-- can be re-issued after the original is archived). +-- +-- Before applying: any existing duplicates must be resolved. The +-- backfill below merges duplicate E17-style rows into a single canonical +-- row, preferring the one with a price (real berth) over the empty +-- shadow. If multiple price-bearing rows exist, the earliest-created +-- wins and the others archive. + +-- Phase 1: dedupe. Pick a canonical id per (port_id, mooring_number) +-- where archived_at IS NULL. +WITH ranked AS ( + SELECT + id, + port_id, + mooring_number, + price, + created_at, + ROW_NUMBER() OVER ( + PARTITION BY port_id, mooring_number + ORDER BY + -- Prefer rows that have a price (real berths) + CASE WHEN price IS NOT NULL THEN 0 ELSE 1 END, + -- Then earliest-created so the audit trail stays consistent + created_at ASC, + id ASC + ) AS rn + FROM berths + WHERE archived_at IS NULL +) +UPDATE berths +SET + archived_at = now(), + archive_reason = 'Auto-archived 2026-05-21: duplicate of canonical row (mooring uniqueness migration)' +WHERE id IN ( + SELECT id FROM ranked WHERE rn > 1 +); + +-- Phase 2: the partial unique index. Excludes archived rows so old +-- moorings can be reissued; includes everything else. +CREATE UNIQUE INDEX IF NOT EXISTS uniq_berths_port_mooring_active + ON berths (port_id, mooring_number) + WHERE archived_at IS NULL; diff --git a/src/lib/services/documenso-client.ts b/src/lib/services/documenso-client.ts index 1393a94a..8fa0b50c 100644 --- a/src/lib/services/documenso-client.ts +++ b/src/lib/services/documenso-client.ts @@ -826,6 +826,45 @@ export async function downloadEnvelopeItemPdf( return Buffer.from(await res.arrayBuffer()); } +/** + * List every template visible to the configured API key. Used by the + * admin "Documenso-first templates" picker (R62) so reps can browse + * available templates instead of typing numeric IDs. + * + * v2 path: paginated `GET /api/v2/template`. Returns 100 per page; we + * walk through pages until empty (cap at 5 pages = 500 templates which + * comfortably covers every observed Documenso instance). + * + * v1 path: `GET /api/v1/templates`. Same pagination via + * `?page=N&perPage=100`. v1 returns the legacy shape — we normalize to + * the same `{ id, name }` summary the UI consumes. + */ +export async function listTemplates( + portId: string | undefined, +): Promise> { + const { apiVersion } = await resolveCreds(portId); + const out: Array<{ id: number; name: string }> = []; + for (let page = 1; page <= 5; page++) { + const path = + apiVersion === 'v2' + ? `/api/v2/template?perPage=100&page=${page}` + : `/api/v1/templates?perPage=100&page=${page}`; + const res = (await documensoFetch(path, undefined, portId)) as { + templates?: Array<{ id?: number; templateId?: number; name?: string; title?: string }>; + data?: Array<{ id?: number; templateId?: number; name?: string; title?: string }>; + }; + const items = res.templates ?? res.data ?? []; + if (items.length === 0) break; + for (const t of items) { + const id = t.templateId ?? t.id; + const name = t.name ?? t.title ?? ''; + if (typeof id === 'number') out.push({ id, name }); + } + if (items.length < 100) break; + } + return out; +} + /** * Fetch a Documenso template by ID. Used by the admin "Sync from Documenso" * flow to discover recipient slot IDs + template field IDs without forcing diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index a3b9a8e5..e3947561 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -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