diff --git a/next.config.ts b/next.config.ts index 3a76e8bd..555075c3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,17 @@ import type { NextConfig } from 'next'; +import bundleAnalyzer from '@next/bundle-analyzer'; const isProd = process.env.NODE_ENV === 'production'; +// Wrap the config with the bundle analyzer. Run `ANALYZE=true pnpm build` +// to get treemaps of the client + server bundles after the build +// completes. Pairs with the recharts dynamic-import work the audit +// flagged — gives us the tool to verify chart bundles only ship on the +// dashboard surface and not on routes that don't render them. +const withBundleAnalyzer = bundleAnalyzer({ + enabled: process.env.ANALYZE === 'true', +}); + /** * Security headers applied to every response. Per audit-pass-#3 finding: * the previous config emitted no CSP, X-Frame-Options, HSTS, or @@ -107,4 +117,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withBundleAnalyzer(nextConfig); diff --git a/package.json b/package.json index 37c8ca26..768f3145 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", "tesseract.js": "^7.0.0", + "ts-pattern": "^5.9.0", "vaul": "^1.1.2", "web-vitals": "^5.2.0", "zod": "^4.4.3", @@ -107,6 +108,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.5", "@hookform/devtools": "^4.4.0", + "@next/bundle-analyzer": "^16.2.6", "@playwright/test": "^1.60.0", "@total-typescript/ts-reset": "^0.6.1", "@types/archiver": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1f3d672..b2122dfe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: tesseract.js: specifier: ^7.0.0 version: 7.0.0 + ts-pattern: + specifier: ^5.9.0 + version: 5.9.0 vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -245,6 +248,9 @@ importers: '@hookform/devtools': specifier: ^4.4.0 version: 4.4.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@next/bundle-analyzer': + specifier: ^16.2.6 + version: 16.2.6 '@playwright/test': specifier: ^1.60.0 version: 1.60.0 @@ -468,6 +474,10 @@ packages: '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@discoveryjs/json-ext@0.5.7': + resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} + engines: {node: '>=10.0.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1019,6 +1029,9 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@next/bundle-analyzer@16.2.6': + resolution: {integrity: sha512-amPkVtHCTJAdBwyhhl5+qztHk24O4JlASgrWqh15AmnYi74apfZR46NGC0u4pM6BMAU1mYld4WdzD3cRBP3dOA==} + '@next/env@15.5.18': resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} @@ -1157,6 +1170,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2221,6 +2237,10 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -2676,6 +2696,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -2794,6 +2818,9 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2999,6 +3026,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -3413,6 +3443,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -3616,6 +3650,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -4035,6 +4073,10 @@ packages: socks: optional: true + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4210,6 +4252,10 @@ packages: resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==} hasBin: true + opener@1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -4754,6 +4800,10 @@ packages: signature_pad@5.1.3: resolution: {integrity: sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==} + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -5032,6 +5082,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -5048,6 +5102,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-pattern@5.9.0: + resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -5271,6 +5328,11 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webpack-bundle-analyzer@4.10.1: + resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} + engines: {node: '>= 10.13.0'} + hasBin: true + whatwg-url@14.2.0: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} @@ -5332,6 +5394,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -5526,6 +5600,8 @@ snapshots: '@date-fns/tz@1.4.1': {} + '@discoveryjs/json-ext@0.5.7': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: react: 19.2.6 @@ -5998,6 +6074,13 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@next/bundle-analyzer@16.2.6': + dependencies: + webpack-bundle-analyzer: 4.10.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@next/env@15.5.18': {} '@next/eslint-plugin-next@15.5.18': @@ -6110,6 +6193,8 @@ snapshots: dependencies: playwright: 1.60.0 + '@polka/url@1.0.0-next.29': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -7175,6 +7260,10 @@ snapshots: dependencies: acorn: 8.16.0 + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + acorn@8.16.0: {} air-datepicker@3.6.0: {} @@ -7618,6 +7707,8 @@ snapshots: commander@4.1.1: {} + commander@7.2.0: {} + compress-commons@6.0.2: dependencies: crc-32: 1.2.2 @@ -7732,6 +7823,8 @@ snapshots: dateformat@4.6.3: {} + debounce@1.2.1: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -7832,6 +7925,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexer@0.1.2: {} + eastasianwidth@0.2.0: {} electron-to-chromium@1.5.352: {} @@ -8438,6 +8533,10 @@ snapshots: graceful-fs@4.2.11: {} + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -8643,6 +8742,8 @@ snapshots: is-number@7.0.0: {} + is-plain-object@5.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -9031,6 +9132,8 @@ snapshots: socks: 2.8.8 optional: true + mrmime@2.0.1: {} + ms@2.1.3: {} msgpackr-extract@3.0.3: @@ -9193,6 +9296,8 @@ snapshots: opencollective-postinstall@2.0.3: {} + opener@1.5.2: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -9812,6 +9917,12 @@ snapshots: signature_pad@5.1.3: {} + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sisteransi@1.0.5: {} slice-ansi@7.1.2: @@ -10168,6 +10279,8 @@ snapshots: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tr46@0.0.3: {} tr46@5.1.1: @@ -10181,6 +10294,8 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-pattern@5.9.0: {} + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -10409,6 +10524,25 @@ snapshots: webidl-conversions@7.0.0: optional: true + webpack-bundle-analyzer@4.10.1: + dependencies: + '@discoveryjs/json-ext': 0.5.7 + acorn: 8.16.0 + acorn-walk: 8.3.5 + commander: 7.2.0 + debounce: 1.2.1 + escape-string-regexp: 4.0.0 + gzip-size: 6.0.0 + html-escaper: 2.0.2 + is-plain-object: 5.0.0 + opener: 1.5.2 + picocolors: 1.1.1 + sirv: 2.0.4 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + whatwg-url@14.2.0: dependencies: tr46: 5.1.1 @@ -10503,6 +10637,8 @@ snapshots: wrappy@1.0.2: {} + ws@7.5.10: {} + ws@8.18.3: {} xml-naming@0.1.0: {} diff --git a/src/app/api/webhooks/documenso/route.ts b/src/app/api/webhooks/documenso/route.ts index 2d88a7e2..56a1a371 100644 --- a/src/app/api/webhooks/documenso/route.ts +++ b/src/app/api/webhooks/documenso/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { createHash } from 'crypto'; +import { match } from 'ts-pattern'; import { db } from '@/lib/db'; import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook'; @@ -27,6 +28,46 @@ function canonicalizeEvent(event: string): string { return event.toUpperCase().replace(/\./g, '_'); } +// 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 = new Set([ + '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); +} + type DocumensoRecipient = { email: string; signingStatus?: string; @@ -144,123 +185,101 @@ export async function POST(req: NextRequest): Promise { const portScope = matchedPortId ? { portId: matchedPortId } : {}; try { - switch (event) { - case 'DOCUMENT_SIGNED': - case 'DOCUMENT_RECIPIENT_COMPLETED': - case 'RECIPIENT_SIGNED': { - // 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 (event === 'RECIPIENT_SIGNED') { - logger.info( - { event, documensoId }, - 'Documenso v2 RECIPIENT_SIGNED received — routing to recipient-signed handler', + 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), ); - } - const signedRecipients = recipients.filter( - (r) => r.signingStatus === 'SIGNED' || Boolean(r.signedAt), - ); - for (const r of signedRecipients) { - await handleRecipientSigned({ + for (const r of signedRecipients) { + await handleRecipientSigned({ + documentId: documensoId, + recipientEmail: r.email, + 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, + 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({ documentId: documensoId, - recipientEmail: r.email, - signatureHash: `${signatureHash}:signed:${r.email}`, + recipientEmail: rejecting?.email, + signatureHash, ...portScope, }); - } - break; - } - - case 'DOCUMENT_OPENED': - case 'RECIPIENT_VIEWED': { - // 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. - // - // RECIPIENT_VIEWED is the v2-flavoured alias for the same semantics - // — log when we see it (telemetry) and route to the same handler. - if (event === 'RECIPIENT_VIEWED') { + }) + .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. logger.info( - { event, documensoId }, - 'Documenso v2 RECIPIENT_VIEWED received — routing to document-opened handler', + { + documensoId, + recipients: recipients.map((r) => r.email), + ...portScope, + }, + 'Documenso auto-reminder sent', ); - } - const openedRecipients = recipients.filter( - (r) => !r.readStatus || String(r.readStatus).toUpperCase() === 'OPENED', - ); - for (const r of openedRecipients) { - await handleDocumentOpened({ - documentId: documensoId, - recipientEmail: r.email, - signatureHash: `${signatureHash}:opened:${r.email}`, - ...portScope, - }); - } - break; - } - - case 'DOCUMENT_COMPLETED': - await handleDocumentCompleted({ documentId: documensoId, ...portScope }); - break; - - case 'DOCUMENT_REJECTED': - case 'DOCUMENT_DECLINED': { - // Documenso v2 distinguishes Decline (recipient refuses to sign) from - // Reject (admin cancels). Both currently map to the same "rejected" - // terminal state in our domain — `handleDocumentRejected` records who - // refused and freezes the workflow. Product may later refine - // downstream UX (different audit tags / notifications), but the - // storage shape is identical for now so they share a handler. - const rejecting = recipients.find( - (r) => r.signingStatus === 'REJECTED' || r.signingStatus === 'DECLINED', - ); - await handleDocumentRejected({ - documentId: documensoId, - recipientEmail: rejecting?.email, - signatureHash, - ...portScope, - }); - break; - } - - case 'DOCUMENT_CANCELLED': - await handleDocumentCancelled({ documentId: documensoId, signatureHash, ...portScope }); - break; - - case 'DOCUMENT_EXPIRED': - await handleDocumentExpired({ documentId: documensoId, ...portScope }); - break; - - case 'DOCUMENT_REMINDER_SENT': - // Documenso auto-reminded a recipient. We don't mutate state — the - // reminder is informational. Structured log line is enough for - // telemetry without polluting the audit_logs table on every - // auto-reminder Documenso sends across all ports. - logger.info( - { - documensoId, - recipients: recipients.map((r) => r.email), - ...portScope, - }, - 'Documenso auto-reminder sent', - ); - break; - - case 'DOCUMENT_CREATED': - case 'DOCUMENT_SENT': - // Created + sent are informational — we initiated these from our - // side so the state is already authoritative in our DB. Log for - // forward-compat / out-of-band-creation telemetry. - logger.info({ event, documensoId, ...portScope }, 'Documenso lifecycle event'); - break; - - default: - logger.info({ event }, 'Unhandled Documenso webhook event type'); + }) + .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(); } } catch (err) { logger.error({ err, event }, 'Error processing Documenso webhook');