Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
|
|
|
import { createHash } from 'crypto';
|
2026-05-12 18:33:10 +02:00
|
|
|
import { match } from 'ts-pattern';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
2026-04-27 13:46:48 +02:00
|
|
|
import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
|
fix(audit-tier-6): validation, perms, ops/infra, per-port webhook secret
Final audit polish — closes the remaining LOW + MED items the previous
tiers didn't reach:
* Validation hardening: me.preferences uses .strict() + 8KB cap
instead of unbounded .passthrough(); files.uploadFile gains
magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan
endpoint enforces 10MB cap + magic-byte check on receipt images;
port logoUrl + me.avatarUrl reject javascript:/data: schemes via
a shared httpUrl refinement.
* Permission gates: document-sends/{brochure,berth-pdf} now require
email.send (was withAuth-only); document-sends/{preview,list} on
email.view; ai/email-draft on email.send; documents/[id]/send
uses send_for_signing (was create); expenses/export/parent-company
flips from hard isSuperAdmin to expenses.export for parity;
admin/users/options gated on reminders.assign_others (was withAuth).
* Envelope hygiene: auth/set-password switches the third {message}
variant to errorResponse + {data: {email}}; ai/email-draft wraps
jobId in {data: {jobId}}.
* UI polish: reports-list.handleDownload surfaces failures via
toastError (was console-only).
* Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles +
packageManager field in package.json; Dockerfile.worker re-orders
user creation BEFORE pnpm install so node_modules / .cache dirs
are worker-owned (fixes tesseract.js + sharp EACCES at first PDF
parse); add Redis-ping HEALTHCHECK to the worker container.
* Public health endpoint: returns full env+appUrl payload only when
the caller presents X-Intake-Secret, otherwise a minimal {status}
so generic uptime monitors still work but anonymous internet
doesn't get deployment fingerprints.
* Per-port Documenso webhook secret: new system_settings key
+ listDocumensoWebhookSecrets() helper. The webhook receiver
iterates every configured per-port secret with timing-safe
comparison + falls back to env, then forwards the resolved portId
into handleDocumentExpired so two ports sharing a documensoId
cannot cross-mutate.
Deferred (handled in dedicated follow-up PRs):
* Tier 5.1 — direct service tests for portal-auth / users /
email-accounts / document-sends / sales-email-config. MED, large
test-writing scope.
* The {ok: true} → {data: null} envelope migration across
alerts/expenses/admin-ocr-settings/storage routes. Mechanical but
needs coordinated client + test updates.
* CSP-nonce migration (drop unsafe-inline) — needs middleware-level
nonce generation that the Next 15 router has to thread through.
* Idempotency-Key header on Documenso createDocument. Requires
schema column on documents to persist the key; deferred so it
doesn't bundle a migration into this commit.
* The 16 better-auth user_id FKs — separate dedicated migration
with care (some columns are NOT NULL today and cascade decisions
matter).
* PermissionGate / Skeleton / EmptyState wraps across 5 admin lists
(auditor-H §§36–37) and the residential-clients filter bar.
Test status: 1175/1175 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43
+ HIGH §9 (Documenso secrets follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:03:31 +02:00
|
|
|
import { listDocumensoWebhookSecrets } from '@/lib/services/port-config';
|
2026-05-13 13:47:33 +02:00
|
|
|
import { extractSigningToken } from '@/lib/services/documenso-signers';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import {
|
|
|
|
|
handleRecipientSigned,
|
|
|
|
|
handleDocumentCompleted,
|
2026-05-02 23:00:58 +02:00
|
|
|
handleDocumentExpired,
|
2026-04-27 13:46:48 +02:00
|
|
|
handleDocumentOpened,
|
|
|
|
|
handleDocumentRejected,
|
|
|
|
|
handleDocumentCancelled,
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
} from '@/lib/services/documents.service';
|
|
|
|
|
import { logger } from '@/lib/logger';
|
2026-05-06 20:44:38 +02:00
|
|
|
import { createAuditLog } from '@/lib/audit';
|
2026-05-06 22:06:40 +02:00
|
|
|
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
audit: Tier 1/3/4/5/7 batch — SSE, gates, dedup, URL escape, FK constraints
Tier 1.6: S3Backend.put now sets ServerSideEncryption=AES256 — closes
the cleartext-at-rest gap for signed contracts, GDPR exports, pg_dumps.
Tier 3.7: New safeUrl() helper in lib/email/shell.ts. Scheme allow-list
(http/https/mailto/tel/relative only — javascript:/data:/vbscript:/file:
rewritten to about:blank) + HTML-attribute escape. Retrofitted across
all 7 transactional templates (crm-invite, portal-auth, document-signing,
notification-digest, residential-inquiry, admin-email-change).
Tier 4.2: /api/v1/alerts GET now gated on admin.view_audit_log.
Tier 4.3: Documenso webhook handler emits captureErrorEvent on catch.
Admin/errors no longer silent on webhook crashes.
Tier 4.6: Inquiry-funnel email dedup is now case-insensitive
(LOWER(value)) and stores normalized email on insert. Capital-letter
resubmissions no longer spawn duplicate client+yacht+interest rows.
Tier 5.6 + data-model H1: migration 0056 adds FK
user_permission_overrides.user_id → user(id) cascade, same for
user_port_roles.userId, plus partial unique index on
user_email_changes pending rows.
Tier 7.6: @types/node bumped from ^25 to ^20.19.0 — matches the runtime.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:09:14 +02:00
|
|
|
import { captureErrorEvent } from '@/lib/services/error-events.service';
|
2026-05-13 11:50:07 +02:00
|
|
|
import { withPublicContext } from '@/lib/api/helpers';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
// BR-024: Dedup via signatureHash unique index on documentEvents
|
|
|
|
|
// Always return 200 from webhook (webhook best practice)
|
|
|
|
|
|
2026-04-27 13:46:48 +02:00
|
|
|
// Documenso emits Prisma enum names on the wire (e.g. "DOCUMENT_SIGNED").
|
|
|
|
|
// The UI displays them as lowercase-dotted ("document.signed") but the JSON
|
|
|
|
|
// body uses the enum value as-is. Normalize both forms in case 2.x ever flips.
|
|
|
|
|
function canonicalizeEvent(event: string): string {
|
|
|
|
|
return event.toUpperCase().replace(/\./g, '_');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 18:33:10 +02:00
|
|
|
// Discriminated union of every Documenso event we know how to react to.
|
|
|
|
|
// Adding a new event type forces a compile error in the `match(...)`
|
|
|
|
|
// below via `.exhaustive()` — so we can't ship a Documenso 2.x bump
|
|
|
|
|
// without consciously deciding how to handle each new event. Anything
|
|
|
|
|
// not in this list falls through to the structured-log catch-all below.
|
|
|
|
|
type KnownDocumensoEvent =
|
|
|
|
|
| 'DOCUMENT_SIGNED'
|
|
|
|
|
| 'DOCUMENT_RECIPIENT_COMPLETED'
|
|
|
|
|
| 'RECIPIENT_SIGNED'
|
|
|
|
|
| 'DOCUMENT_OPENED'
|
|
|
|
|
| 'RECIPIENT_VIEWED'
|
|
|
|
|
| 'DOCUMENT_COMPLETED'
|
|
|
|
|
| 'DOCUMENT_REJECTED'
|
|
|
|
|
| 'DOCUMENT_DECLINED'
|
|
|
|
|
| 'DOCUMENT_CANCELLED'
|
|
|
|
|
| 'DOCUMENT_EXPIRED'
|
|
|
|
|
| 'DOCUMENT_REMINDER_SENT'
|
|
|
|
|
| 'DOCUMENT_CREATED'
|
|
|
|
|
| 'DOCUMENT_SENT';
|
|
|
|
|
|
|
|
|
|
const KNOWN_DOCUMENSO_EVENTS: ReadonlySet<KnownDocumensoEvent> = new Set<KnownDocumensoEvent>([
|
|
|
|
|
'DOCUMENT_SIGNED',
|
|
|
|
|
'DOCUMENT_RECIPIENT_COMPLETED',
|
|
|
|
|
'RECIPIENT_SIGNED',
|
|
|
|
|
'DOCUMENT_OPENED',
|
|
|
|
|
'RECIPIENT_VIEWED',
|
|
|
|
|
'DOCUMENT_COMPLETED',
|
|
|
|
|
'DOCUMENT_REJECTED',
|
|
|
|
|
'DOCUMENT_DECLINED',
|
|
|
|
|
'DOCUMENT_CANCELLED',
|
|
|
|
|
'DOCUMENT_EXPIRED',
|
|
|
|
|
'DOCUMENT_REMINDER_SENT',
|
|
|
|
|
'DOCUMENT_CREATED',
|
|
|
|
|
'DOCUMENT_SENT',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
function isKnownEvent(event: string): event is KnownDocumensoEvent {
|
|
|
|
|
return KNOWN_DOCUMENSO_EVENTS.has(event as KnownDocumensoEvent);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 13:47:33 +02:00
|
|
|
/**
|
|
|
|
|
* Pull the recipient's signing token out of a Documenso webhook
|
|
|
|
|
* payload. v1.13 emits `recipients[].token`; some 2.x payloads use
|
|
|
|
|
* `signingToken`; both versions always carry a `signingUrl` whose tail
|
|
|
|
|
* IS the token. Prefer the explicit fields, fall back to URL extraction
|
|
|
|
|
* so the cascade still works when Documenso reshapes its payload.
|
|
|
|
|
*/
|
|
|
|
|
function resolveRecipientToken(r: DocumensoRecipient): string | null {
|
|
|
|
|
if (r.token) return r.token;
|
|
|
|
|
if (r.signingToken) return r.signingToken;
|
|
|
|
|
if (r.signingUrl) return extractSigningToken(r.signingUrl);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 13:46:48 +02:00
|
|
|
type DocumensoRecipient = {
|
|
|
|
|
email: string;
|
|
|
|
|
signingStatus?: string;
|
|
|
|
|
readStatus?: string;
|
|
|
|
|
signedAt?: string | null;
|
2026-05-13 13:47:33 +02:00
|
|
|
/** Per-recipient signing token Documenso uses as the URL tail.
|
|
|
|
|
* Present on both v1.13 and v2 payloads under varied field names —
|
|
|
|
|
* we coalesce them below. Phase 2: passed through to the handlers
|
|
|
|
|
* so they can match against `document_signers.signing_token`
|
|
|
|
|
* instead of email. */
|
|
|
|
|
token?: string | null;
|
|
|
|
|
signingToken?: string | null;
|
|
|
|
|
signingUrl?: string | null;
|
2026-04-27 13:46:48 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type DocumensoWebhookBody = {
|
|
|
|
|
event: string;
|
|
|
|
|
payload: {
|
|
|
|
|
id: number | string;
|
|
|
|
|
recipients?: DocumensoRecipient[];
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-13 11:50:07 +02:00
|
|
|
async function handleDocumensoWebhook(req: NextRequest): Promise<NextResponse> {
|
2026-04-27 13:46:48 +02:00
|
|
|
let rawBody: string;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
try {
|
2026-04-27 13:46:48 +02:00
|
|
|
rawBody = await req.text();
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
} catch {
|
|
|
|
|
return NextResponse.json({ ok: false }, { status: 200 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 13:46:48 +02:00
|
|
|
// Documenso v1.13 + 2.x send the secret in plaintext via X-Documenso-Secret.
|
fix(audit-tier-6): validation, perms, ops/infra, per-port webhook secret
Final audit polish — closes the remaining LOW + MED items the previous
tiers didn't reach:
* Validation hardening: me.preferences uses .strict() + 8KB cap
instead of unbounded .passthrough(); files.uploadFile gains
magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan
endpoint enforces 10MB cap + magic-byte check on receipt images;
port logoUrl + me.avatarUrl reject javascript:/data: schemes via
a shared httpUrl refinement.
* Permission gates: document-sends/{brochure,berth-pdf} now require
email.send (was withAuth-only); document-sends/{preview,list} on
email.view; ai/email-draft on email.send; documents/[id]/send
uses send_for_signing (was create); expenses/export/parent-company
flips from hard isSuperAdmin to expenses.export for parity;
admin/users/options gated on reminders.assign_others (was withAuth).
* Envelope hygiene: auth/set-password switches the third {message}
variant to errorResponse + {data: {email}}; ai/email-draft wraps
jobId in {data: {jobId}}.
* UI polish: reports-list.handleDownload surfaces failures via
toastError (was console-only).
* Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles +
packageManager field in package.json; Dockerfile.worker re-orders
user creation BEFORE pnpm install so node_modules / .cache dirs
are worker-owned (fixes tesseract.js + sharp EACCES at first PDF
parse); add Redis-ping HEALTHCHECK to the worker container.
* Public health endpoint: returns full env+appUrl payload only when
the caller presents X-Intake-Secret, otherwise a minimal {status}
so generic uptime monitors still work but anonymous internet
doesn't get deployment fingerprints.
* Per-port Documenso webhook secret: new system_settings key
+ listDocumensoWebhookSecrets() helper. The webhook receiver
iterates every configured per-port secret with timing-safe
comparison + falls back to env, then forwards the resolved portId
into handleDocumentExpired so two ports sharing a documensoId
cannot cross-mutate.
Deferred (handled in dedicated follow-up PRs):
* Tier 5.1 — direct service tests for portal-auth / users /
email-accounts / document-sends / sales-email-config. MED, large
test-writing scope.
* The {ok: true} → {data: null} envelope migration across
alerts/expenses/admin-ocr-settings/storage routes. Mechanical but
needs coordinated client + test updates.
* CSP-nonce migration (drop unsafe-inline) — needs middleware-level
nonce generation that the Next 15 router has to thread through.
* Idempotency-Key header on Documenso createDocument. Requires
schema column on documents to persist the key; deferred so it
doesn't bundle a migration into this commit.
* The 16 better-auth user_id FKs — separate dedicated migration
with care (some columns are NOT NULL today and cascade decisions
matter).
* PermissionGate / Skeleton / EmptyState wraps across 5 admin lists
(auditor-H §§36–37) and the residential-clients filter bar.
Test status: 1175/1175 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43
+ HIGH §9 (Documenso secrets follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:03:31 +02:00
|
|
|
// Resolve the matching port by trying each configured per-port secret
|
|
|
|
|
// (plus the global env fallback) with timing-safe comparison. The
|
|
|
|
|
// resolved portId, when non-null, is threaded into handleDocumentExpired
|
|
|
|
|
// so two ports sharing a documensoId can't cross-mutate (auditor-D §22).
|
2026-04-27 13:46:48 +02:00
|
|
|
const providedSecret = req.headers.get('x-documenso-secret') ?? '';
|
fix(audit-tier-6): validation, perms, ops/infra, per-port webhook secret
Final audit polish — closes the remaining LOW + MED items the previous
tiers didn't reach:
* Validation hardening: me.preferences uses .strict() + 8KB cap
instead of unbounded .passthrough(); files.uploadFile gains
magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan
endpoint enforces 10MB cap + magic-byte check on receipt images;
port logoUrl + me.avatarUrl reject javascript:/data: schemes via
a shared httpUrl refinement.
* Permission gates: document-sends/{brochure,berth-pdf} now require
email.send (was withAuth-only); document-sends/{preview,list} on
email.view; ai/email-draft on email.send; documents/[id]/send
uses send_for_signing (was create); expenses/export/parent-company
flips from hard isSuperAdmin to expenses.export for parity;
admin/users/options gated on reminders.assign_others (was withAuth).
* Envelope hygiene: auth/set-password switches the third {message}
variant to errorResponse + {data: {email}}; ai/email-draft wraps
jobId in {data: {jobId}}.
* UI polish: reports-list.handleDownload surfaces failures via
toastError (was console-only).
* Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles +
packageManager field in package.json; Dockerfile.worker re-orders
user creation BEFORE pnpm install so node_modules / .cache dirs
are worker-owned (fixes tesseract.js + sharp EACCES at first PDF
parse); add Redis-ping HEALTHCHECK to the worker container.
* Public health endpoint: returns full env+appUrl payload only when
the caller presents X-Intake-Secret, otherwise a minimal {status}
so generic uptime monitors still work but anonymous internet
doesn't get deployment fingerprints.
* Per-port Documenso webhook secret: new system_settings key
+ listDocumensoWebhookSecrets() helper. The webhook receiver
iterates every configured per-port secret with timing-safe
comparison + falls back to env, then forwards the resolved portId
into handleDocumentExpired so two ports sharing a documensoId
cannot cross-mutate.
Deferred (handled in dedicated follow-up PRs):
* Tier 5.1 — direct service tests for portal-auth / users /
email-accounts / document-sends / sales-email-config. MED, large
test-writing scope.
* The {ok: true} → {data: null} envelope migration across
alerts/expenses/admin-ocr-settings/storage routes. Mechanical but
needs coordinated client + test updates.
* CSP-nonce migration (drop unsafe-inline) — needs middleware-level
nonce generation that the Next 15 router has to thread through.
* Idempotency-Key header on Documenso createDocument. Requires
schema column on documents to persist the key; deferred so it
doesn't bundle a migration into this commit.
* The 16 better-auth user_id FKs — separate dedicated migration
with care (some columns are NOT NULL today and cascade decisions
matter).
* PermissionGate / Skeleton / EmptyState wraps across 5 admin lists
(auditor-H §§36–37) and the residential-clients filter bar.
Test status: 1175/1175 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43
+ HIGH §9 (Documenso secrets follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:03:31 +02:00
|
|
|
const secrets = await listDocumensoWebhookSecrets();
|
|
|
|
|
let matchedPortId: string | null = null;
|
|
|
|
|
let matched = false;
|
|
|
|
|
for (const entry of secrets) {
|
|
|
|
|
if (verifyDocumensoSecret(providedSecret, entry.secret)) {
|
|
|
|
|
matched = true;
|
|
|
|
|
matchedPortId = entry.portId;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!matched) {
|
2026-05-06 22:06:40 +02:00
|
|
|
const callerIp =
|
|
|
|
|
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
|
|
|
|
req.headers.get('x-real-ip') ??
|
|
|
|
|
'unknown';
|
|
|
|
|
// Rate-limit per IP. Real Documenso traffic won't fail the secret
|
|
|
|
|
// check, so any traffic here is enumeration / brute-force; we cap
|
|
|
|
|
// it sharply to keep audit-log volume bounded too.
|
|
|
|
|
const rl = await checkRateLimit(callerIp, rateLimiters.webhookBadSecret);
|
|
|
|
|
logger.warn(
|
|
|
|
|
{ providedLen: providedSecret.length, ip: callerIp, allowed: rl.allowed },
|
|
|
|
|
'Invalid Documenso webhook secret',
|
|
|
|
|
);
|
|
|
|
|
if (rl.allowed) {
|
|
|
|
|
void createAuditLog({
|
|
|
|
|
userId: null,
|
|
|
|
|
portId: null,
|
|
|
|
|
action: 'webhook_failed',
|
|
|
|
|
entityType: 'webhook_inbound',
|
|
|
|
|
entityId: 'documenso',
|
|
|
|
|
metadata: {
|
|
|
|
|
reason: 'invalid_secret',
|
|
|
|
|
providedLen: providedSecret.length,
|
|
|
|
|
},
|
|
|
|
|
ipAddress: callerIp,
|
|
|
|
|
userAgent: req.headers.get('user-agent') ?? '',
|
|
|
|
|
severity: 'warning',
|
|
|
|
|
source: 'webhook',
|
|
|
|
|
});
|
|
|
|
|
}
|
fix(audit-wave-11): dossier sweep — error-ux + webhook + storage + search + maintainability
Final pass over the unaddressed AUDIT-2026-05-12 dossiers, taking the
tractable Critical/High items from each:
error-ux-auditor (5 items)
- C2: 17 toast.error(err.message) sites swept to toastError(err, …) so
every user-visible failure carries a copy-paste Reference ID
- C3: apiFetch synthesizes a client-side correlation id when a 5xx
comes back with a non-JSON body (reverse-proxy HTML pages); message
becomes "The server is unreachable. Please try again." with code
UPSTREAM_UNREACHABLE
- C4: checkRateLimit fails OPEN when Redis is unavailable so an outage
no longer 500s login + portal sign-in; logged at warn so monitoring
catches it
- H2: StorageTimeoutError (name='TimeoutError') replaces the plain
Error throw in s3.ts withTimeout — error-classifier hints fire now
- H5: errorResponse() adopted across /api/storage/[token],
/api/public/website-inquiries, and the Documenso webhook body (drops
the "Invalid secret" reconnaissance string)
outbound-webhook-auditor (5 items)
- C1: signature is now HMAC(secret, `${ts}.${body}`) with the
timestamp surfaced as X-Webhook-Timestamp so receivers can reject
replays outside a freshness window
- C3: dead-letter with reason missing_signing_secret when secret is
null (defence-in-depth against DB tampering / future migration
mistakes)
- H2: webhooks queue bumped to maxAttempts=8 with 30 s base
exponential backoff so a 30 s receiver blip during a deploy no
longer dead-letters every in-flight event; per-queue
backoffDelayMs added to QUEUE_CONFIGS
- M1: SSRF denylist gains Oracle Cloud metadata 192.0.0.192
- M2: dispatch-time https:// assertion before fetch, so a bad DB edit
can't slip plaintext through
storage-pathing-auditor (2 items)
- H1: berth-PDF presigned-upload keys now `${portSlug}/berths/…/…`
with portSlug threaded into backend.presignUpload — engages the
filesystem-proxy port-binding `p` token verifier
- H2: presignDownloadUrl auto-derives portSlug from the key's first
segment when callers don't pass it, so all 8 download sites engage
the `p`-token guard without per-site plumbing
search-auditor (1 item)
- H3: removed dead void wantEmail; void wantPhone; pair plus the
unused looksLikeEmail helper — the bucket-reorder it was scaffolded
for was never wired
maintainability-auditor (1 item)
- M2: swept seven abandoned `void <symbol>` markers and their dead
imports across clients/bulk, interests/bulk, admin/email-templates,
admin/website-submissions, alert-rules, and notes.service
Deferred to future work (substantial refactors, schema migrations, or
multi-file UI work):
- error-ux M3-M8 (global-error.tsx, per-route loading.tsx coverage,
ErrorBanner component, /api/ready route, worker DLQ admin surface)
- maintainability C1-C4 (documents/search/notes service splits,
interest-tabs split — multi-hour refactors)
- currency C1-H5 (mixed-currency dashboard aggregation, FX history
table, rounding policy) — wait for second non-USD port
- outbound-webhook C2 (deliveries reaper job), H1 (DNS-rebind TOCTOU
with undici Agent), H3 (circuit-breaker), H5 (presigned-post-policy)
- storage-pathing C2 (orphan reaper), H3-H5 (streaming + content-type
binding)
Tests: 1315/1315 vitest ✅ ; tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:27:32 +02:00
|
|
|
// Always return 200 (webhook best-practice — don't leak signal). Body
|
|
|
|
|
// is intentionally empty/uniform — error-ux-auditor H5 noted the
|
|
|
|
|
// literal "Invalid secret" string confirms the endpoint expects a
|
|
|
|
|
// secret, which is a free reconnaissance hint for enumeration.
|
|
|
|
|
return NextResponse.json({ ok: false }, { status: 200 });
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Compute deduplication hash
|
2026-04-27 13:46:48 +02:00
|
|
|
const signatureHash = createHash('sha256').update(rawBody).digest('hex');
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
2026-04-27 13:46:48 +02:00
|
|
|
let parsed: DocumensoWebhookBody;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
try {
|
2026-04-27 13:46:48 +02:00
|
|
|
parsed = JSON.parse(rawBody) as DocumensoWebhookBody;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
} catch {
|
|
|
|
|
logger.warn('Failed to parse Documenso webhook payload');
|
|
|
|
|
return NextResponse.json({ ok: false }, { status: 200 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 13:46:48 +02:00
|
|
|
// Replay guard: if any event with this hash already exists, skip.
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
try {
|
|
|
|
|
const existing = await db.query.documentEvents.findFirst({
|
|
|
|
|
where: (de, { eq }) => eq(de.signatureHash, signatureHash),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
2026-05-04 22:57:01 +02:00
|
|
|
logger.info({ signatureHash }, 'Duplicate Documenso webhook - skipping');
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
return NextResponse.json({ ok: true }, { status: 200 });
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error({ err }, 'Failed to check duplicate webhook');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 13:46:48 +02:00
|
|
|
const event = canonicalizeEvent(parsed.event);
|
|
|
|
|
const documensoId = String(parsed.payload?.id ?? '');
|
|
|
|
|
const recipients = parsed.payload?.recipients ?? [];
|
|
|
|
|
|
|
|
|
|
if (!documensoId) {
|
|
|
|
|
logger.warn({ event }, 'Documenso webhook missing payload.id');
|
|
|
|
|
return NextResponse.json({ ok: true }, { status: 200 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 21:19:39 +02:00
|
|
|
// Every handler accepts an optional `portId` and refuses to mutate when
|
|
|
|
|
// the lookup is ambiguous across multiple ports without one. Forward
|
|
|
|
|
// the secret-resolved portId everywhere — not just the expired path —
|
|
|
|
|
// so signed/completed/opened/rejected/cancelled events can't flip a
|
|
|
|
|
// foreign-tenant document via documensoId reuse.
|
|
|
|
|
const portScope = matchedPortId ? { portId: matchedPortId } : {};
|
|
|
|
|
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
try {
|
2026-05-12 18:33:10 +02:00
|
|
|
if (!isKnownEvent(event)) {
|
|
|
|
|
// New / unknown Documenso event — structured log catches the
|
|
|
|
|
// shape so we can add a handler before the next webhook lands.
|
|
|
|
|
logger.info({ event }, 'Unhandled Documenso webhook event type');
|
|
|
|
|
} else {
|
|
|
|
|
await match(event)
|
|
|
|
|
.with('DOCUMENT_SIGNED', 'DOCUMENT_RECIPIENT_COMPLETED', 'RECIPIENT_SIGNED', async (e) => {
|
|
|
|
|
// v1.13 fires DOCUMENT_SIGNED per recipient sign;
|
|
|
|
|
// 2.x fires DOCUMENT_RECIPIENT_COMPLETED for the same semantics.
|
|
|
|
|
// Some 2.x deployments emit RECIPIENT_SIGNED as a v2-flavoured alias
|
|
|
|
|
// — log when we see it (telemetry) and route to the same handler so
|
|
|
|
|
// v2 deployments don't silently drop per-recipient signs.
|
|
|
|
|
if (e === 'RECIPIENT_SIGNED') {
|
|
|
|
|
logger.info(
|
|
|
|
|
{ event: e, documensoId },
|
|
|
|
|
'Documenso v2 RECIPIENT_SIGNED received — routing to recipient-signed handler',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const signedRecipients = recipients.filter(
|
|
|
|
|
(r) => r.signingStatus === 'SIGNED' || Boolean(r.signedAt),
|
fix(integration): webhook v2 events, storage migrate, test theatre
- F1: DOCUMENT_DECLINED handler (v2 Decline vs Reject) — routes to same
handler as DOCUMENT_REJECTED until product refines downstream UX
- Add RECIPIENT_VIEWED / RECIPIENT_SIGNED v2-alias cases with telemetry
logging so we see when v2 deployments emit them
- D1: populate TABLES_WITH_STORAGE_KEYS (files, berth_pdf_versions,
brochure_versions, gdpr_exports) — was an empty list, migrated 0 files
- MinIO putObject/getObject/statObject/removeObject socket timeout wrapper
to prevent worker hangs on TCP blackhole (30s deadline)
- E1: convert test.skip on smoke-setup infra failure to throw new Error
so green-skipped silence becomes a real test failure (Playwright
doesn't expose vitest's expect.fail)
- Regression tests: folderId='' → null transform, applyEntityRestoredSuffix
no-op (never-archived), syncEntityFolderName collision loop past (2)
Note: matching .env.example documentation (D2 — bare DOCUMENSO_API_URL,
DOCUMENSO_API_VERSION, MINIO_AUTO_CREATE_BUCKET, DOCUMENSO_TEMPLATE_ID_EOI,
recipient role id vars) prepared but not committed — pre-commit hook
blocks .env*. Apply manually via the separate .env workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:02:26 +02:00
|
|
|
);
|
2026-05-12 18:33:10 +02:00
|
|
|
for (const r of signedRecipients) {
|
|
|
|
|
await handleRecipientSigned({
|
|
|
|
|
documentId: documensoId,
|
|
|
|
|
recipientEmail: r.email,
|
2026-05-13 13:47:33 +02:00
|
|
|
recipientToken: resolveRecipientToken(r),
|
2026-05-12 18:33:10 +02:00
|
|
|
signatureHash: `${signatureHash}:signed:${r.email}`,
|
|
|
|
|
...portScope,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.with('DOCUMENT_OPENED', 'RECIPIENT_VIEWED', async (e) => {
|
|
|
|
|
// Documenso v1 sends `readStatus: 'OPENED'`; v2 has used both
|
|
|
|
|
// upper and lower case across releases and may omit the field
|
|
|
|
|
// entirely (the event itself signals the open). Treat the event
|
|
|
|
|
// as the signal: dispatch a per-recipient open for every
|
|
|
|
|
// recipient on the document so v2 deployments stop silently
|
|
|
|
|
// dropping opens.
|
|
|
|
|
if (e === 'RECIPIENT_VIEWED') {
|
|
|
|
|
logger.info(
|
|
|
|
|
{ event: e, documensoId },
|
|
|
|
|
'Documenso v2 RECIPIENT_VIEWED received — routing to document-opened handler',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const openedRecipients = recipients.filter(
|
|
|
|
|
(r) => !r.readStatus || String(r.readStatus).toUpperCase() === 'OPENED',
|
|
|
|
|
);
|
|
|
|
|
for (const r of openedRecipients) {
|
|
|
|
|
await handleDocumentOpened({
|
|
|
|
|
documentId: documensoId,
|
|
|
|
|
recipientEmail: r.email,
|
2026-05-13 13:47:33 +02:00
|
|
|
recipientToken: resolveRecipientToken(r),
|
2026-05-12 18:33:10 +02:00
|
|
|
signatureHash: `${signatureHash}:opened:${r.email}`,
|
|
|
|
|
...portScope,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.with('DOCUMENT_COMPLETED', async () => {
|
|
|
|
|
await handleDocumentCompleted({ documentId: documensoId, ...portScope });
|
|
|
|
|
})
|
|
|
|
|
.with('DOCUMENT_REJECTED', 'DOCUMENT_DECLINED', async () => {
|
|
|
|
|
// v2 distinguishes Decline (recipient refuses to sign) from
|
|
|
|
|
// Reject (admin cancels). Both currently map to the same
|
|
|
|
|
// "rejected" terminal state in our domain.
|
|
|
|
|
const rejecting = recipients.find(
|
|
|
|
|
(r) => r.signingStatus === 'REJECTED' || r.signingStatus === 'DECLINED',
|
|
|
|
|
);
|
|
|
|
|
await handleDocumentRejected({
|
2026-04-27 13:46:48 +02:00
|
|
|
documentId: documensoId,
|
2026-05-12 18:33:10 +02:00
|
|
|
recipientEmail: rejecting?.email,
|
|
|
|
|
signatureHash,
|
2026-05-05 21:19:39 +02:00
|
|
|
...portScope,
|
2026-04-27 13:46:48 +02:00
|
|
|
});
|
2026-05-12 18:33:10 +02:00
|
|
|
})
|
|
|
|
|
.with('DOCUMENT_CANCELLED', async () => {
|
|
|
|
|
await handleDocumentCancelled({ documentId: documensoId, signatureHash, ...portScope });
|
|
|
|
|
})
|
|
|
|
|
.with('DOCUMENT_EXPIRED', async () => {
|
|
|
|
|
await handleDocumentExpired({ documentId: documensoId, ...portScope });
|
|
|
|
|
})
|
|
|
|
|
.with('DOCUMENT_REMINDER_SENT', async () => {
|
|
|
|
|
// Auto-reminder — informational only, no state change.
|
fix(integration): webhook v2 events, storage migrate, test theatre
- F1: DOCUMENT_DECLINED handler (v2 Decline vs Reject) — routes to same
handler as DOCUMENT_REJECTED until product refines downstream UX
- Add RECIPIENT_VIEWED / RECIPIENT_SIGNED v2-alias cases with telemetry
logging so we see when v2 deployments emit them
- D1: populate TABLES_WITH_STORAGE_KEYS (files, berth_pdf_versions,
brochure_versions, gdpr_exports) — was an empty list, migrated 0 files
- MinIO putObject/getObject/statObject/removeObject socket timeout wrapper
to prevent worker hangs on TCP blackhole (30s deadline)
- E1: convert test.skip on smoke-setup infra failure to throw new Error
so green-skipped silence becomes a real test failure (Playwright
doesn't expose vitest's expect.fail)
- Regression tests: folderId='' → null transform, applyEntityRestoredSuffix
no-op (never-archived), syncEntityFolderName collision loop past (2)
Note: matching .env.example documentation (D2 — bare DOCUMENSO_API_URL,
DOCUMENSO_API_VERSION, MINIO_AUTO_CREATE_BUCKET, DOCUMENSO_TEMPLATE_ID_EOI,
recipient role id vars) prepared but not committed — pre-commit hook
blocks .env*. Apply manually via the separate .env workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:02:26 +02:00
|
|
|
logger.info(
|
2026-05-12 18:33:10 +02:00
|
|
|
{
|
|
|
|
|
documensoId,
|
|
|
|
|
recipients: recipients.map((r) => r.email),
|
|
|
|
|
...portScope,
|
|
|
|
|
},
|
|
|
|
|
'Documenso auto-reminder sent',
|
fix(integration): webhook v2 events, storage migrate, test theatre
- F1: DOCUMENT_DECLINED handler (v2 Decline vs Reject) — routes to same
handler as DOCUMENT_REJECTED until product refines downstream UX
- Add RECIPIENT_VIEWED / RECIPIENT_SIGNED v2-alias cases with telemetry
logging so we see when v2 deployments emit them
- D1: populate TABLES_WITH_STORAGE_KEYS (files, berth_pdf_versions,
brochure_versions, gdpr_exports) — was an empty list, migrated 0 files
- MinIO putObject/getObject/statObject/removeObject socket timeout wrapper
to prevent worker hangs on TCP blackhole (30s deadline)
- E1: convert test.skip on smoke-setup infra failure to throw new Error
so green-skipped silence becomes a real test failure (Playwright
doesn't expose vitest's expect.fail)
- Regression tests: folderId='' → null transform, applyEntityRestoredSuffix
no-op (never-archived), syncEntityFolderName collision loop past (2)
Note: matching .env.example documentation (D2 — bare DOCUMENSO_API_URL,
DOCUMENSO_API_VERSION, MINIO_AUTO_CREATE_BUCKET, DOCUMENSO_TEMPLATE_ID_EOI,
recipient role id vars) prepared but not committed — pre-commit hook
blocks .env*. Apply manually via the separate .env workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:02:26 +02:00
|
|
|
);
|
2026-05-12 18:33:10 +02:00
|
|
|
})
|
|
|
|
|
.with('DOCUMENT_CREATED', 'DOCUMENT_SENT', async (e) => {
|
|
|
|
|
// We initiated these from our side; log for forward-compat /
|
|
|
|
|
// out-of-band-creation telemetry.
|
|
|
|
|
logger.info({ event: e, documensoId, ...portScope }, 'Documenso lifecycle event');
|
|
|
|
|
})
|
|
|
|
|
.exhaustive();
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
2026-04-27 13:46:48 +02:00
|
|
|
logger.error({ err, event }, 'Error processing Documenso webhook');
|
audit: Tier 1/3/4/5/7 batch — SSE, gates, dedup, URL escape, FK constraints
Tier 1.6: S3Backend.put now sets ServerSideEncryption=AES256 — closes
the cleartext-at-rest gap for signed contracts, GDPR exports, pg_dumps.
Tier 3.7: New safeUrl() helper in lib/email/shell.ts. Scheme allow-list
(http/https/mailto/tel/relative only — javascript:/data:/vbscript:/file:
rewritten to about:blank) + HTML-attribute escape. Retrofitted across
all 7 transactional templates (crm-invite, portal-auth, document-signing,
notification-digest, residential-inquiry, admin-email-change).
Tier 4.2: /api/v1/alerts GET now gated on admin.view_audit_log.
Tier 4.3: Documenso webhook handler emits captureErrorEvent on catch.
Admin/errors no longer silent on webhook crashes.
Tier 4.6: Inquiry-funnel email dedup is now case-insensitive
(LOWER(value)) and stores normalized email on insert. Capital-letter
resubmissions no longer spawn duplicate client+yacht+interest rows.
Tier 5.6 + data-model H1: migration 0056 adds FK
user_permission_overrides.user_id → user(id) cascade, same for
user_port_roles.userId, plus partial unique index on
user_email_changes pending rows.
Tier 7.6: @types/node bumped from ^25 to ^20.19.0 — matches the runtime.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:09:14 +02:00
|
|
|
// The audit caught that webhook handlers were the only API surface
|
|
|
|
|
// bypassing the platform-error pipeline — admin/errors was silent on
|
|
|
|
|
// Documenso webhook crashes. Pipe them in so they surface alongside
|
|
|
|
|
// every other 5xx.
|
|
|
|
|
void captureErrorEvent({
|
|
|
|
|
statusCode: 500,
|
|
|
|
|
error: err,
|
|
|
|
|
metadata: { source: 'webhook', provider: 'documenso', event },
|
|
|
|
|
});
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return NextResponse.json({ ok: true }, { status: 200 });
|
|
|
|
|
}
|
2026-05-13 11:50:07 +02:00
|
|
|
|
|
|
|
|
// Wrap with withPublicContext so the handler runs inside a
|
|
|
|
|
// runWithRequestContext ALS frame — without it the inline
|
|
|
|
|
// `captureErrorEvent` call in the catch block silently no-ops because
|
|
|
|
|
// getRequestContext() returns null for unauthenticated routes.
|
|
|
|
|
export const POST = withPublicContext(handleDocumensoWebhook);
|