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:
30
src/app/api/v1/admin/documenso/templates/route.ts
Normal file
30
src/app/api/v1/admin/documenso/templates/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
48
src/lib/db/migrations/0082_berth_mooring_unique.sql
Normal file
48
src/lib/db/migrations/0082_berth_mooring_unique.sql
Normal file
@@ -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;
|
||||||
@@ -826,6 +826,45 @@ export async function downloadEnvelopeItemPdf(
|
|||||||
return Buffer.from(await res.arrayBuffer());
|
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<Array<{ id: number; name: string }>> {
|
||||||
|
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"
|
* Fetch a Documenso template by ID. Used by the admin "Sync from Documenso"
|
||||||
* flow to discover recipient slot IDs + template field IDs without forcing
|
* flow to discover recipient slot IDs + template field IDs without forcing
|
||||||
|
|||||||
@@ -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 oldStage = existing.pipelineStage;
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
|
|||||||
Reference in New Issue
Block a user