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