fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish

Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 13:28:50 +02:00
parent 397dbd1490
commit 4b5f85cb7d
158 changed files with 12255 additions and 1303 deletions

View File

@@ -45,9 +45,18 @@ export async function POST(req: NextRequest) {
const ip = clientIp(req);
const rl = await checkRateLimit(ip, rateLimiters.auth);
if (!rl.allowed) {
// H-04: RFC 6585 §4 requires Retry-After on 429 so automated clients
// can back off correctly. rateLimitHeaders only emits the X-RateLimit-*
// triplet; checkRateLimit's helper enforcePublicRateLimit adds this
// header, but this route uses checkRateLimit directly so the header
// has to be added explicitly.
const retryAfter = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
return NextResponse.json(
{ error: { message: 'Too many attempts. Try again later.' } },
{ status: 429, headers: rateLimitHeaders(rl) },
{
status: 429,
headers: { ...rateLimitHeaders(rl), 'Retry-After': String(retryAfter) },
},
);
}

View File

@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { findTemplateIdByEnvelopeId } from '@/lib/services/documenso-client';
import { syncDocumensoTemplate } from '@/lib/services/documenso-template-sync.service';
/**
* POST /api/v1/admin/documenso/sync-template/:templateId
*
* Calls Documenso's GET /template/{id} via the configured per-port creds,
* pre-fills the matching documenso_*_recipient_id settings, and caches the
* field name→ID map at documenso_eoi_field_map for v2 prefillFields usage.
*
* Accepts either a numeric template ID (`123`) or a Documenso 2.x envelope
* ID (`envelope_xxxxxxxx`) — the latter is what the Documenso UI URL shows,
* so paste-from-URL works out of the box on v2 instances. Envelope IDs get
* resolved to their numeric template id via `findTemplateIdByEnvelopeId`
* before the sync runs.
*
* Admin-only via `admin.manage_settings`. Audit-logged through the per-field
* writeSetting calls inside the service.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const raw = params.templateId ?? '';
let templateId: number;
if (/^envelope_/.test(raw)) {
const resolved = await findTemplateIdByEnvelopeId(raw, ctx.portId);
if (!resolved) {
throw new NotFoundError(`Template "${raw}" — no matching envelopeId found`);
}
templateId = resolved;
} else {
templateId = Number(raw);
if (!Number.isInteger(templateId) || templateId <= 0) {
throw new ValidationError(
'templateId must be a positive integer or a Documenso envelopeId (envelope_…)',
);
}
}
const result = await syncDocumensoTemplate(templateId, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync.service';
/**
* GET /api/v1/admin/documenso/sync-template/report
*
* Returns the cached sync result from the most recent successful Sync run,
* so the admin panel's status box survives a page reload without re-hitting
* Documenso. Returns `{ data: null }` when no sync has run for this port.
*
* Admin-only via `admin.manage_settings` — same gate as the sync write
* endpoint, since the report contains template recipient identities and
* AcroForm field names that aren't OK to leak outside the admin surface.
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const report = await getEoiTemplateSyncReport(ctx.portId);
return NextResponse.json({ data: report });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { sendEmail } from '@/lib/email';
import { logger } from '@/lib/logger';
const bodySchema = z.object({
to: z.string().email().optional(),
});
/**
* Fire a test email through the per-port sales SMTP credentials. Used by
* the admin "Test SMTP" button on the Sales email config card to verify
* connectivity / auth without waiting for the next real send to fail.
*
* Sends a small text/HTML message to either the body-supplied `to` or
* (default) the admin's own email so they get the verification in their
* inbox. Returns { ok: true } on success or { ok: false, error } on
* failure — the admin UI rates accordingly.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const body = await parseBody(req, bodySchema);
const recipient = body.to ?? ctx.user.email;
if (!recipient) {
return NextResponse.json(
{ data: { ok: false, error: 'No recipient resolved — sign-in email is empty' } },
{ status: 200 },
);
}
try {
const subject = `Port Nimara CRM — SMTP test (${new Date().toLocaleTimeString()})`;
const html = `<p>Hello,</p><p>This is a test message sent from your CRM's <strong>Sales SMTP</strong> configuration. If you received this, your SMTP credentials work.</p><p style="color:#666;font-size:12px;">Timestamp: ${new Date().toISOString()}</p>`;
const text = `This is a test message sent from your CRM's Sales SMTP configuration. If you received this, your SMTP credentials work.\n\nTimestamp: ${new Date().toISOString()}`;
await sendEmail(recipient, subject, html, undefined, text, ctx.portId);
return NextResponse.json({ data: { ok: true, to: recipient } });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.warn({ portId: ctx.portId, err: message }, 'Sales SMTP test send failed');
return NextResponse.json({ data: { ok: false, error: message } });
}
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { readSetting, SETTING_KEYS } from '@/lib/services/port-config';
import { fetchWithTimeout, FetchTimeoutError } from '@/lib/fetch-with-timeout';
import { logger } from '@/lib/logger';
/**
* POST /api/v1/admin/embedded-signing/test
*
* Verifies that the configured `embedded_signing_host` (the marketing
* site that hosts the branded embedded-signing wrapper) is reachable
* and returns a 2xx for the test path. Used by the admin "Test
* connection" button on the Documenso settings page so an admin can
* tell whether their marketing-site cutover is ready BEFORE signers
* get sent there from outbound emails.
*
* Two checks:
* 1. Bare host returns 2xx — the site is up.
* 2. `/sign/health` (or `/`) returns 2xx within 5s — soft probe; not
* every marketing site exposes /sign/health, so we degrade to a
* root probe when the dedicated path 404s.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const host = await readSetting<string>(SETTING_KEYS.embeddedSigningHost, ctx.portId);
if (!host) {
return NextResponse.json({
data: {
ok: false,
error: 'No embedded_signing_host configured. Set the URL in Documenso settings first.',
},
});
}
const checked: Array<{ path: string; status?: number; ok: boolean; error?: string }> = [];
const probe = async (path: string) => {
try {
const res = await fetchWithTimeout(`${host.replace(/\/$/, '')}${path}`, {
method: 'GET',
redirect: 'manual',
});
checked.push({
path,
status: res.status,
ok: res.ok || (res.status >= 300 && res.status < 400),
});
return res.status;
} catch (err) {
const msg =
err instanceof FetchTimeoutError
? `timed out after ${err.timeoutMs}ms`
: err instanceof Error
? err.message
: String(err);
checked.push({ path, ok: false, error: msg });
return null;
}
};
// Try root first — it's the most universal signal of "the site is
// up." Then probe /sign/success which the post-signing redirect
// typically points to, so admins can also catch a stale path.
await probe('/');
await probe('/sign/success');
const anyOk = checked.some((c) => c.ok);
if (!anyOk) {
logger.warn({ portId: ctx.portId, host, checked }, 'Embedded signing host probe failed');
}
return NextResponse.json({
data: {
ok: anyOk,
host,
checks: checked,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { copyFromEnv } from '@/lib/settings/resolver';
/**
* POST /api/v1/admin/settings/:key/copy-from-env
*
* One-click migration helper used by the admin form's "Copy from env"
* button. Reads the env var named in the registry entry's `envFallback`
* field and writes it as the current scope's row. Returns `{ copied: false }`
* if the env var is unset / empty.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const result = await copyFromEnv(params.key!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { registryFor } from '@/lib/settings/registry';
import { getSetting } from '@/lib/settings/resolver';
/**
* POST /api/v1/admin/settings/:key/reveal
*
* Returns the decrypted cleartext for an encrypted / sensitive setting.
* Used by the eye-toggle on encrypted fields in the registry-driven admin
* form so the operator can verify what they saved earlier.
*
* Gated on `admin.manage_settings` (the same permission required to write
* the value — so this never widens an existing trust boundary). Every
* reveal is audit-logged with the request id so a super-admin can trace
* who looked at what and when.
*
* Refuses to reveal values resolved from `env` or `default` — those would
* leak server-process secrets via the API.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
const key = params.key!;
const entry = registryFor(key);
if (!entry) throw new NotFoundError(`Unknown setting: ${key}`);
if (!entry.encrypted && !entry.sensitive) {
// Non-sensitive values are already returned in the resolved-list
// endpoint, so a dedicated reveal isn't needed (and could be
// misused to bypass observability).
return NextResponse.json({ data: { revealed: false, value: null } }, { status: 200 });
}
// Resolve through the standard chain so the user sees exactly what
// the runtime would. The resolver decrypts on the way out.
const value = await getSetting<string>(key, ctx.portId);
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'view',
entityType: 'setting',
entityId: key,
metadata: { settingKey: key, op: 'reveal' },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: { revealed: true, value: value ?? null } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { writeSetting, deleteSetting } from '@/lib/settings/resolver';
const putSchema = z.object({
value: z.unknown(),
});
/**
* PUT /api/v1/admin/settings/:key
*
* Writes a registry-known setting. The resolver validates against the
* entry's Zod schema, encrypts at rest if registered as such, and writes
* an audit log with secrets masked.
*
* Body: { value: <whatever the entry's type accepts> }
*
* Empty / null `value` on a non-sensitive field DELETEs the row (reverts
* to global → env → default). On a sensitive/encrypted field, empty is a
* no-op so an unchanged save through the ••• placeholder doesn't wipe
* the stored ciphertext. Use the DELETE endpoint to explicitly revert.
*/
export const PUT = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
try {
const { value } = await parseBody(req, putSchema);
await writeSetting(params.key!, value, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);
/**
* DELETE /api/v1/admin/settings/:key
*
* Removes the row, reverting the resolver to global → env → default.
* 404 if no row exists at the appropriate scope.
*/
export const DELETE = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
try {
await deleteSetting(params.key!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { entriesForSections } from '@/lib/settings/registry';
import { resolveForAdminAPI } from '@/lib/settings/resolver';
/**
* GET /api/v1/admin/settings/resolved?sections=documenso.api,documenso.signers
*
* Returns the resolved value + source (port/global/env/default) for every
* registry entry in the requested sections. Drives the registry-driven
* admin form: the `source` field gates the "Using env fallback" badge.
*
* Sensitive fields surface `isSet` only — never the decrypted value.
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx) => {
try {
const url = new URL(req.url);
const sectionsParam = url.searchParams.get('sections');
if (!sectionsParam) {
return NextResponse.json({ data: { entries: [], values: {} } }, { status: 200 });
}
const sections = sectionsParam
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const entries = entriesForSections(sections);
const keys = entries.map((e) => e.key);
const resolved = await resolveForAdminAPI(keys, ctx.portId);
// Return the entry metadata so the client can render labels/types
// without bundling the registry into the client JS. Strip the
// `validator` + `transform` function references — they're not
// JSON-serializable.
const entriesForClient = entries.map((e) => ({
key: e.key,
section: e.section,
label: e.label,
description: e.description,
type: e.type,
options: e.options,
encrypted: !!e.encrypted,
sensitive: !!(e.sensitive || e.encrypted),
scope: e.scope,
envFallback: e.envFallback,
placeholder: e.placeholder,
defaultValue: e.defaultValue,
}));
const values: Record<string, unknown> = {};
for (const [k, r] of resolved.entries()) {
values[k] = r;
}
return NextResponse.json({ data: { entries: entriesForClient, values } });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { and, eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { user, userPortRoles, userProfiles } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
/**
* GET /api/v1/admin/users/picker
*
* Lightweight list of users in the active port, used by admin form
* user-select dropdowns (e.g. linking a CRM user to a Documenso recipient
* slot). Returns only the fields needed to render an option: id, email,
* name. Excludes deactivated users.
*
* Gated on `admin.manage_settings` — anyone editing per-port admin
* settings can already see all the configured Documenso recipient
* email/name values, so revealing the user roster to them doesn't
* widen the trust boundary. Tighter than the full `admin/users` GET
* (which is `admin.manage_users`-gated).
*/
export const GET = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const rows = await db
.select({
id: user.id,
email: user.email,
name: user.name,
isActive: userProfiles.isActive,
})
.from(user)
.innerJoin(userPortRoles, eq(userPortRoles.userId, user.id))
.leftJoin(userProfiles, eq(userProfiles.userId, user.id))
.where(and(eq(userPortRoles.portId, ctx.portId)));
// Dedupe by id (a user with multiple role rows in this port would
// otherwise repeat) and drop deactivated profiles.
const seen = new Set<string>();
const data = rows
.filter((r) => r.isActive !== false)
.filter((r) => {
if (seen.has(r.id)) return false;
seen.add(r.id);
return true;
})
.map(({ id, email, name }) => ({ id, email, name }));
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -69,6 +69,7 @@ export async function getMatchCandidatesHandler(
id: clients.id,
fullName: clients.fullName,
nationalityIso: clients.nationalityIso,
archivedAt: clients.archivedAt,
})
.from(clients)
.where(and(eq(clients.portId, ctx.portId)));
@@ -142,6 +143,13 @@ export async function getMatchCandidatesHandler(
interestsByClient.set(r.clientId, (interestsByClient.get(r.clientId) ?? 0) + 1);
}
// Build a lookup from the original pool for archived flag — the dedup
// candidate type intentionally doesn't carry it, but the suggestion card
// needs to differentiate "use this live client" from "restore this
// archived client". Without this the UX swallows soft-deleted dupes.
const archivedById = new Map<string, Date | null>();
for (const c of liveClients) archivedById.set(c.id, c.archivedAt ?? null);
const data = useful.map((m) => ({
clientId: m.candidate.id,
fullName: m.candidate.fullName,
@@ -151,6 +159,7 @@ export async function getMatchCandidatesHandler(
interestCount: interestsByClient.get(m.candidate.id) ?? 0,
emails: m.candidate.emails,
phonesE164: m.candidate.phonesE164,
archivedAt: archivedById.get(m.candidate.id)?.toISOString() ?? null,
}));
return NextResponse.json({ data });

View File

@@ -26,6 +26,7 @@ export const POST = withAuth(
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
{ dimensionUnit: body.dimensionUnit },
);
return NextResponse.json({ data: result }, { status: 201 });
} catch (error) {

View File

@@ -1,18 +1,44 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { cancelDocument } from '@/lib/services/documents.service';
const cancelBodySchema = z
.object({
reason: z.string().max(2000).optional().nullable(),
notifyRecipients: z.array(z.string().uuid()).max(20).optional(),
})
.strict()
.optional();
export const POST = withAuth(
withPermission('documents', 'edit', async (_req, ctx, params) => {
withPermission('documents', 'edit', async (req, ctx, params) => {
try {
const doc = await cancelDocument(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
// Body is optional — legacy callers POST with `{}`. parseBody returns
// null when the request has no body; default to empty options.
let body: z.infer<typeof cancelBodySchema> = undefined;
try {
body = await parseBody(req, cancelBodySchema);
} catch {
body = undefined;
}
const doc = await cancelDocument(
params.id!,
ctx.portId,
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
{
reason: body?.reason ?? null,
notifyRecipients: body?.notifyRecipients ?? [],
},
);
return NextResponse.json({ data: doc });
} catch (error) {
return errorResponse(error);

View File

@@ -54,15 +54,99 @@ export const POST = withAuth(
.where(eq(documentSigners.documentId, documentId))
.orderBy(asc(documentSigners.signingOrder));
const target = body.recipientId
let target = body.recipientId
? signers.find((s) => s.id === body.recipientId)
: signers.find((s) => s.status === 'pending');
if (!target) {
throw new ValidationError('No pending signer found to invite');
}
// Self-heal flow when target.signingUrl is null. Two scenarios:
// 1. Envelope was created before the auto-distribute fix shipped
// — never distributed, so we must call /envelope/distribute
// to mint URLs.
// 2. Envelope WAS auto-distributed at generate time, but the
// response we got didn't carry signingUrls into our DB row
// (transient Documenso bug, or response shape mismatch).
// In that case the envelope is already PENDING and a second
// /distribute call returns 4xx ("already distributed").
//
// Defensive flow: try `getEnvelope` FIRST (cheap, always works).
// If recipients carry signingUrls, persist + skip distribute.
// If not, fall through to distribute, but catch 4xx so we don't
// surface a confusing "Documenso upstream error" to the rep —
// instead we re-fetch via GET one more time and accept whatever
// URLs the envelope has.
if (!target.signingUrl && doc.documensoId) {
const { distributeEnvelopeV2, getDocument } =
await import('@/lib/services/documenso-client');
const persistUrlsForDocument = async (
recipients: Array<{
signingOrder: number;
signingUrl?: string;
embeddedUrl?: string;
token?: string;
}>,
) => {
for (const r of recipients) {
if (!r.signingUrl) continue;
await db
.update(documentSigners)
.set({
signingUrl: r.signingUrl,
embeddedUrl: r.embeddedUrl ?? null,
signingToken: r.token ?? null,
})
.where(
and(
eq(documentSigners.documentId, documentId),
eq(documentSigners.signingOrder, r.signingOrder),
),
);
}
};
// Step 1: cheap GET.
let recovered = false;
try {
const fetched = await getDocument(doc.documensoId, ctx.portId);
if (fetched.recipients.some((r) => r.signingUrl)) {
await persistUrlsForDocument(fetched.recipients);
recovered = true;
}
} catch {
// ignore — fall through to distribute attempt
}
// Step 2: distribute, only if GET didn't recover URLs.
if (!recovered) {
try {
const distributed = await distributeEnvelopeV2(doc.documensoId, ctx.portId);
await persistUrlsForDocument(distributed.recipients);
} catch {
// Probably "already distributed" — last-ditch GET.
try {
const fetched = await getDocument(doc.documensoId, ctx.portId);
await persistUrlsForDocument(fetched.recipients);
} catch {
// give up; the validator below surfaces a clean error
}
}
}
// Re-read target so its signingUrl is now populated.
const refreshed = await db
.select()
.from(documentSigners)
.where(eq(documentSigners.id, target.id))
.limit(1);
target = refreshed[0] ?? target;
}
if (!target.signingUrl) {
throw new ValidationError(
'Signer has no Documenso URL yet — generate or send the document first',
'Signer has no Documenso URL yet — try regenerating the EOI; v2 envelopes require distribution before the signing link exists.',
);
}

View File

@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import { getEoiTemplateSyncReport } from '@/lib/services/documenso-template-sync.service';
/**
* GET `/api/v1/documents/signing-defaults`
@@ -21,6 +22,21 @@ export const GET = withAuth(
withPermission('documents', 'send_for_signing', async (_req, ctx) => {
try {
const cfg = await getPortDocumensoConfig(ctx.portId);
// Signing order resolution chain (highest → lowest priority):
// 1. Cached `documento_eoi_template_sync_report.templateMeta.signingOrder`
// — populated by the admin "Sync from Documenso" button and
// represents the live template's bound order. On v2 this is the
// authoritative value because `/template/use` doesn't accept a
// per-call override.
// 2. Per-port `documenso_signing_order` setting from
// getPortDocumensoConfig (used by v1 + as a UI fallback when the
// admin hasn't run a sync yet).
// 3. Documenso's own default (`PARALLEL` = concurrent signing).
const syncReport = await getEoiTemplateSyncReport(ctx.portId).catch(() => null);
const signingOrder: 'PARALLEL' | 'SEQUENTIAL' =
syncReport?.templateMeta?.signingOrder ?? cfg.signingOrder ?? 'PARALLEL';
return NextResponse.json({
data: {
developer: {
@@ -34,6 +50,16 @@ export const GET = withAuth(
label: cfg.approverLabel ?? 'Approver',
},
sendMode: cfg.sendMode,
signingOrder,
// Surface where the value came from so the UI tooltip can be
// honest about the source. Helps reps debug "I changed it in
// Documenso but the CRM still says X" — they need to re-run
// Sync to pull the change.
signingOrderSource: syncReport?.templateMeta?.signingOrder
? 'template'
: cfg.signingOrder
? 'port-setting'
: 'default',
},
});
} catch (error) {

View File

@@ -10,7 +10,12 @@ export const PATCH = withAuth(
withPermission('email', 'configure_account', async (req, ctx, params) => {
try {
const body = await parseBody(req, toggleAccountSchema);
const account = await toggleAccount(params.accountId!, ctx.userId, body);
const account = await toggleAccount(params.accountId!, ctx.userId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: account });
} catch (error) {
return errorResponse(error);

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/helpers';
import { env } from '@/lib/env';
/**
* GET /api/v1/internal/dev-flags
*
* Read-only feed of dev-mode safety flags that the UI surfaces as
* always-visible badges. Authenticated (any signed-in user) — these
* flags affect every outbound email so reps need to see them too,
* not just admins.
*
* Today returns just `emailRedirectTo`. Add more flags here (e.g.
* MOCK_DOCUMENSO, FAKE_PAYMENTS, READ_ONLY_DB) as they appear.
*/
export const GET = withAuth(async () => {
return NextResponse.json({
data: {
emailRedirectTo: env.EMAIL_REDIRECT_TO ?? null,
isDev: env.NODE_ENV !== 'production',
},
});
});