feat(deps): @next/bundle-analyzer + ts-pattern exhaustive webhook
Two adoption candidates from the audit's section-35 package matrix:
1. @next/bundle-analyzer wraps next.config.ts. Run
`ANALYZE=true pnpm build` to get treemaps of client + server bundles.
Companion to the recharts dynamic-import work the audit flagged —
gives us the tool to verify the dashboard chart bundle only ships on
the dashboard surface, not routes that don't render charts. Dev-only
dependency, zero runtime impact.
2. ts-pattern replaces the 13-case event-type switch in the Documenso
webhook with `match(event).with(...).exhaustive()`. The 13 known
event types are codified as a `KnownDocumensoEvent` union with an
`isKnownEvent()` type guard so:
- Unknown events still get the informational catch-all log (so
Documenso 2.x adding a new event doesn't 500).
- The match itself is compile-time exhaustive — adding a new
event to KnownDocumensoEvent without handling it in the
match() fails the build.
This is the bug class the multi-agent audit flagged ("webhook
silently drops new event types"). Same pattern can be rolled out
to the 19-case search dispatcher and the 12-case client-restore
service when those files are next touched.
Verified: tsc clean, vitest 1293/1293 (webhook tests green).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
136
pnpm-lock.yaml
generated
136
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
type DocumensoRecipient = {
|
||||
email: string;
|
||||
signingStatus?: string;
|
||||
@@ -144,123 +185,101 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user