Compare commits

..

6 Commits

87 changed files with 12060 additions and 218 deletions
+1033 -7
View File
File diff suppressed because it is too large Load Diff
+31 -14
View File
@@ -124,28 +124,44 @@ Admin-specific UI permissions that control visibility and data loading for admin
| `procurement.catalog.inventory.refresh` | Refresh on-hand inventory for a catalog item from ConnectWise | [src/api/procurement/[id]/refreshInventory.ts](src/api/procurement/[id]/refreshInventory.ts) | `procurement.catalog.fetch` |
| `procurement.catalog.link` | Link or unlink catalog items to each other | [src/api/procurement/[id]/link.ts](src/api/procurement/[id]/link.ts), [src/api/procurement/[id]/unlink.ts](src/api/procurement/[id]/unlink.ts) | `procurement.catalog.fetch` |
### ConnectWise Callback Routes
### ConnectWise Routes
`GET /v1/cw/members` requires only authentication (any logged-in user) and does **not** require a specific permission node.
`POST /v1/cw/callback/:secret/:resource` is intentionally unauthenticated for inbound ConnectWise callbacks and does **not** require a permission node.
| Permission Node | Description | Used In | Dependencies |
| --------------- | ------------------------------------------------------------------------------- | ------------------------------------------------ | ------------ |
| _None_ | Inbound callback route; secured operationally (network controls / source trust) | [src/api/cw/callback.ts](src/api/cw/callback.ts) | N/A |
| Permission Node | Description | Used In | Dependencies |
| --------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------ |
| _None_ | Fetch CW members (auth only) | [src/api/cw/fetchMembers.ts](src/api/cw/fetchMembers.ts) | N/A |
| _None_ | Inbound callback route; secured operationally (network controls / source trust) | [src/api/cw/callback.ts](src/api/cw/callback.ts) | N/A |
### Sales Permissions
Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (products, notes, contacts) are fetched live from CW.
| Permission Node | Description | Used In | Dependencies |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/products.ts](src/api/sales/[id]/products.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/fetchNote.ts](src/api/sales/[id]/fetchNote.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | |
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.ts) | |
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/[id]/createNote.ts](src/api/sales/[id]/createNote.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/[id]/updateNote.ts](src/api/sales/[id]/updateNote.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<field>` permissions. | [src/api/sales/[id]/addProduct.ts](src/api/sales/[id]/addProduct.ts) | `sales.opportunity.fetch` |
**WebSocket note:** The `/secure` socket event chain `opp:live_quote_preview` and `opp:live_quote_preview:<id>:data` is gated by `sales.opportunity.fetch`.
| Permission Node | Description | Used In | Dependencies |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/opportunities/[id]/fetch.ts](src/api/sales/opportunities/[id]/fetch.ts), [src/api/sales/opportunities/[id]/products/fetchAll.ts](src/api/sales/opportunities/[id]/products/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetchAll.ts](src/api/sales/opportunities/[id]/notes/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetch.ts](src/api/sales/opportunities/[id]/notes/fetch.ts), [src/api/sales/opportunities/[id]/contacts/fetchAll.ts](src/api/sales/opportunities/[id]/contacts/fetchAll.ts), [src/api/sockets/events/liveQuotePreview.ts](src/api/sockets/events/liveQuotePreview.ts) | |
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/opportunities/fetchAll.ts](src/api/sales/opportunities/fetchAll.ts), [src/api/sales/opportunities/count.ts](src/api/sales/opportunities/count.ts), [src/api/sales/opportunities/fetchTypes.ts](src/api/sales/opportunities/fetchTypes.ts) | |
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/opportunities/[id]/refresh.ts](src/api/sales/opportunities/[id]/refresh.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.update` | Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise | [src/api/sales/opportunities/[id]/update.ts](src/api/sales/opportunities/[id]/update.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.create` | Create a new opportunity in ConnectWise | [src/api/sales/opportunities/create.ts](src/api/sales/opportunities/create.ts) | |
| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/opportunities/[id]/notes/create.ts](src/api/sales/opportunities/[id]/notes/create.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/opportunities/[id]/notes/update.ts](src/api/sales/opportunities/[id]/notes/update.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/opportunities/[id]/notes/delete.ts](src/api/sales/opportunities/[id]/notes/delete.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/opportunities/[id]/products/resequence.ts](src/api/sales/opportunities/[id]/products/resequence.ts), [src/api/sales/opportunities/[id]/products/update.ts](src/api/sales/opportunities/[id]/products/update.ts), [src/api/sales/opportunities/[id]/products/cancel.ts](src/api/sales/opportunities/[id]/products/cancel.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<field>` permissions. | [src/api/sales/opportunities/[id]/products/add.ts](src/api/sales/opportunities/[id]/products/add.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add.specialOrder` | Add one or more "SPECIAL ORDER" products via the dedicated special-order route. | [src/api/sales/opportunities/[id]/products/addSpecialOrder.ts](src/api/sales/opportunities/[id]/products/addSpecialOrder.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add.labor` | Add labor products via the dedicated labor route with Field/Tech catalog selection and labor pricing inputs. | [src/api/sales/opportunities/[id]/products/addLabor.ts](src/api/sales/opportunities/[id]/products/addLabor.ts), [src/api/sales/opportunities/[id]/products/laborOptions.ts](src/api/sales/opportunities/[id]/products/laborOptions.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.fetch` | Fetch all committed quotes for an opportunity. | [src/api/sales/opportunities/[id]/quotes/fetchAll.ts](src/api/sales/opportunities/[id]/quotes/fetchAll.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.commit` | Generate and store a finalized quote PDF for an opportunity with regeneration metadata and creator attribution. | [src/api/sales/opportunities/[id]/quotes/commit.ts](src/api/sales/opportunities/[id]/quotes/commit.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.preview` | Generate a preview-stamped quote PDF for an opportunity without storing it. | [src/api/sales/opportunities/[id]/quotes/preview.ts](src/api/sales/opportunities/[id]/quotes/preview.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.download` | Download a committed quote PDF. Each download is recorded with timestamp and user info. | [src/api/sales/opportunities/[id]/quotes/download.ts](src/api/sales/opportunities/[id]/quotes/download.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.fetch_downloads` | Fetch download/print history for all quotes on an opportunity. Admin-level permission. | [src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts](src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.view_margin` | View margin and markup data on opportunity products. Controls visibility of margin %, markup %, and related progress bars in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` |
| `sales.opportunity.view_cost` | View cost data on opportunity products. Controls visibility of unit cost, total cost, and recurring cost in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` |
<details>
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
@@ -347,6 +363,7 @@ All fetch and fetchAll routes gate response object keys using `processObjectValu
| `obj.opportunity.site` | View site |
| `obj.opportunity.customerPO` | View customer PO |
| `obj.opportunity.totalSalesTax` | View total sales tax |
| `obj.opportunity.probability` | View probability percentage |
| `obj.opportunity.location` | View location |
| `obj.opportunity.department` | View department |
| `obj.opportunity.expectedCloseDate` | View expected close date |
+58
View File
@@ -19,6 +19,8 @@
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"keypair": "^1.0.4",
"pdf-lib": "^1.17.1",
"pdfmake": "^0.3.5",
"prisma": "^7.3.0",
"socket.io": "^4.8.3",
"zod": "^4.3.6",
@@ -62,6 +64,10 @@
"@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="],
"@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="],
"@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="],
"@prisma/adapter-pg": ["@prisma/adapter-pg@7.3.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.3.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-iuYQMbIPO6i9O45Fv8TB7vWu00BXhCaNAShenqF7gLExGDbnGp5BfFB4yz1K59zQ59jF6tQ9YHrg0P6/J3OoLg=="],
"@prisma/client": ["@prisma/client@7.3.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.3.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-FXBIxirqQfdC6b6HnNgxGmU7ydCPEPk7maHMOduJJfnTP+MuOGa15X4omjR/zpPUUpm8ef/mEFQjJudOGkXFcQ=="],
@@ -96,6 +102,8 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
@@ -116,10 +124,14 @@
"axios": ["axios@1.13.3", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g=="],
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
"blakets": ["blakets@0.1.12", "", { "dependencies": { "@prokopschield/argv": "^0.1.0-2" }, "bin": { "blake": "lib/demo.js", "blake2b": "lib/cli.js", "blake2s": "lib/cli.js", "blakejs": "lib/demo.js", "blakets": "lib/demo.js" } }, "sha512-ReOnLTDRlbExlTXbJZoA2xkvhzauJ7ldpvhKnb1cUNw8gdAHWHWOWG8XMjwpxQmmEZCDAR7VZiM5BYTUSOLVrw=="],
"brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
@@ -134,6 +146,8 @@
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
@@ -148,6 +162,8 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"cuid": ["cuid@3.0.0", "", {}, "sha512-WZYYkHdIDnaxdeP8Misq3Lah5vFjJwGuItJuV+tvMafosMzw0nF297T7mrm8IOWiPJkV6gc7sa8pzx27+w25Zg=="],
@@ -164,6 +180,8 @@
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -190,8 +208,12 @@
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
@@ -236,6 +258,8 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jpeg-exif": ["jpeg-exif@1.1.4", "", {}, "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ=="],
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
@@ -246,6 +270,8 @@
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
@@ -292,10 +318,18 @@
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="],
"pdfkit": ["pdfkit@0.17.2", "", { "dependencies": { "crypto-js": "^4.2.0", "fontkit": "^2.0.4", "jpeg-exif": "^1.1.4", "linebreak": "^1.1.0", "png-js": "^1.0.0" } }, "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw=="],
"pdfmake": ["pdfmake@0.3.5", "", { "dependencies": { "linebreak": "^1.1.0", "pdfkit": "^0.17.2", "xmldoc": "^2.0.3" } }, "sha512-DR7jRrK4lk7UiRT6pi+NeWhW1ToTsL2Y8CH+bFKNYz3M7agIVgeCtwARveEORhCAqoG3AUDrN318xU/lkOr1Bg=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"pg": ["pg@8.17.2", "", { "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw=="],
@@ -316,6 +350,8 @@
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"png-js": ["png-js@1.0.0", "", {}, "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="],
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
"postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
@@ -350,12 +386,16 @@
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="],
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -382,12 +422,20 @@
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="],
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
@@ -398,6 +446,8 @@
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"xmldoc": ["xmldoc@2.0.3", "", { "dependencies": { "sax": "^1.4.3" } }, "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="],
@@ -414,10 +464,18 @@
"@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="],
"@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="],
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
}
}
+900
View File
@@ -0,0 +1,900 @@
#!/usr/bin/env python3
"""
Generate a print-friendly PDF report from the latest test-webserver log file.
Usage:
python3 generate_log_report.py [optional_log_file_path]
If no path is given, the script finds the latest test-webserver-*.jsonl
file in ../cw-api-logs/.
"""
import json
import os
import sys
import glob
from datetime import datetime, timezone
from collections import Counter, defaultdict
from reportlab.lib import colors
from reportlab.lib.pagesizes import LETTER
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.platypus import (
SimpleDocTemplate,
Paragraph,
Spacer,
Table,
TableStyle,
PageBreak,
HRFlowable,
KeepTogether,
)
from reportlab.graphics.shapes import Drawing, String
from reportlab.graphics.charts.piecharts import Pie
from reportlab.graphics.charts.barcharts import VerticalBarChart
# ─── Print-friendly color palette ─────────────────────────────────────────────
# Minimal ink: white backgrounds, thin borders, dark text, subtle accents
HEADER_BG = colors.HexColor("#2c3e50") # Dark header (used sparingly)
ACCENT = colors.HexColor("#2980b9") # Muted blue
ACCENT_2 = colors.HexColor("#27ae60") # Muted green
ACCENT_3 = colors.HexColor("#8e44ad") # Muted purple
WHITE = colors.white
GRAY_50 = colors.HexColor("#fafafa")
GRAY_100 = colors.HexColor("#f5f5f5")
GRAY_200 = colors.HexColor("#e0e0e0")
GRAY_400 = colors.HexColor("#bdbdbd")
GRAY_600 = colors.HexColor("#757575")
GRAY_800 = colors.HexColor("#424242")
GRAY_900 = colors.HexColor("#212121")
# Pie/chart fills — muted, distinguishable in B&W too
PIE_COLORS = [
colors.HexColor("#5b9bd5"), # steel blue
colors.HexColor("#ed7d31"), # soft orange
colors.HexColor("#a5a5a5"), # gray
colors.HexColor("#ffc000"), # amber
colors.HexColor("#70ad47"), # olive green
colors.HexColor("#4472c4"), # darker blue
colors.HexColor("#c55a11"), # brown
colors.HexColor("#7030a0"), # purple
]
# ─── Helpers ──────────────────────────────────────────────────────────────────
def find_latest_log(base_dir):
pattern = os.path.join(base_dir, "test-webserver-*.jsonl")
files = sorted(glob.glob(pattern))
if not files:
raise FileNotFoundError(f"No test-webserver log files found in {base_dir}")
return files[-1]
def parse_log(path):
entries = []
with open(path, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
return entries
def fmt_ts(iso_str):
try:
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
except Exception:
return str(iso_str)
def duration_str(seconds):
if seconds < 60:
return f"{seconds:.1f}s"
minutes = seconds / 60
if minutes < 60:
return f"{minutes:.1f}m"
hours = minutes / 60
return f"{hours:.1f}h"
def truncate(s, max_len=50):
s = str(s)
return s if len(s) <= max_len else s[: max_len - 3] + "..."
def resolve_actor(entry):
"""
Derive the actor exactly like testWebserver.ts does:
entityUpdatedBy ?? query.params.memberId ?? summary.memberId ?? "-"
The summary is already stored in request.summary in the log.
"""
req = entry.get("request", {})
summary = req.get("summary") or {}
query = req.get("query") or {}
params = query.get("params") or {}
return str(
summary.get("entityUpdatedBy")
or params.get("memberId")
or summary.get("memberId")
or "-"
)
# ─── Analysis ─────────────────────────────────────────────────────────────────
def analyze(entries):
stats = {}
timestamps = []
for e in entries:
ts = e.get("timestamp")
if ts:
try:
timestamps.append(datetime.fromisoformat(ts.replace("Z", "+00:00")))
except Exception:
pass
timestamps.sort()
stats["total_entries"] = len(entries)
stats["first_ts"] = timestamps[0] if timestamps else None
stats["last_ts"] = timestamps[-1] if timestamps else None
stats["duration_seconds"] = (
(timestamps[-1] - timestamps[0]).total_seconds() if len(timestamps) >= 2 else 0
)
# Global counters
methods = Counter()
paths = Counter()
statuses = Counter()
actions = Counter()
types = Counter()
actors = Counter()
companies = Counter()
entity_ids = set()
stages = Counter()
ratings = Counter()
hourly_buckets = Counter()
minute_buckets = Counter()
for e in entries:
req = e.get("request", {})
resp = e.get("response", {})
bp = req.get("bodyParsed") or {}
entity = req.get("entityParsed") or bp.get("Entity") or {}
methods[req.get("method", "?")] += 1
raw_path = req.get("path", "?").split("?")[0]
paths[raw_path] += 1
statuses[resp.get("status", "?")] += 1
actions[bp.get("Action", "?")] += 1
types[bp.get("Type", "?")] += 1
actor = resolve_actor(e)
actors[actor] += 1
if isinstance(entity, dict):
cn = entity.get("CompanyName")
if cn:
companies[cn] += 1
eid = entity.get("Id")
if eid is not None:
entity_ids.add(eid)
stage = entity.get("StageName")
if stage:
stages[stage] += 1
rating = entity.get("Rating")
if rating:
ratings[rating] += 1
ts_str = e.get("timestamp")
if ts_str:
try:
dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
hourly_buckets[dt.strftime("%H:00")] += 1
minute_buckets[dt.strftime("%H:%M")] += 1
except Exception:
pass
# ── Per-actor deep stats ──
actor_details = defaultdict(lambda: {
"count": 0,
"actions": Counter(),
"types": Counter(),
"companies": Counter(),
"entity_ids": set(),
"stages": Counter(),
"ratings": Counter(),
"timestamps": [],
"statuses": Counter(),
"paths": Counter(),
"member_ids": Counter(),
"sales_reps": Counter(),
"total_estimated": 0.0,
})
for e in entries:
req = e.get("request", {})
resp = e.get("response", {})
bp = req.get("bodyParsed") or {}
entity = req.get("entityParsed") or bp.get("Entity") or {}
summary = req.get("summary") or {}
actor = resolve_actor(e)
ad = actor_details[actor]
ad["count"] += 1
ad["actions"][bp.get("Action", "?")] += 1
ad["types"][bp.get("Type", "?")] += 1
ad["statuses"][resp.get("status", "?")] += 1
raw_path = req.get("path", "?").split("?")[0]
ad["paths"][raw_path] += 1
# Track which MemberIds triggered callbacks for this actor
mid = bp.get("MemberId")
if mid:
ad["member_ids"][mid] += 1
ts_str = e.get("timestamp")
if ts_str:
try:
ad["timestamps"].append(datetime.fromisoformat(ts_str.replace("Z", "+00:00")))
except Exception:
pass
if isinstance(entity, dict):
cn = entity.get("CompanyName")
if cn:
ad["companies"][cn] += 1
eid = entity.get("Id")
if eid is not None:
ad["entity_ids"].add(eid)
stage = entity.get("StageName")
if stage:
ad["stages"][stage] += 1
rating = entity.get("Rating")
if rating:
ad["ratings"][rating] += 1
et = entity.get("EstimatedTotal")
if et is not None:
ad["total_estimated"] += float(et)
pr = entity.get("PrimarySalesRep")
if pr:
ad["sales_reps"][pr] += 1
# Compute per-actor derived stats
for aid, ad in actor_details.items():
ad["timestamps"].sort()
if len(ad["timestamps"]) >= 2:
dur = (ad["timestamps"][-1] - ad["timestamps"][0]).total_seconds()
ad["duration_seconds"] = dur
ad["events_per_minute"] = ad["count"] / (dur / 60) if dur > 0 else ad["count"]
else:
ad["duration_seconds"] = 0
ad["events_per_minute"] = ad["count"]
ad["first_ts"] = ad["timestamps"][0] if ad["timestamps"] else None
ad["last_ts"] = ad["timestamps"][-1] if ad["timestamps"] else None
stats["actor_details"] = dict(actor_details)
stats["methods"] = methods
stats["paths"] = paths
stats["statuses"] = statuses
stats["actions"] = actions
stats["types"] = types
stats["actors"] = actors
stats["companies"] = companies
stats["entity_ids"] = entity_ids
stats["stages"] = stages
stats["ratings"] = ratings
stats["hourly_buckets"] = hourly_buckets
stats["minute_buckets"] = minute_buckets
if stats["duration_seconds"] > 0:
stats["events_per_minute"] = len(entries) / (stats["duration_seconds"] / 60)
else:
stats["events_per_minute"] = len(entries)
return stats
# ─── PDF building ─────────────────────────────────────────────────────────────
def build_styles():
ss = getSampleStyleSheet()
ss.add(ParagraphStyle(
"ReportTitle", parent=ss["Title"], fontSize=24, textColor=WHITE,
spaceAfter=4, fontName="Helvetica-Bold", alignment=TA_CENTER,
))
ss.add(ParagraphStyle(
"ReportSubtitle", parent=ss["Normal"], fontSize=11, textColor=GRAY_400,
spaceAfter=2, fontName="Helvetica", alignment=TA_CENTER,
))
ss.add(ParagraphStyle(
"SectionHeader", parent=ss["Heading1"], fontSize=16, textColor=GRAY_900,
spaceBefore=14, spaceAfter=6, fontName="Helvetica-Bold",
))
ss.add(ParagraphStyle(
"SubHeader", parent=ss["Heading2"], fontSize=12, textColor=GRAY_800,
spaceBefore=10, spaceAfter=4, fontName="Helvetica-Bold",
))
ss.add(ParagraphStyle(
"BodyText2", parent=ss["Normal"], fontSize=9, textColor=GRAY_800,
spaceAfter=3, fontName="Helvetica", leading=13,
))
ss.add(ParagraphStyle(
"SmallGray", parent=ss["Normal"], fontSize=8, textColor=GRAY_600,
spaceAfter=2, fontName="Helvetica",
))
ss.add(ParagraphStyle(
"KPIValue", parent=ss["Normal"], fontSize=20, textColor=GRAY_900,
fontName="Helvetica-Bold", alignment=TA_CENTER, leading=24,
))
ss.add(ParagraphStyle(
"KPILabel", parent=ss["Normal"], fontSize=8, textColor=GRAY_600,
fontName="Helvetica", alignment=TA_CENTER, spaceAfter=2,
))
ss.add(ParagraphStyle(
"BannerText", parent=ss["Normal"], fontSize=9, textColor=WHITE,
fontName="Helvetica-Bold", spaceAfter=1,
))
return ss
def make_header_banner(ss, log_path, stats):
elements = []
fname = os.path.basename(log_path)
banner_data = [[
Paragraph("Webhook Log Report", ss["ReportTitle"]),
], [
Paragraph(fname, ss["ReportSubtitle"]),
], [
Paragraph(
f"Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
ss["ReportSubtitle"],
),
]]
banner = Table(banner_data, colWidths=[7.0 * inch])
banner.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), HEADER_BG),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("TOPPADDING", (0, 0), (0, 0), 20),
("BOTTOMPADDING", (0, -1), (-1, -1), 16),
("LEFTPADDING", (0, 0), (-1, -1), 20),
("RIGHTPADDING", (0, 0), (-1, -1), 20),
]))
elements.append(banner)
elements.append(Spacer(1, 14))
return elements
def make_kpi_card(label, value):
ss = build_styles()
card_data = [[
Paragraph(str(value), ss["KPIValue"]),
], [
Paragraph(label, ss["KPILabel"]),
]]
card = Table(card_data, colWidths=[1.6 * inch], rowHeights=[28, 16])
card.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), GRAY_100),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("BOX", (0, 0), (-1, -1), 0.5, GRAY_200),
]))
return card
def make_kpi_row(cards_data):
cards = [make_kpi_card(label, value) for label, value in cards_data]
row = Table([cards], colWidths=[1.75 * inch] * len(cards))
row.setStyle(TableStyle([
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
return row
def make_table(title, counter, ss, max_rows=15):
"""Generic table from a Counter — light styling for print."""
elements = []
elements.append(Paragraph(title, ss["SubHeader"]))
items = counter.most_common(max_rows)
if not items:
elements.append(Paragraph("<i>No data</i>", ss["BodyText2"]))
return elements
total = sum(counter.values())
header = ["Item", "Count", "%"]
rows = [header]
for name, count in items:
pct = (count / total * 100) if total else 0
rows.append([truncate(str(name), 45), f"{count:,}", f"{pct:.1f}%"])
t = Table(rows, colWidths=[3.6 * inch, 1.0 * inch, 0.8 * inch])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), GRAY_800),
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 9),
("FONTSIZE", (0, 1), (-1, -1), 9),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("BOTTOMPADDING", (0, 0), (-1, 0), 6),
("TOPPADDING", (0, 0), (-1, 0), 6),
("GRID", (0, 0), (-1, -1), 0.4, GRAY_200),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, GRAY_50]),
("ALIGN", (1, 0), (2, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("TOPPADDING", (0, 1), (-1, -1), 3),
("BOTTOMPADDING", (0, 1), (-1, -1), 3),
]))
elements.append(t)
return elements
def make_pie_chart(title, counter, width=280, height=190):
items = counter.most_common(8)
if not items:
return Spacer(1, 1)
d = Drawing(width, height)
d.add(String(width / 2, height - 12, title,
fontSize=10, fontName="Helvetica-Bold",
fillColor=GRAY_900, textAnchor="middle"))
pie = Pie()
pie.x = 50
pie.y = 10
pie.width = 110
pie.height = 110
pie.data = [v for _, v in items]
pie.labels = [truncate(str(k), 18) for k, _ in items]
pie.sideLabels = True
pie.slices.strokeWidth = 0.5
pie.slices.strokeColor = WHITE
for i in range(len(items)):
pie.slices[i].fillColor = PIE_COLORS[i % len(PIE_COLORS)]
pie.slices[i].fontName = "Helvetica"
pie.slices[i].fontSize = 7
pie.slices[i].labelRadius = 1.35
d.add(pie)
return d
def make_timeline_chart(minute_buckets, width=500, height=150):
if not minute_buckets:
return Spacer(1, 1)
sorted_keys = sorted(minute_buckets.keys())
if len(sorted_keys) > 40:
step = max(1, len(sorted_keys) // 40)
sampled_keys = sorted_keys[::step]
else:
sampled_keys = sorted_keys
values = [minute_buckets[k] for k in sampled_keys]
d = Drawing(width, height)
d.add(String(width / 2, height - 10, "Event Timeline (by minute)",
fontSize=10, fontName="Helvetica-Bold",
fillColor=GRAY_900, textAnchor="middle"))
chart = VerticalBarChart()
chart.x = 50
chart.y = 25
chart.width = width - 80
chart.height = height - 50
chart.data = [values]
chart.categoryAxis.categoryNames = sampled_keys
chart.categoryAxis.labels.angle = 45
chart.categoryAxis.labels.fontSize = 6
chart.categoryAxis.labels.fontName = "Helvetica"
chart.categoryAxis.labels.dy = -5
chart.valueAxis.labels.fontSize = 7
chart.valueAxis.labels.fontName = "Helvetica"
chart.valueAxis.valueMin = 0
chart.bars[0].fillColor = GRAY_600
chart.bars[0].strokeColor = None
chart.barWidth = max(2, int((width - 100) / len(values) * 0.7))
d.add(chart)
return d
def build_actor_activity_section(stats, ss):
"""Per-actor deep-dive. Actor = entityUpdatedBy ?? query.memberId ?? summary.memberId."""
elements = []
elements.append(PageBreak())
elements.append(Paragraph("Actor Activity Deep Dive", ss["SectionHeader"]))
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=4))
elements.append(Paragraph(
'The <b>actor</b> is resolved as: <i>entityUpdatedBy → query.memberId → summary.memberId</i>. '
'This is the person or system that caused the change in ConnectWise.',
ss["SmallGray"],
))
elements.append(Spacer(1, 10))
actor_details = stats.get("actor_details", {})
if not actor_details:
elements.append(Paragraph("<i>No actor data available.</i>", ss["BodyText2"]))
return elements
sorted_actors = sorted(actor_details.items(), key=lambda x: -x[1]["count"])
# Actor distribution pie chart
actor_counter = Counter({aid: ad["count"] for aid, ad in sorted_actors})
elements.append(make_pie_chart("Events by Actor", actor_counter, width=350, height=210))
elements.append(Spacer(1, 10))
for idx, (aid, ad) in enumerate(sorted_actors):
# Actor header — slim dark bar
banner_data = [[
Paragraph(
f'<font size="12"><b>{aid}</b></font>'
f'&nbsp;&nbsp;&nbsp;'
f'<font size="9" color="#cccccc">{ad["count"]:,} events</font>',
ss["BannerText"],
),
]]
banner = Table(banner_data, colWidths=[7.0 * inch])
banner.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), HEADER_BG),
("TOPPADDING", (0, 0), (-1, -1), 7),
("BOTTOMPADDING", (0, 0), (-1, -1), 7),
("LEFTPADDING", (0, 0), (-1, -1), 12),
]))
elements.append(banner)
# KPI row
kpi = make_kpi_row([
("Events", f"{ad['count']:,}"),
("Entities", f"{len(ad['entity_ids']):,}"),
("Companies", f"{len(ad['companies']):,}"),
("Evts/Min", f"{ad['events_per_minute']:.1f}"),
])
elements.append(kpi)
elements.append(Spacer(1, 4))
# Info grid
first_str = ad["first_ts"].strftime("%Y-%m-%d %H:%M:%S") if ad["first_ts"] else ""
last_str = ad["last_ts"].strftime("%Y-%m-%d %H:%M:%S") if ad["last_ts"] else ""
dur = duration_str(ad["duration_seconds"])
est = ad["total_estimated"]
mid_str = ", ".join(f"{k} ({v})" for k, v in ad["member_ids"].most_common(5)) if ad["member_ids"] else ""
sr_str = ", ".join(f"{k} ({v})" for k, v in ad["sales_reps"].most_common(5)) if ad["sales_reps"] else ""
info_rows = [
[
Paragraph('<font color="#757575">First Event</font>', ss["BodyText2"]),
Paragraph(f'<b>{first_str}</b>', ss["BodyText2"]),
Paragraph('<font color="#757575">Last Event</font>', ss["BodyText2"]),
Paragraph(f'<b>{last_str}</b>', ss["BodyText2"]),
],
[
Paragraph('<font color="#757575">Active Duration</font>', ss["BodyText2"]),
Paragraph(f'<b>{dur}</b>', ss["BodyText2"]),
Paragraph('<font color="#757575">Total Est. Value</font>', ss["BodyText2"]),
Paragraph(f'<b>${est:,.2f}</b>', ss["BodyText2"]),
],
[
Paragraph('<font color="#757575">Callback Members</font>', ss["BodyText2"]),
Paragraph(f'{truncate(mid_str, 40)}', ss["BodyText2"]),
Paragraph('<font color="#757575">Sales Reps</font>', ss["BodyText2"]),
Paragraph(f'{truncate(sr_str, 40)}', ss["BodyText2"]),
],
]
info_table = Table(info_rows, colWidths=[1.3 * inch, 2.1 * inch, 1.3 * inch, 2.1 * inch])
info_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), GRAY_50),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.3, GRAY_200),
]))
elements.append(info_table)
elements.append(Spacer(1, 6))
# Breakdown tables
if ad["types"]:
elements.extend(make_table(f"{aid} — Entity Types", ad["types"], ss, max_rows=8))
elements.append(Spacer(1, 6))
if ad["companies"]:
elements.extend(make_table(f"{aid} — Companies", ad["companies"], ss, max_rows=10))
elements.append(Spacer(1, 6))
if ad["stages"]:
elements.extend(make_table(f"{aid} — Stages", ad["stages"], ss, max_rows=8))
elements.append(Spacer(1, 6))
if ad["ratings"]:
elements.extend(make_table(f"{aid} — Ratings", ad["ratings"], ss, max_rows=8))
elements.append(Spacer(1, 6))
# Entity IDs
if ad["entity_ids"]:
id_list = sorted(ad["entity_ids"])
id_str = ", ".join(str(i) for i in id_list[:30])
if len(id_list) > 30:
id_str += f" ... (+{len(id_list) - 30} more)"
elements.append(Paragraph(f"{aid} — Entity IDs Touched", ss["SubHeader"]))
elements.append(Paragraph(f'<font size="8">{id_str}</font>', ss["BodyText2"]))
elements.append(Spacer(1, 6))
# Divider
if idx < len(sorted_actors) - 1:
elements.append(Spacer(1, 4))
elements.append(HRFlowable(width="100%", thickness=0.5, color=GRAY_200, spaceAfter=4))
elements.append(Spacer(1, 4))
return elements
def build_summary_log_table(entries, ss, max_rows=30):
elements = []
elements.append(PageBreak())
elements.append(Paragraph("Event Summary", ss["SectionHeader"]))
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=6))
elements.append(Paragraph(
f"Aggregated view of {len(entries):,} webhook events — grouped by entity.",
ss["SmallGray"],
))
elements.append(Spacer(1, 8))
entity_groups = defaultdict(lambda: {
"count": 0, "name": "", "company": "",
"actions": Counter(), "actors": set(),
"est_total": None,
})
for e in entries:
req = e.get("request", {})
bp = req.get("bodyParsed") or {}
entity = req.get("entityParsed") or bp.get("Entity") or {}
if not isinstance(entity, dict):
continue
eid = entity.get("Id")
if eid is None:
continue
eg = entity_groups[eid]
eg["count"] += 1
eg["name"] = entity.get("OpportunityName") or entity.get("CompanyName") or eg["name"]
eg["company"] = entity.get("CompanyName") or eg["company"]
eg["actions"][bp.get("Action", "?")] += 1
actor = resolve_actor(e)
eg["actors"].add(actor)
et = entity.get("EstimatedTotal")
if et is not None:
eg["est_total"] = et
sorted_entities = sorted(entity_groups.items(), key=lambda x: -x[1]["count"])
header = ["ID", "Entity Name", "Company", "Events", "Actions", "Actors", "Est. Total"]
rows = [header]
for eid, eg in sorted_entities[:max_rows]:
actions_str = ", ".join(f"{a}({c})" for a, c in eg["actions"].most_common(3))
actors_str = ", ".join(sorted(eg["actors"]))
total_str = f"${eg['est_total']:,.2f}" if eg["est_total"] is not None else ""
rows.append([
str(eid),
truncate(eg["name"], 28),
truncate(eg["company"], 18),
f"{eg['count']:,}",
truncate(actions_str, 24),
truncate(actors_str, 18),
total_str,
])
t = Table(rows, colWidths=[0.45 * inch, 1.7 * inch, 1.1 * inch, 0.55 * inch, 1.2 * inch, 1.0 * inch, 0.8 * inch])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), GRAY_800),
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8),
("FONTSIZE", (0, 1), (-1, -1), 8),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("GRID", (0, 0), (-1, -1), 0.3, GRAY_200),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, GRAY_50]),
("ALIGN", (0, 0), (0, -1), "CENTER"),
("ALIGN", (3, 0), (3, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 5),
("RIGHTPADDING", (0, 0), (-1, -1), 5),
("TOPPADDING", (0, 1), (-1, -1), 2),
("BOTTOMPADDING", (0, 1), (-1, -1), 2),
]))
elements.append(t)
if len(sorted_entities) > max_rows:
elements.append(Spacer(1, 4))
elements.append(Paragraph(
f'Showing top {max_rows} of {len(sorted_entities)} entities.',
ss["SmallGray"],
))
return elements
def add_page_number(canvas, doc):
canvas.saveState()
canvas.setFillColor(GRAY_800)
canvas.rect(0, 0, LETTER[0], 22, fill=1, stroke=0)
canvas.setFillColor(WHITE)
canvas.setFont("Helvetica", 7)
canvas.drawCentredString(LETTER[0] / 2, 8, f"Page {doc.page}")
canvas.setFillColor(GRAY_400)
canvas.setFont("Helvetica", 7)
canvas.drawString(30, 8, "Optima API · Webhook Log Report")
canvas.drawRightString(LETTER[0] - 30, 8, datetime.now(timezone.utc).strftime("%Y-%m-%d"))
canvas.restoreState()
# ─── Main ─────────────────────────────────────────────────────────────────────
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
log_dir = os.path.join(script_dir, "..", "cw-api-logs")
if len(sys.argv) > 1:
log_path = sys.argv[1]
else:
log_path = find_latest_log(log_dir)
print(f"📄 Reading log: {log_path}")
entries = parse_log(log_path)
print(f"{len(entries)} entries parsed")
stats = analyze(entries)
ss = build_styles()
log_basename = os.path.splitext(os.path.basename(log_path))[0]
out_path = os.path.join(script_dir, "..", "cw-api-logs", f"{log_basename}-report.pdf")
doc = SimpleDocTemplate(
out_path,
pagesize=LETTER,
leftMargin=0.6 * inch,
rightMargin=0.6 * inch,
topMargin=0.5 * inch,
bottomMargin=0.5 * inch,
)
elements = []
# ── Title Banner ──
elements.extend(make_header_banner(ss, log_path, stats))
# ── Overview ──
elements.append(Paragraph("Overview", ss["SectionHeader"]))
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
elements.append(make_kpi_row([
("Total Events", f"{stats['total_entries']:,}"),
("Unique Entities", f"{len(stats['entity_ids']):,}"),
("Companies", f"{len(stats['companies']):,}"),
("Duration", duration_str(stats["duration_seconds"])),
]))
elements.append(Spacer(1, 8))
elements.append(make_kpi_row([
("Events / Min", f"{stats['events_per_minute']:.1f}"),
("HTTP Methods", f"{len(stats['methods']):,}"),
("Action Types", f"{len(stats['actions']):,}"),
("Actors", f"{len(stats['actors']):,}"),
]))
elements.append(Spacer(1, 12))
# Time range
if stats["first_ts"] and stats["last_ts"]:
info = [
[
Paragraph('<font color="#757575">First Event</font>', ss["BodyText2"]),
Paragraph(f'<b>{stats["first_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}</b>', ss["BodyText2"]),
Paragraph('<font color="#757575">Last Event</font>', ss["BodyText2"]),
Paragraph(f'<b>{stats["last_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}</b>', ss["BodyText2"]),
]
]
ti = Table(info, colWidths=[1.2 * inch, 2.4 * inch, 1.2 * inch, 2.4 * inch])
ti.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), GRAY_50),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("BOX", (0, 0), (-1, -1), 0.4, GRAY_200),
]))
elements.append(ti)
elements.append(Spacer(1, 14))
# ── Charts ──
elements.append(Paragraph("Visual Breakdown", ss["SectionHeader"]))
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
elements.append(make_timeline_chart(stats["minute_buckets"]))
elements.append(Spacer(1, 12))
pie_row = [[
make_pie_chart("By Action", stats["actions"]),
make_pie_chart("By Type", stats["types"]),
]]
pt = Table(pie_row, colWidths=[3.5 * inch, 3.5 * inch])
pt.setStyle(TableStyle([
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
elements.append(pt)
elements.append(Spacer(1, 6))
if len(stats["stages"]) > 1 or len(stats["ratings"]) > 1:
pie_row2 = [[
make_pie_chart("By Stage", stats["stages"]),
make_pie_chart("By Rating", stats["ratings"]),
]]
pt2 = Table(pie_row2, colWidths=[3.5 * inch, 3.5 * inch])
pt2.setStyle(TableStyle([
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
elements.append(pt2)
# Actor pie chart
elements.append(Spacer(1, 6))
elements.append(make_pie_chart("By Actor", stats["actors"], width=350, height=210))
# ── General Information ──
elements.append(PageBreak())
elements.append(Paragraph("General Information", ss["SectionHeader"]))
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
elements.extend(make_table("Response Status Codes", stats["statuses"], ss))
elements.append(Spacer(1, 10))
elements.extend(make_table("HTTP Methods", stats["methods"], ss))
elements.append(Spacer(1, 10))
elements.extend(make_table("Webhook Actions", stats["actions"], ss))
elements.append(Spacer(1, 10))
elements.extend(make_table("Entity Types", stats["types"], ss))
elements.append(Spacer(1, 10))
elements.extend(make_table("Request Paths", stats["paths"], ss))
elements.append(Spacer(1, 10))
elements.extend(make_table("Actors", stats["actors"], ss))
elements.append(Spacer(1, 10))
if stats["companies"]:
elements.extend(make_table("Companies", stats["companies"], ss))
elements.append(Spacer(1, 10))
if stats["stages"]:
elements.extend(make_table("Opportunity Stages", stats["stages"], ss))
elements.append(Spacer(1, 10))
if stats["ratings"]:
elements.extend(make_table("Opportunity Ratings", stats["ratings"], ss))
elements.append(Spacer(1, 10))
elements.extend(make_table("Hourly Distribution", stats["hourly_buckets"], ss))
# ── Actor Deep Dive ──
elements.extend(build_actor_activity_section(stats, ss))
# ── Entity Summary ──
elements.extend(build_summary_log_table(entries, ss, max_rows=30))
# Build
doc.build(elements, onFirstPage=add_page_number, onLaterPages=add_page_number)
print(f"✅ Report generated: {out_path}")
print(f" File size: {os.path.getsize(out_path) / 1024:.1f} KB")
if __name__ == "__main__":
main()
+10
View File
@@ -67,3 +67,13 @@ export type SecureValue = Prisma.SecureValueModel
*
*/
export type Credential = Prisma.CredentialModel
/**
* Model GeneratedQuotes
*
*/
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
/**
* Model CwMember
*
*/
export type CwMember = Prisma.CwMemberModel
+10
View File
@@ -89,3 +89,13 @@ export type SecureValue = Prisma.SecureValueModel
*
*/
export type Credential = Prisma.CredentialModel
/**
* Model GeneratedQuotes
*
*/
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
/**
* Model CwMember
*
*/
export type CwMember = Prisma.CwMemberModel
+34
View File
@@ -280,6 +280,23 @@ export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
_max?: Prisma.NestedJsonFilter<$PrismaModel>
}
export type BytesFilter<$PrismaModel = never> = {
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
not?: Prisma.NestedBytesFilter<$PrismaModel> | runtime.Bytes
}
export type BytesWithAggregatesFilter<$PrismaModel = never> = {
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
not?: Prisma.NestedBytesWithAggregatesFilter<$PrismaModel> | runtime.Bytes
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBytesFilter<$PrismaModel>
_max?: Prisma.NestedBytesFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
@@ -521,4 +538,21 @@ export type NestedJsonFilterBase<$PrismaModel = never> = {
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type NestedBytesFilter<$PrismaModel = never> = {
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
not?: Prisma.NestedBytesFilter<$PrismaModel> | runtime.Bytes
}
export type NestedBytesWithAggregatesFilter<$PrismaModel = never> = {
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
not?: Prisma.NestedBytesWithAggregatesFilter<$PrismaModel> | runtime.Bytes
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBytesFilter<$PrismaModel>
_max?: Prisma.NestedBytesFilter<$PrismaModel>
}
File diff suppressed because one or more lines are too long
+203 -2
View File
@@ -393,7 +393,9 @@ export const ModelName = {
Opportunity: 'Opportunity',
CredentialType: 'CredentialType',
SecureValue: 'SecureValue',
Credential: 'Credential'
Credential: 'Credential',
GeneratedQuotes: 'GeneratedQuotes',
CwMember: 'CwMember'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -409,7 +411,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential"
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential" | "generatedQuotes" | "cwMember"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -1153,6 +1155,154 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
GeneratedQuotes: {
payload: Prisma.$GeneratedQuotesPayload<ExtArgs>
fields: Prisma.GeneratedQuotesFieldRefs
operations: {
findUnique: {
args: Prisma.GeneratedQuotesFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload> | null
}
findUniqueOrThrow: {
args: Prisma.GeneratedQuotesFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
}
findFirst: {
args: Prisma.GeneratedQuotesFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload> | null
}
findFirstOrThrow: {
args: Prisma.GeneratedQuotesFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
}
findMany: {
args: Prisma.GeneratedQuotesFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>[]
}
create: {
args: Prisma.GeneratedQuotesCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
}
createMany: {
args: Prisma.GeneratedQuotesCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.GeneratedQuotesCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>[]
}
delete: {
args: Prisma.GeneratedQuotesDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
}
update: {
args: Prisma.GeneratedQuotesUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
}
deleteMany: {
args: Prisma.GeneratedQuotesDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.GeneratedQuotesUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.GeneratedQuotesUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>[]
}
upsert: {
args: Prisma.GeneratedQuotesUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
}
aggregate: {
args: Prisma.GeneratedQuotesAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateGeneratedQuotes>
}
groupBy: {
args: Prisma.GeneratedQuotesGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.GeneratedQuotesGroupByOutputType>[]
}
count: {
args: Prisma.GeneratedQuotesCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.GeneratedQuotesCountAggregateOutputType> | number
}
}
}
CwMember: {
payload: Prisma.$CwMemberPayload<ExtArgs>
fields: Prisma.CwMemberFieldRefs
operations: {
findUnique: {
args: Prisma.CwMemberFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload> | null
}
findUniqueOrThrow: {
args: Prisma.CwMemberFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
findFirst: {
args: Prisma.CwMemberFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload> | null
}
findFirstOrThrow: {
args: Prisma.CwMemberFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
findMany: {
args: Prisma.CwMemberFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
}
create: {
args: Prisma.CwMemberCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
createMany: {
args: Prisma.CwMemberCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.CwMemberCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
}
delete: {
args: Prisma.CwMemberDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
update: {
args: Prisma.CwMemberUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
deleteMany: {
args: Prisma.CwMemberDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CwMemberUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.CwMemberUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
}
upsert: {
args: Prisma.CwMemberUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
aggregate: {
args: Prisma.CwMemberAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCwMember>
}
groupBy: {
args: Prisma.CwMemberGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CwMemberGroupByOutputType>[]
}
count: {
args: Prisma.CwMemberCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CwMemberCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
@@ -1322,6 +1472,7 @@ export const OpportunityScalarFieldEnum = {
siteName: 'siteName',
customerPO: 'customerPO',
totalSalesTax: 'totalSalesTax',
probability: 'probability',
locationName: 'locationName',
locationCwId: 'locationCwId',
departmentName: 'departmentName',
@@ -1384,6 +1535,40 @@ export const CredentialScalarFieldEnum = {
export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof CredentialScalarFieldEnum]
export const GeneratedQuotesScalarFieldEnum = {
id: 'id',
quoteRegenData: 'quoteRegenData',
quoteRegenParams: 'quoteRegenParams',
quoteRegenHash: 'quoteRegenHash',
downloads: 'downloads',
quoteFile: 'quoteFile',
quoteFileName: 'quoteFileName',
opportunityId: 'opportunityId',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
export const CwMemberScalarFieldEnum = {
id: 'id',
cwMemberId: 'cwMemberId',
identifier: 'identifier',
firstName: 'firstName',
lastName: 'lastName',
officeEmail: 'officeEmail',
inactiveFlag: 'inactiveFlag',
apiKey: 'apiKey',
cwLastUpdated: 'cwLastUpdated',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CwMemberScalarFieldEnum = (typeof CwMemberScalarFieldEnum)[keyof typeof CwMemberScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -1506,6 +1691,20 @@ export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'J
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
/**
* Reference to a field of type 'Bytes'
*/
export type BytesFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Bytes'>
/**
* Reference to a field of type 'Bytes[]'
*/
export type ListBytesFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Bytes[]'>
/**
* Batch Payload for updateMany & deleteMany & createMany
*/
@@ -1611,6 +1810,8 @@ export type GlobalOmitConfig = {
credentialType?: Prisma.CredentialTypeOmit
secureValue?: Prisma.SecureValueOmit
credential?: Prisma.CredentialOmit
generatedQuotes?: Prisma.GeneratedQuotesOmit
cwMember?: Prisma.CwMemberOmit
}
/* Types for Logging */
@@ -60,7 +60,9 @@ export const ModelName = {
Opportunity: 'Opportunity',
CredentialType: 'CredentialType',
SecureValue: 'SecureValue',
Credential: 'Credential'
Credential: 'Credential',
GeneratedQuotes: 'GeneratedQuotes',
CwMember: 'CwMember'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -209,6 +211,7 @@ export const OpportunityScalarFieldEnum = {
siteName: 'siteName',
customerPO: 'customerPO',
totalSalesTax: 'totalSalesTax',
probability: 'probability',
locationName: 'locationName',
locationCwId: 'locationCwId',
departmentName: 'departmentName',
@@ -271,6 +274,40 @@ export const CredentialScalarFieldEnum = {
export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof CredentialScalarFieldEnum]
export const GeneratedQuotesScalarFieldEnum = {
id: 'id',
quoteRegenData: 'quoteRegenData',
quoteRegenParams: 'quoteRegenParams',
quoteRegenHash: 'quoteRegenHash',
downloads: 'downloads',
quoteFile: 'quoteFile',
quoteFileName: 'quoteFileName',
opportunityId: 'opportunityId',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
export const CwMemberScalarFieldEnum = {
id: 'id',
cwMemberId: 'cwMemberId',
identifier: 'identifier',
firstName: 'firstName',
lastName: 'lastName',
officeEmail: 'officeEmail',
inactiveFlag: 'inactiveFlag',
apiKey: 'apiKey',
cwLastUpdated: 'cwLastUpdated',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CwMemberScalarFieldEnum = (typeof CwMemberScalarFieldEnum)[keyof typeof CwMemberScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
+2
View File
@@ -18,4 +18,6 @@ export type * from './models/Opportunity.ts'
export type * from './models/CredentialType.ts'
export type * from './models/SecureValue.ts'
export type * from './models/Credential.ts'
export type * from './models/GeneratedQuotes.ts'
export type * from './models/CwMember.ts'
export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+351 -1
View File
@@ -40,6 +40,7 @@ export type OpportunityAvgAggregateOutputType = {
contactCwId: number | null
siteCwId: number | null
totalSalesTax: number | null
probability: number | null
locationCwId: number | null
departmentCwId: number | null
closedByCwId: number | null
@@ -60,6 +61,7 @@ export type OpportunitySumAggregateOutputType = {
contactCwId: number | null
siteCwId: number | null
totalSalesTax: number | null
probability: number | null
locationCwId: number | null
departmentCwId: number | null
closedByCwId: number | null
@@ -98,6 +100,7 @@ export type OpportunityMinAggregateOutputType = {
siteName: string | null
customerPO: string | null
totalSalesTax: number | null
probability: number | null
locationName: string | null
locationCwId: number | null
departmentName: string | null
@@ -147,6 +150,7 @@ export type OpportunityMaxAggregateOutputType = {
siteName: string | null
customerPO: string | null
totalSalesTax: number | null
probability: number | null
locationName: string | null
locationCwId: number | null
departmentName: string | null
@@ -196,6 +200,7 @@ export type OpportunityCountAggregateOutputType = {
siteName: number
customerPO: number
totalSalesTax: number
probability: number
locationName: number
locationCwId: number
departmentName: number
@@ -230,6 +235,7 @@ export type OpportunityAvgAggregateInputType = {
contactCwId?: true
siteCwId?: true
totalSalesTax?: true
probability?: true
locationCwId?: true
departmentCwId?: true
closedByCwId?: true
@@ -250,6 +256,7 @@ export type OpportunitySumAggregateInputType = {
contactCwId?: true
siteCwId?: true
totalSalesTax?: true
probability?: true
locationCwId?: true
departmentCwId?: true
closedByCwId?: true
@@ -288,6 +295,7 @@ export type OpportunityMinAggregateInputType = {
siteName?: true
customerPO?: true
totalSalesTax?: true
probability?: true
locationName?: true
locationCwId?: true
departmentName?: true
@@ -337,6 +345,7 @@ export type OpportunityMaxAggregateInputType = {
siteName?: true
customerPO?: true
totalSalesTax?: true
probability?: true
locationName?: true
locationCwId?: true
departmentName?: true
@@ -386,6 +395,7 @@ export type OpportunityCountAggregateInputType = {
siteName?: true
customerPO?: true
totalSalesTax?: true
probability?: true
locationName?: true
locationCwId?: true
departmentName?: true
@@ -523,6 +533,7 @@ export type OpportunityGroupByOutputType = {
siteName: string | null
customerPO: string | null
totalSalesTax: number
probability: number
locationName: string | null
locationCwId: number | null
departmentName: string | null
@@ -596,6 +607,7 @@ export type OpportunityWhereInput = {
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
probability?: Prisma.FloatFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
@@ -612,6 +624,7 @@ export type OpportunityWhereInput = {
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
}
@@ -647,6 +660,7 @@ export type OpportunityOrderByWithRelationInput = {
siteName?: Prisma.SortOrderInput | Prisma.SortOrder
customerPO?: Prisma.SortOrderInput | Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrderInput | Prisma.SortOrder
locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder
departmentName?: Prisma.SortOrderInput | Prisma.SortOrder
@@ -663,6 +677,7 @@ export type OpportunityOrderByWithRelationInput = {
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
company?: Prisma.CompanyOrderByWithRelationInput
}
@@ -701,6 +716,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
probability?: Prisma.FloatFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
@@ -717,6 +733,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
}, "id" | "cwOpportunityId">
@@ -752,6 +769,7 @@ export type OpportunityOrderByWithAggregationInput = {
siteName?: Prisma.SortOrderInput | Prisma.SortOrder
customerPO?: Prisma.SortOrderInput | Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrderInput | Prisma.SortOrder
locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder
departmentName?: Prisma.SortOrderInput | Prisma.SortOrder
@@ -810,6 +828,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
siteName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number
probability?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
@@ -860,6 +879,7 @@ export type OpportunityCreateInput = {
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
@@ -875,6 +895,7 @@ export type OpportunityCreateInput = {
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
}
@@ -910,6 +931,7 @@ export type OpportunityUncheckedCreateInput = {
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
@@ -926,6 +948,7 @@ export type OpportunityUncheckedCreateInput = {
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
}
export type OpportunityUpdateInput = {
@@ -960,6 +983,7 @@ export type OpportunityUpdateInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -975,6 +999,7 @@ export type OpportunityUpdateInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
}
@@ -1010,6 +1035,7 @@ export type OpportunityUncheckedUpdateInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1026,6 +1052,7 @@ export type OpportunityUncheckedUpdateInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
}
export type OpportunityCreateManyInput = {
@@ -1060,6 +1087,7 @@ export type OpportunityCreateManyInput = {
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
@@ -1110,6 +1138,7 @@ export type OpportunityUpdateManyMutationInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1159,6 +1188,7 @@ export type OpportunityUncheckedUpdateManyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1227,6 +1257,7 @@ export type OpportunityCountOrderByAggregateInput = {
siteName?: Prisma.SortOrder
customerPO?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder
departmentName?: Prisma.SortOrder
@@ -1259,6 +1290,7 @@ export type OpportunityAvgOrderByAggregateInput = {
contactCwId?: Prisma.SortOrder
siteCwId?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder
departmentCwId?: Prisma.SortOrder
closedByCwId?: Prisma.SortOrder
@@ -1297,6 +1329,7 @@ export type OpportunityMaxOrderByAggregateInput = {
siteName?: Prisma.SortOrder
customerPO?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder
departmentName?: Prisma.SortOrder
@@ -1346,6 +1379,7 @@ export type OpportunityMinOrderByAggregateInput = {
siteName?: Prisma.SortOrder
customerPO?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder
departmentName?: Prisma.SortOrder
@@ -1377,12 +1411,18 @@ export type OpportunitySumOrderByAggregateInput = {
contactCwId?: Prisma.SortOrder
siteCwId?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder
departmentCwId?: Prisma.SortOrder
closedByCwId?: Prisma.SortOrder
productSequence?: Prisma.SortOrder
}
export type OpportunityScalarRelationFilter = {
is?: Prisma.OpportunityWhereInput
isNot?: Prisma.OpportunityWhereInput
}
export type OpportunityCreateNestedManyWithoutCompanyInput = {
create?: Prisma.XOR<Prisma.OpportunityCreateWithoutCompanyInput, Prisma.OpportunityUncheckedCreateWithoutCompanyInput> | Prisma.OpportunityCreateWithoutCompanyInput[] | Prisma.OpportunityUncheckedCreateWithoutCompanyInput[]
connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutCompanyInput | Prisma.OpportunityCreateOrConnectWithoutCompanyInput[]
@@ -1434,6 +1474,20 @@ export type OpportunityUpdateproductSequenceInput = {
push?: number | number[]
}
export type OpportunityCreateNestedOneWithoutGeneratedQuotesInput = {
create?: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutGeneratedQuotesInput
connect?: Prisma.OpportunityWhereUniqueInput
}
export type OpportunityUpdateOneRequiredWithoutGeneratedQuotesNestedInput = {
create?: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutGeneratedQuotesInput
upsert?: Prisma.OpportunityUpsertWithoutGeneratedQuotesInput
connect?: Prisma.OpportunityWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.OpportunityUpdateToOneWithWhereWithoutGeneratedQuotesInput, Prisma.OpportunityUpdateWithoutGeneratedQuotesInput>, Prisma.OpportunityUncheckedUpdateWithoutGeneratedQuotesInput>
}
export type OpportunityCreateWithoutCompanyInput = {
id?: string
cwOpportunityId: number
@@ -1466,6 +1520,7 @@ export type OpportunityCreateWithoutCompanyInput = {
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
@@ -1481,6 +1536,7 @@ export type OpportunityCreateWithoutCompanyInput = {
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
}
export type OpportunityUncheckedCreateWithoutCompanyInput = {
@@ -1515,6 +1571,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
@@ -1530,6 +1587,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
}
export type OpportunityCreateOrConnectWithoutCompanyInput = {
@@ -1593,6 +1651,7 @@ export type OpportunityScalarWhereInput = {
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
probability?: Prisma.FloatFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
@@ -1611,6 +1670,226 @@ export type OpportunityScalarWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
}
export type OpportunityCreateWithoutGeneratedQuotesInput = {
id?: string
cwOpportunityId: number
name: string
notes?: string | null
typeName?: string | null
typeCwId?: number | null
stageName?: string | null
stageCwId?: number | null
statusName?: string | null
statusCwId?: number | null
priorityName?: string | null
priorityCwId?: number | null
ratingName?: string | null
ratingCwId?: number | null
source?: string | null
campaignName?: string | null
campaignCwId?: number | null
primarySalesRepName?: string | null
primarySalesRepIdentifier?: string | null
primarySalesRepCwId?: number | null
secondarySalesRepName?: string | null
secondarySalesRepIdentifier?: string | null
secondarySalesRepCwId?: number | null
companyCwId?: number | null
companyName?: string | null
contactCwId?: number | null
contactName?: string | null
siteCwId?: number | null
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
departmentCwId?: number | null
expectedCloseDate?: Date | string | null
pipelineChangeDate?: Date | string | null
dateBecameLead?: Date | string | null
closedDate?: Date | string | null
closedFlag?: boolean
closedByName?: string | null
closedByCwId?: number | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
}
export type OpportunityUncheckedCreateWithoutGeneratedQuotesInput = {
id?: string
cwOpportunityId: number
name: string
notes?: string | null
typeName?: string | null
typeCwId?: number | null
stageName?: string | null
stageCwId?: number | null
statusName?: string | null
statusCwId?: number | null
priorityName?: string | null
priorityCwId?: number | null
ratingName?: string | null
ratingCwId?: number | null
source?: string | null
campaignName?: string | null
campaignCwId?: number | null
primarySalesRepName?: string | null
primarySalesRepIdentifier?: string | null
primarySalesRepCwId?: number | null
secondarySalesRepName?: string | null
secondarySalesRepIdentifier?: string | null
secondarySalesRepCwId?: number | null
companyCwId?: number | null
companyName?: string | null
contactCwId?: number | null
contactName?: string | null
siteCwId?: number | null
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
departmentCwId?: number | null
expectedCloseDate?: Date | string | null
pipelineChangeDate?: Date | string | null
dateBecameLead?: Date | string | null
closedDate?: Date | string | null
closedFlag?: boolean
closedByName?: string | null
closedByCwId?: number | null
companyId?: string | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
}
export type OpportunityCreateOrConnectWithoutGeneratedQuotesInput = {
where: Prisma.OpportunityWhereUniqueInput
create: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
}
export type OpportunityUpsertWithoutGeneratedQuotesInput = {
update: Prisma.XOR<Prisma.OpportunityUpdateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedUpdateWithoutGeneratedQuotesInput>
create: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
where?: Prisma.OpportunityWhereInput
}
export type OpportunityUpdateToOneWithWhereWithoutGeneratedQuotesInput = {
where?: Prisma.OpportunityWhereInput
data: Prisma.XOR<Prisma.OpportunityUpdateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedUpdateWithoutGeneratedQuotesInput>
}
export type OpportunityUpdateWithoutGeneratedQuotesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
cwOpportunityId?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
typeName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
typeCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
stageName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
stageCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
statusName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
statusCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
priorityName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
priorityCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
ratingName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ratingCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
source?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
campaignName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
campaignCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
primarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
primarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
primarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
secondarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
secondarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
secondarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
contactCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
contactName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
siteCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
departmentCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
expectedCloseDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pipelineChangeDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
dateBecameLead?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
closedDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
}
export type OpportunityUncheckedUpdateWithoutGeneratedQuotesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
cwOpportunityId?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
typeName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
typeCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
stageName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
stageCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
statusName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
statusCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
priorityName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
priorityCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
ratingName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ratingCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
source?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
campaignName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
campaignCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
primarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
primarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
primarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
secondarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
secondarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
secondarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
contactCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
contactName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
siteCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
departmentCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
expectedCloseDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pipelineChangeDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
dateBecameLead?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
closedDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
export type OpportunityCreateManyCompanyInput = {
id?: string
cwOpportunityId: number
@@ -1643,6 +1922,7 @@ export type OpportunityCreateManyCompanyInput = {
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
@@ -1692,6 +1972,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1707,6 +1988,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
}
export type OpportunityUncheckedUpdateWithoutCompanyInput = {
@@ -1741,6 +2023,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1756,6 +2039,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
}
export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
@@ -1790,6 +2074,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1808,6 +2093,35 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
}
/**
* Count Type OpportunityCountOutputType
*/
export type OpportunityCountOutputType = {
generatedQuotes: number
}
export type OpportunityCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
generatedQuotes?: boolean | OpportunityCountOutputTypeCountGeneratedQuotesArgs
}
/**
* OpportunityCountOutputType without action
*/
export type OpportunityCountOutputTypeDefaultArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the OpportunityCountOutputType
*/
select?: Prisma.OpportunityCountOutputTypeSelect<ExtArgs> | null
}
/**
* OpportunityCountOutputType without action
*/
export type OpportunityCountOutputTypeCountGeneratedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.GeneratedQuotesWhereInput
}
export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@@ -1841,6 +2155,7 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
siteName?: boolean
customerPO?: boolean
totalSalesTax?: boolean
probability?: boolean
locationName?: boolean
locationCwId?: boolean
departmentName?: boolean
@@ -1857,7 +2172,9 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
cwLastUpdated?: boolean
createdAt?: boolean
updatedAt?: boolean
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
_count?: boolean | Prisma.OpportunityCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["opportunity"]>
export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@@ -1892,6 +2209,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
siteName?: boolean
customerPO?: boolean
totalSalesTax?: boolean
probability?: boolean
locationName?: boolean
locationCwId?: boolean
departmentName?: boolean
@@ -1943,6 +2261,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
siteName?: boolean
customerPO?: boolean
totalSalesTax?: boolean
probability?: boolean
locationName?: boolean
locationCwId?: boolean
departmentName?: boolean
@@ -1994,6 +2313,7 @@ export type OpportunitySelectScalar = {
siteName?: boolean
customerPO?: boolean
totalSalesTax?: boolean
probability?: boolean
locationName?: boolean
locationCwId?: boolean
departmentName?: boolean
@@ -2012,9 +2332,11 @@ export type OpportunitySelectScalar = {
updatedAt?: boolean
}
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "productSequence" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "probability" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "productSequence" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
export type OpportunityInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
_count?: boolean | Prisma.OpportunityCountOutputTypeDefaultArgs<ExtArgs>
}
export type OpportunityIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
@@ -2026,6 +2348,7 @@ export type OpportunityIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.
export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "Opportunity"
objects: {
generatedQuotes: Prisma.$GeneratedQuotesPayload<ExtArgs>[]
company: Prisma.$CompanyPayload<ExtArgs> | null
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
@@ -2060,6 +2383,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
siteName: string | null
customerPO: string | null
totalSalesTax: number
probability: number
locationName: string | null
locationCwId: number | null
departmentName: string | null
@@ -2470,6 +2794,7 @@ readonly fields: OpportunityFieldRefs;
*/
export interface Prisma__OpportunityClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
generatedQuotes<T extends Prisma.Opportunity$generatedQuotesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Opportunity$generatedQuotesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$GeneratedQuotesPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
company<T extends Prisma.Opportunity$companyArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Opportunity$companyArgs<ExtArgs>>): Prisma.Prisma__CompanyClient<runtime.Types.Result.GetResult<Prisma.$CompanyPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
@@ -2531,6 +2856,7 @@ export interface OpportunityFieldRefs {
readonly siteName: Prisma.FieldRef<"Opportunity", 'String'>
readonly customerPO: Prisma.FieldRef<"Opportunity", 'String'>
readonly totalSalesTax: Prisma.FieldRef<"Opportunity", 'Float'>
readonly probability: Prisma.FieldRef<"Opportunity", 'Float'>
readonly locationName: Prisma.FieldRef<"Opportunity", 'String'>
readonly locationCwId: Prisma.FieldRef<"Opportunity", 'Int'>
readonly departmentName: Prisma.FieldRef<"Opportunity", 'String'>
@@ -2942,6 +3268,30 @@ export type OpportunityDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.I
limit?: number
}
/**
* Opportunity.generatedQuotes
*/
export type Opportunity$generatedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the GeneratedQuotes
*/
select?: Prisma.GeneratedQuotesSelect<ExtArgs> | null
/**
* Omit specific fields from the GeneratedQuotes
*/
omit?: Prisma.GeneratedQuotesOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.GeneratedQuotesInclude<ExtArgs> | null
where?: Prisma.GeneratedQuotesWhereInput
orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[]
cursor?: Prisma.GeneratedQuotesWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[]
}
/**
* Opportunity.company
*/
+157
View File
@@ -240,6 +240,7 @@ export type UserWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
roles?: Prisma.RoleListRelationFilter
sessions?: Prisma.SessionListRelationFilter
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
}
export type UserOrderByWithRelationInput = {
@@ -257,6 +258,7 @@ export type UserOrderByWithRelationInput = {
updatedAt?: Prisma.SortOrder
roles?: Prisma.RoleOrderByRelationAggregateInput
sessions?: Prisma.SessionOrderByRelationAggregateInput
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
}
export type UserWhereUniqueInput = Prisma.AtLeast<{
@@ -277,6 +279,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
roles?: Prisma.RoleListRelationFilter
sessions?: Prisma.SessionListRelationFilter
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
}, "id" | "login" | "email" | "userId">
export type UserOrderByWithAggregationInput = {
@@ -330,6 +333,7 @@ export type UserCreateInput = {
updatedAt?: Date | string
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
}
export type UserUncheckedCreateInput = {
@@ -347,6 +351,7 @@ export type UserUncheckedCreateInput = {
updatedAt?: Date | string
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
}
export type UserUpdateInput = {
@@ -364,6 +369,7 @@ export type UserUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
}
export type UserUncheckedUpdateInput = {
@@ -381,6 +387,7 @@ export type UserUncheckedUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
}
export type UserCreateManyInput = {
@@ -488,6 +495,11 @@ export type UserOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder
}
export type UserNullableScalarRelationFilter = {
is?: Prisma.UserWhereInput | null
isNot?: Prisma.UserWhereInput | null
}
export type UserCreateNestedOneWithoutSessionsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutSessionsInput, Prisma.UserUncheckedCreateWithoutSessionsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutSessionsInput
@@ -544,6 +556,22 @@ export type UserUncheckedUpdateManyWithoutRolesNestedInput = {
deleteMany?: Prisma.UserScalarWhereInput | Prisma.UserScalarWhereInput[]
}
export type UserCreateNestedOneWithoutGeneratedQuotesInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutGeneratedQuotesInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneWithoutGeneratedQuotesNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutGeneratedQuotesInput
upsert?: Prisma.UserUpsertWithoutGeneratedQuotesInput
disconnect?: Prisma.UserWhereInput | boolean
delete?: Prisma.UserWhereInput | boolean
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutGeneratedQuotesInput, Prisma.UserUpdateWithoutGeneratedQuotesInput>, Prisma.UserUncheckedUpdateWithoutGeneratedQuotesInput>
}
export type UserCreateWithoutSessionsInput = {
id?: string
permissions?: string | null
@@ -558,6 +586,7 @@ export type UserCreateWithoutSessionsInput = {
createdAt?: Date | string
updatedAt?: Date | string
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
}
export type UserUncheckedCreateWithoutSessionsInput = {
@@ -574,6 +603,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
createdAt?: Date | string
updatedAt?: Date | string
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
}
export type UserCreateOrConnectWithoutSessionsInput = {
@@ -606,6 +636,7 @@ export type UserUpdateWithoutSessionsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
}
export type UserUncheckedUpdateWithoutSessionsInput = {
@@ -622,6 +653,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
}
export type UserCreateWithoutRolesInput = {
@@ -638,6 +670,7 @@ export type UserCreateWithoutRolesInput = {
createdAt?: Date | string
updatedAt?: Date | string
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
}
export type UserUncheckedCreateWithoutRolesInput = {
@@ -654,6 +687,7 @@ export type UserUncheckedCreateWithoutRolesInput = {
createdAt?: Date | string
updatedAt?: Date | string
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
}
export type UserCreateOrConnectWithoutRolesInput = {
@@ -695,6 +729,90 @@ export type UserScalarWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
}
export type UserCreateWithoutGeneratedQuotesInput = {
id?: string
permissions?: string | null
login: string
name?: string | null
email: string
emailVerified?: Date | string | null
image?: string | null
cwIdentifier?: string | null
userId: string
token?: string | null
createdAt?: Date | string
updatedAt?: Date | string
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutGeneratedQuotesInput = {
id?: string
permissions?: string | null
login: string
name?: string | null
email: string
emailVerified?: Date | string | null
image?: string | null
cwIdentifier?: string | null
userId: string
token?: string | null
createdAt?: Date | string
updatedAt?: Date | string
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutGeneratedQuotesInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
}
export type UserUpsertWithoutGeneratedQuotesInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutGeneratedQuotesInput, Prisma.UserUncheckedUpdateWithoutGeneratedQuotesInput>
create: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutGeneratedQuotesInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutGeneratedQuotesInput, Prisma.UserUncheckedUpdateWithoutGeneratedQuotesInput>
}
export type UserUpdateWithoutGeneratedQuotesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
login?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
userId?: Prisma.StringFieldUpdateOperationsInput | string
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutGeneratedQuotesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
login?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
userId?: Prisma.StringFieldUpdateOperationsInput | string
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
}
export type UserUpdateWithoutRolesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -709,6 +827,7 @@ export type UserUpdateWithoutRolesInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
}
export type UserUncheckedUpdateWithoutRolesInput = {
@@ -725,6 +844,7 @@ export type UserUncheckedUpdateWithoutRolesInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
}
export type UserUncheckedUpdateManyWithoutRolesInput = {
@@ -750,11 +870,13 @@ export type UserUncheckedUpdateManyWithoutRolesInput = {
export type UserCountOutputType = {
roles: number
sessions: number
generatedQuotes: number
}
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
roles?: boolean | UserCountOutputTypeCountRolesArgs
sessions?: boolean | UserCountOutputTypeCountSessionsArgs
generatedQuotes?: boolean | UserCountOutputTypeCountGeneratedQuotesArgs
}
/**
@@ -781,6 +903,13 @@ export type UserCountOutputTypeCountSessionsArgs<ExtArgs extends runtime.Types.E
where?: Prisma.SessionWhereInput
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountGeneratedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.GeneratedQuotesWhereInput
}
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@@ -797,6 +926,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
updatedAt?: boolean
roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]>
@@ -849,6 +979,7 @@ export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = run
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
@@ -859,6 +990,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
objects: {
roles: Prisma.$RolePayload<ExtArgs>[]
sessions: Prisma.$SessionPayload<ExtArgs>[]
generatedQuotes: Prisma.$GeneratedQuotesPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@@ -1269,6 +1401,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
readonly [Symbol.toStringTag]: "PrismaPromise"
roles<T extends Prisma.User$rolesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$rolesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$RolePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
sessions<T extends Prisma.User$sessionsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$sessionsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SessionPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
generatedQuotes<T extends Prisma.User$generatedQuotesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$generatedQuotesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$GeneratedQuotesPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1745,6 +1878,30 @@ export type User$sessionsArgs<ExtArgs extends runtime.Types.Extensions.InternalA
distinct?: Prisma.SessionScalarFieldEnum | Prisma.SessionScalarFieldEnum[]
}
/**
* User.generatedQuotes
*/
export type User$generatedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the GeneratedQuotes
*/
select?: Prisma.GeneratedQuotesSelect<ExtArgs> | null
/**
* Omit specific fields from the GeneratedQuotes
*/
omit?: Prisma.GeneratedQuotesOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.GeneratedQuotesInclude<ExtArgs> | null
where?: Prisma.GeneratedQuotesWhereInput
orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[]
cursor?: Prisma.GeneratedQuotesWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[]
}
/**
* User without action
*/
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+2
View File
@@ -48,6 +48,8 @@
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"keypair": "^1.0.4",
"pdf-lib": "^1.17.1",
"pdfmake": "^0.3.5",
"prisma": "^7.3.0",
"socket.io": "^4.8.3",
"zod": "^4.3.6",
@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "GeneratedQuotes" (
"id" TEXT NOT NULL,
"quoteFile" BYTEA NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GeneratedQuotes_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,19 @@
/*
Warnings:
- Added the required column `opportunityId` to the `GeneratedQuotes` table without a default value. This is not possible if the table is not empty.
- Added the required column `quoteFileName` to the `GeneratedQuotes` table without a default value. This is not possible if the table is not empty.
- Added the required column `quoteRegenData` to the `GeneratedQuotes` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "GeneratedQuotes" ADD COLUMN "createdById" TEXT,
ADD COLUMN "opportunityId" TEXT NOT NULL,
ADD COLUMN "quoteFileName" TEXT NOT NULL,
ADD COLUMN "quoteRegenData" JSONB NOT NULL;
-- AddForeignKey
ALTER TABLE "GeneratedQuotes" ADD CONSTRAINT "GeneratedQuotes_opportunityId_fkey" FOREIGN KEY ("opportunityId") REFERENCES "Opportunity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GeneratedQuotes" ADD CONSTRAINT "GeneratedQuotes_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable: Opportunity
ALTER TABLE "Opportunity" ADD COLUMN "probability" DOUBLE PRECISION NOT NULL DEFAULT 0;
@@ -0,0 +1,10 @@
-- AlterTable: GeneratedQuotes — add columns missing from prior db push
ALTER TABLE "GeneratedQuotes" ADD COLUMN "quoteRegenParams" JSONB NOT NULL DEFAULT '{}';
ALTER TABLE "GeneratedQuotes" ADD COLUMN "quoteRegenHash" TEXT NOT NULL DEFAULT '';
ALTER TABLE "GeneratedQuotes" ADD COLUMN "downloads" JSONB NOT NULL DEFAULT '[]';
-- AlterTable: GeneratedQuotes — set default on existing quoteRegenData column
ALTER TABLE "GeneratedQuotes" ALTER COLUMN "quoteRegenData" SET DEFAULT '{}';
-- CreateIndex
CREATE UNIQUE INDEX "GeneratedQuotes_quoteRegenHash_key" ON "GeneratedQuotes"("quoteRegenHash");
+45 -2
View File
@@ -41,8 +41,9 @@ model User {
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
generatedQuotes GeneratedQuotes[]
}
model Role {
@@ -130,6 +131,8 @@ model Opportunity {
name String
notes String?
generatedQuotes GeneratedQuotes[]
// Stage / status / priority / type / rating stored as JSON references
// so we don't need separate lookup tables for CW enums
typeName String?
@@ -165,6 +168,7 @@ model Opportunity {
// Financials
totalSalesTax Float @default(0)
probability Float @default(0)
// Location / department
locationName String?
@@ -244,3 +248,42 @@ model Credential {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model GeneratedQuotes {
id String @id @default(uuid())
quoteRegenData Json @default("{}") // Store any additional data needed for quote regeneration, such as product details, pricing, etc.
quoteRegenParams Json @default("{}") // Store parameters used for quote regeneration, such as template ID, formatting options, etc.
quoteRegenHash String @unique @default("")
downloads Json @default("[]") // Array of download records with timestamp and user info
quoteFile Bytes
quoteFileName String
opportunityId String
opportunity Opportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade)
createdById String?
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CwMember {
id String @id @default(cuid())
cwMemberId Int @unique
identifier String @unique
firstName String
lastName String
officeEmail String?
inactiveFlag Boolean @default(false)
apiKey String?
cwLastUpdated DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+37
View File
@@ -0,0 +1,37 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { getMemberCache } from "../../modules/cw-utils/members/memberCache";
/* GET /v1/cw/members */
export default createRoute(
"get",
["/members"],
async (c) => {
const cache = await getMemberCache();
const activeOnly = c.req.query("active") !== "false";
const members = cache
.filter((m) => (activeOnly ? !m.inactiveFlag : true))
.map((m) => ({
id: m.id,
identifier: m.identifier,
firstName: m.firstName,
lastName: m.lastName,
name: `${m.firstName} ${m.lastName}`.trim(),
officeEmail: m.officeEmail,
inactive: m.inactiveFlag,
}));
const sorted = members.sort((a, b) => a.name.localeCompare(b.name));
const response = apiResponse.successful(
"CW members fetched successfully!",
sorted,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware(),
);
+2 -1
View File
@@ -1,3 +1,4 @@
import { default as callback } from "./callback";
import { default as fetchMembers } from "./fetchMembers";
export { callback };
export { callback, fetchMembers };
+27 -53
View File
@@ -18,13 +18,13 @@ import {
fetchAndCacheProducts,
fetchAndCacheSite,
} from "../../../modules/cache/opportunityCache";
import { generatedQuotes } from "../../../managers/generatedQuotes";
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products */
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
export default createRoute(
"get",
["/opportunities/:identifier"],
async (c) => {
const t0 = performance.now();
const identifier = c.req.param("identifier");
const includeParam = c.req.query("include") ?? "";
const includes = new Set(
@@ -80,24 +80,14 @@ export default createRoute(
// Check Redis first — if the background refresh has kept the keys warm,
// skip the CW calls entirely. Only fetch-and-cache on a miss.
const cwOppId = dbRecord.cwOpportunityId;
const _pw0 = performance.now();
const _wrapPw = (label: string, p: Promise<any>) =>
p
.then((r) => {
console.log(
`[PERF:prewarm] ${label}: ${(performance.now() - _pw0).toFixed(0)}ms`,
);
return r;
})
.catch(() => {});
const _ignoreErrors = (p: Promise<any>) => p.catch(() => {});
const prewarmPromises: Promise<any>[] = [];
if (dbRecord.companyCwId && dbRecord.siteCwId) {
const compId = dbRecord.companyCwId,
siteId = dbRecord.siteCwId;
prewarmPromises.push(
_wrapPw(
"site",
_ignoreErrors(
getCachedSite(compId, siteId).then(
(c) => c ?? fetchAndCacheSite(compId, siteId),
),
@@ -106,8 +96,7 @@ export default createRoute(
}
if (includes.has("notes") && subTtl)
prewarmPromises.push(
_wrapPw(
"notes",
_ignoreErrors(
getCachedNotes(cwOppId).then(
(c) => c ?? fetchAndCacheNotes(cwOppId, subTtl),
),
@@ -115,8 +104,7 @@ export default createRoute(
);
if (includes.has("contacts") && subTtl)
prewarmPromises.push(
_wrapPw(
"contacts",
_ignoreErrors(
getCachedContacts(cwOppId).then(
(c) => c ?? fetchAndCacheContacts(cwOppId, subTtl),
),
@@ -124,8 +112,7 @@ export default createRoute(
);
if (includes.has("products") && prodTtl)
prewarmPromises.push(
_wrapPw(
"products",
_ignoreErrors(
getCachedProducts(cwOppId).then(
(c) => c ?? fetchAndCacheProducts(cwOppId, prodTtl),
),
@@ -138,46 +125,30 @@ export default createRoute(
opportunities.fetchItem(identifier),
...prewarmPromises,
]);
const t1 = performance.now();
console.log(`[PERF] fetchItem + prewarm: ${(t1 - t0).toFixed(0)}ms`);
// Sub-resources now hit warm Redis cache (near-instant)
const _st = performance.now();
const _wrapTimed = (label: string, p: Promise<any>) =>
p.then((r) => {
console.log(
`[PERF:sub] ${label}: ${(performance.now() - _st).toFixed(0)}ms`,
);
return r;
});
const subResourcePromises: Record<string, Promise<any>> = {
_site: _wrapTimed("site", item.fetchSite()),
_site: item.fetchSite(),
};
if (includes.has("notes")) {
subResourcePromises.notes = _wrapTimed("notes", item.fetchNotes());
subResourcePromises.notes = item.fetchNotes();
}
if (includes.has("contacts")) {
subResourcePromises.contacts = _wrapTimed(
"contacts",
item.fetchContacts(),
);
subResourcePromises.contacts = item.fetchContacts();
}
if (includes.has("products")) {
subResourcePromises.products = _wrapTimed(
"products",
item
.fetchProducts()
.then((products) => products.map((p) => p.toJson())),
);
subResourcePromises.products = item
.fetchProducts()
.then((products) => products.map((p) => p.toJson()));
}
if (includes.has("quotes")) {
subResourcePromises.quotes = generatedQuotes
.fetchByOpportunity(item.id)
.then((quotes) => quotes.map((q) => q.toJson()));
}
const keys = Object.keys(subResourcePromises);
const results = await Promise.all(keys.map((k) => subResourcePromises[k]));
const t2 = performance.now();
console.log(
`[PERF] sub-resources (${keys.join(",")}): ${(t2 - t1).toFixed(0)}ms`,
);
// Apply toJson after site is hydrated (side-effect from fetchSite)
const gatedData = await processObjectValuePerms(
@@ -185,8 +156,8 @@ export default createRoute(
"obj.opportunity",
c.get("user"),
);
const t3 = performance.now();
console.log(`[PERF] processObjectValuePerms: ${(t3 - t2).toFixed(0)}ms`);
const originalOpportunityNoteText = (gatedData as any).notes;
// Attach sub-resources (skip the internal _site key)
keys.forEach((k, i) => {
@@ -195,14 +166,17 @@ export default createRoute(
}
});
if (includes.has("notes")) {
(gatedData as any).opportunityNoteText =
typeof originalOpportunityNoteText === "string"
? originalOpportunityNoteText
: null;
}
const response = apiResponse.successful(
"Opportunity fetched successfully!",
gatedData,
);
console.log(
`[PERF] total handler: ${(performance.now() - t0).toFixed(0)}ms (includes=${includeParam || "none"})`,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
+37 -13
View File
@@ -1,31 +1,55 @@
import { default as fetchAll } from "./fetchAll";
import { default as fetchAll } from "./opportunities/fetchAll";
import { default as createOpportunity } from "./opportunities/create";
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
import { default as count } from "./count";
import { default as fetch } from "./[id]/fetch";
import { default as refresh } from "./[id]/refresh";
import { default as products } from "./[id]/products";
import { default as addProduct } from "./[id]/addProduct";
import { default as resequenceProducts } from "./[id]/resequenceProducts";
import { default as notes } from "./[id]/notes";
import { default as fetchNote } from "./[id]/fetchNote";
import { default as createNote } from "./[id]/createNote";
import { default as updateNote } from "./[id]/updateNote";
import { default as deleteNote } from "./[id]/deleteNote";
import { default as contacts } from "./[id]/contacts";
import { default as count } from "./opportunities/count";
import { default as fetch } from "./opportunities/[id]/fetch";
import { default as refresh } from "./opportunities/[id]/refresh";
import { default as updateOpportunity } from "./opportunities/[id]/update";
import { default as products } from "./opportunities/[id]/products/fetchAll";
import { default as addProduct } from "./opportunities/[id]/products/add";
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
import { default as addLabor } from "./opportunities/[id]/products/addLabor";
import { default as laborOptions } from "./opportunities/[id]/products/laborOptions";
import { default as resequenceProducts } from "./opportunities/[id]/products/resequence";
import { default as updateProduct } from "./opportunities/[id]/products/update";
import { default as cancelProduct } from "./opportunities/[id]/products/cancel";
import { default as notes } from "./opportunities/[id]/notes/fetchAll";
import { default as fetchNote } from "./opportunities/[id]/notes/fetch";
import { default as createNote } from "./opportunities/[id]/notes/create";
import { default as updateNote } from "./opportunities/[id]/notes/update";
import { default as deleteNote } from "./opportunities/[id]/notes/delete";
import { default as contacts } from "./opportunities/[id]/contacts";
import { default as commitQuote } from "./opportunities/[id]/quotes/commit";
import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll";
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
export {
addProduct,
addLabor,
laborOptions,
addSpecialOrderProduct,
count,
createOpportunity,
fetch,
fetchAll,
fetchOpportunityTypes,
products,
resequenceProducts,
updateProduct,
cancelProduct,
notes,
fetchNote,
createNote,
updateNote,
deleteNote,
contacts,
commitQuote,
fetchQuotes,
previewQuote,
downloadQuote,
fetchDownloads,
refresh,
updateOpportunity,
};
@@ -1,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { authMiddleware } from "../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/contacts */
export default createRoute(
+187
View File
@@ -0,0 +1,187 @@
import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
import { processObjectValuePerms } from "../../../../modules/permission-utils/processObjectPermissions";
import GenericError from "../../../../Errors/GenericError";
import { prisma } from "../../../../constants";
import { computeSubResourceCacheTTL } from "../../../../modules/algorithms/computeSubResourceCacheTTL";
import { computeProductsCacheTTL } from "../../../../modules/algorithms/computeProductsCacheTTL";
import {
getCachedSite,
getCachedNotes,
getCachedContacts,
getCachedProducts,
fetchAndCacheNotes,
fetchAndCacheContacts,
fetchAndCacheProducts,
fetchAndCacheSite,
} from "../../../../modules/cache/opportunityCache";
import { generatedQuotes } from "../../../../managers/generatedQuotes";
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
export default createRoute(
"get",
["/opportunities/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const includeParam = c.req.query("include") ?? "";
const includes = new Set(
includeParam
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
// ── Quick DB lookup (≈3ms) to get cwOpportunityId for pre-warming ──
const isNumeric = /^\d+$/.test(identifier);
const dbRecord = await prisma.opportunity.findFirst({
where: isNumeric
? { cwOpportunityId: Number(identifier) }
: { id: identifier },
select: {
cwOpportunityId: true,
companyCwId: true,
siteCwId: true,
closedFlag: true,
closedDate: true,
expectedCloseDate: true,
cwLastUpdated: true,
statusCwId: true,
},
});
if (!dbRecord) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
// Compute TTLs from DB state
const subTtl = computeSubResourceCacheTTL({
closedFlag: dbRecord.closedFlag,
closedDate: dbRecord.closedDate,
expectedCloseDate: dbRecord.expectedCloseDate,
lastUpdated: dbRecord.cwLastUpdated,
});
const prodTtl = computeProductsCacheTTL({
closedFlag: dbRecord.closedFlag,
closedDate: dbRecord.closedDate,
expectedCloseDate: dbRecord.expectedCloseDate,
lastUpdated: dbRecord.cwLastUpdated,
statusCwId: dbRecord.statusCwId,
});
// ── Pre-warm sub-resources only on cache miss ───────────────────────
// Check Redis first — if the background refresh has kept the keys warm,
// skip the CW calls entirely. Only fetch-and-cache on a miss.
const cwOppId = dbRecord.cwOpportunityId;
const _ignoreErrors = (p: Promise<any>) => p.catch(() => {});
const prewarmPromises: Promise<any>[] = [];
if (dbRecord.companyCwId && dbRecord.siteCwId) {
const compId = dbRecord.companyCwId,
siteId = dbRecord.siteCwId;
prewarmPromises.push(
_ignoreErrors(
getCachedSite(compId, siteId).then(
(c) => c ?? fetchAndCacheSite(compId, siteId),
),
),
);
}
if (includes.has("notes") && subTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedNotes(cwOppId).then(
(c) => c ?? fetchAndCacheNotes(cwOppId, subTtl),
),
),
);
if (includes.has("contacts") && subTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedContacts(cwOppId).then(
(c) => c ?? fetchAndCacheContacts(cwOppId, subTtl),
),
),
);
if (includes.has("products") && prodTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedProducts(cwOppId).then(
(c) => c ?? fetchAndCacheProducts(cwOppId, prodTtl),
),
),
);
// fetchItem runs its own CW calls (opp, activities, company) —
// these execute concurrently with the sub-resource pre-warming above.
const [item] = await Promise.all([
opportunities.fetchItem(identifier),
...prewarmPromises,
]);
// Sub-resources now hit warm Redis cache (near-instant)
const subResourcePromises: Record<string, Promise<any>> = {
_site: item.fetchSite(),
};
if (includes.has("notes")) {
subResourcePromises.notes = item.fetchNotes();
}
if (includes.has("contacts")) {
subResourcePromises.contacts = item.fetchContacts();
}
if (includes.has("products")) {
subResourcePromises.products = item
.fetchProducts()
.then((products) => products.map((p) => p.toJson()));
}
if (includes.has("quotes")) {
const includeRegenData = c.req.query("includeRegenData") === "true";
const includeRegenParams = c.req.query("includeRegenParams") === "true";
subResourcePromises.quotes = generatedQuotes
.fetchByOpportunity(item.id)
.then((quotes) =>
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams })),
);
}
const keys = Object.keys(subResourcePromises);
const results = await Promise.all(keys.map((k) => subResourcePromises[k]));
// Apply toJson after site is hydrated (side-effect from fetchSite)
const gatedData = await processObjectValuePerms(
item.toJson(),
"obj.opportunity",
c.get("user"),
);
const originalOpportunityNoteText = (gatedData as any).notes;
// Attach sub-resources (skip the internal _site key)
keys.forEach((k, i) => {
if (k !== "_site") {
(gatedData as any)[k] = results[i];
}
});
if (includes.has("notes")) {
(gatedData as any).opportunityNoteText =
typeof originalOpportunityNoteText === "string"
? originalOpportunityNoteText
: null;
}
const response = apiResponse.successful(
"Opportunity fetched successfully!",
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { resolveMember } from "../../../modules/cw-utils/members/memberCache";
import { authMiddleware } from "../../../../middleware/authorization";
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
import { z } from "zod";
/* POST /v1/sales/opportunities/:identifier/notes */
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
export default createRoute(
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */
export default createRoute(
@@ -1,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/notes */
export default createRoute(
@@ -1,10 +1,10 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { resolveMember } from "../../../modules/cw-utils/members/memberCache";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
import { authMiddleware } from "../../../../middleware/authorization";
import { processObjectValuePerms } from "../../../../../modules/permission-utils/processObjectPermissions";
import { z } from "zod";
const productItemSchema = z
@@ -0,0 +1,147 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { procurement } from "../../../../../managers/procurement";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod";
const LABOR_DEFAULT_RATE = {
corporate: 100,
residential: 85,
} as const;
const roundMoney = (value: number) => Math.round(value * 100) / 100;
const addLaborSchema = z
.object({
laborStyle: z.enum(["field", "tech"]),
customerType: z.enum(["corporate", "residential"]).optional(),
hours: z.number().positive().optional(),
rate: z.number().min(0).optional(),
ppu: z.number().min(0).optional(),
cpu: z.number().min(0).optional(),
taxable: z.boolean().optional(),
taxableFlag: z.boolean().optional(),
description: z.string().min(1).optional(),
customerDescription: z.string().min(1).optional(),
procurementNotes: z.string().optional(),
productNarrative: z.string().optional(),
})
.strict();
/* POST /v1/sales/opportunities/:identifier/products/labor */
export default createRoute(
"post",
["/opportunities/:identifier/products/labor"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const input = addLaborSchema.parse(body);
const laborCatalog = await procurement.fetchLaborCatalogItems();
const selectedCatalog =
input.laborStyle === "tech" ? laborCatalog.tech : laborCatalog.field;
const customerType = input.customerType ?? "corporate";
const defaultRate = LABOR_DEFAULT_RATE[customerType];
const quantity = input.hours ?? 1;
const ppu = input.ppu ?? input.rate ?? defaultRate;
const cpu = input.cpu ?? roundMoney(ppu * 0.5);
const taxableFlag =
input.taxable ?? input.taxableFlag ?? selectedCatalog.salesTaxable;
const makeCustomField = (
caption: string,
value: string,
fieldId: number,
) => ({
id: fieldId,
caption,
type: "Text",
entryMethod: "EntryField",
value,
});
const payload = {
...(input.procurementNotes || input.productNarrative
? {
customFields: [
...(input.procurementNotes
? [
makeCustomField(
"Procurement Notes",
input.procurementNotes,
29,
),
]
: []),
...(input.productNarrative
? [
makeCustomField(
"Product Narrative",
input.productNarrative,
46,
),
]
: []),
],
}
: {}),
catalogItem: { id: selectedCatalog.cwCatalogId },
description:
input.description ??
selectedCatalog.name ??
selectedCatalog.identifier ??
`${input.laborStyle.toUpperCase()} Labor`,
customerDescription: input.customerDescription,
quantity,
price: ppu,
cost: cpu,
taxableFlag,
dropshipFlag: false,
billableOption: "Billable",
};
const opportunity = await opportunities.fetchRecord(identifier);
const [created] = await opportunity.addProcurementProducts(payload);
const fields = Array.isArray(created?.customFields)
? created.customFields
: [];
const procurementNotes =
fields.find((f: any) => f?.id === 29)?.value ?? null;
const productNarrative =
fields.find((f: any) => f?.id === 46)?.value ?? null;
const response = apiResponse.created(
"Labor added to opportunity successfully!",
{
id: created?.id ?? null,
forecastDetailId: created?.forecastDetailId ?? null,
laborStyle: input.laborStyle,
customerType,
catalogItem: {
id: selectedCatalog.cwCatalogId,
identifier: selectedCatalog.identifier,
name: selectedCatalog.name,
},
description: created?.description ?? payload.description,
customerDescription:
created?.customerDescription ?? input.customerDescription ?? null,
quantity: created?.quantity ?? quantity,
rate: ppu,
ppu,
cpu,
revenue: roundMoney((created?.quantity ?? quantity) * ppu),
cost: roundMoney((created?.quantity ?? quantity) * cpu),
taxableFlag: created?.taxableFlag ?? taxableFlag,
procurementNotes,
productNarrative,
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
);
@@ -0,0 +1,134 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { procurement } from "../../../../../managers/procurement";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod";
const specialOrderItemSchema = z
.object({
desc: z.string().min(1),
customerDesc: z.string().min(1).optional(),
qty: z.number().positive().optional(),
price: z.number(),
cost: z.number().optional(),
taxable: z.boolean().optional(),
taxableFlag: z.boolean().optional(),
procurementNotes: z.string().optional(),
productNarrative: z.string().optional(),
})
.strict();
const addSpecialOrderSchema = z.union([
specialOrderItemSchema,
z
.array(specialOrderItemSchema)
.min(1, "At least one special-order product is required"),
]);
/* POST /v1/sales/opportunities/:identifier/products/special-order */
export default createRoute(
"post",
["/opportunities/:identifier/products/special-order"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const validated = addSpecialOrderSchema.parse(body);
const inputItems = Array.isArray(validated) ? validated : [validated];
const specialOrderCatalogItem =
await procurement.fetchItem("SPECIAL ORDER");
const makeCustomField = (
caption: string,
value: string,
fieldId: number,
) => ({
id: fieldId,
caption,
type: "Text",
entryMethod: "EntryField",
value,
});
const normalizedItems = inputItems.map((item) => ({
...(item.procurementNotes || item.productNarrative
? {
customFields: [
...(item.procurementNotes
? [
makeCustomField(
"Procurement Notes",
item.procurementNotes,
29,
),
]
: []),
...(item.productNarrative
? [
makeCustomField(
"Product Narrative",
item.productNarrative,
46,
),
]
: []),
],
}
: {}),
catalogItem: { id: specialOrderCatalogItem.cwCatalogId },
description: item.desc,
customerDescription: item.customerDesc,
quantity: item.qty ?? 1,
price: item.price,
cost: item.cost,
taxableFlag:
item.taxable ??
item.taxableFlag ??
specialOrderCatalogItem.salesTaxable,
dropshipFlag: false,
billableOption: "Billable",
}));
const opportunity = await opportunities.fetchRecord(identifier);
const created = await opportunity.addProcurementProducts(normalizedItems);
const serialized = created.map((item: any) => {
const fields = Array.isArray(item?.customFields) ? item.customFields : [];
const procurementNotes =
fields.find((f: any) => f?.id === 29)?.value ?? null;
const productNarrative =
fields.find((f: any) => f?.id === 46)?.value ?? null;
return {
id: item?.id ?? null,
forecastDetailId: item?.forecastDetailId ?? null,
description: item?.description ?? null,
productDescription: item?.description ?? null,
customerDescription: item?.customerDescription ?? null,
quantity: item?.quantity ?? null,
price: item?.price ?? null,
revenue: item?.price ?? null,
cost: item?.cost ?? null,
taxableFlag: item?.taxableFlag ?? null,
specialOrderFlag: item?.specialOrderFlag ?? null,
procurementNotes,
productNarrative,
};
});
const isBatch = Array.isArray(body);
const response = apiResponse.created(
isBatch
? `${created.length} special-order product(s) added successfully!`
: "Special-order product added successfully!",
isBatch ? serialized : serialized[0]!,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({
permissions: ["sales.opportunity.product.add.specialOrder"],
}),
);
@@ -0,0 +1,84 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
import { z } from "zod";
const cancelProductSchema = z
.object({
quantityCancelled: z.number().int().min(0),
cancellationReason: z.string().nullable().optional(),
})
.strict();
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/cancel */
export default createRoute(
"patch",
["/opportunities/:identifier/products/:productId/cancel"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
const body = await c.req.json();
if (!Number.isInteger(productId) || productId <= 0) {
throw new GenericError({
status: 400,
name: "InvalidProductId",
message: "productId must be a positive integer",
});
}
const input = cancelProductSchema.parse(body);
const opportunity = await opportunities.fetchRecord(identifier);
const products = await opportunity.fetchProducts();
const product = products.find((item) => item.cwForecastId === productId);
if (!product) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
message: `Forecast item ${productId} not found on opportunity`,
});
}
const quantity = product.quantity ?? 0;
if (input.quantityCancelled > quantity) {
throw new GenericError({
status: 400,
name: "InvalidCancelledQuantity",
message: `quantityCancelled cannot exceed product quantity (${quantity})`,
});
}
await opportunity.setProductCancellation(productId, {
quantityCancelled: input.quantityCancelled,
cancellationReason: input.cancellationReason,
});
const refreshedProducts = await opportunity.fetchProducts({ fresh: true });
const updated = refreshedProducts.find(
(item) => item.cwForecastId === productId,
);
if (!updated) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
message: `Forecast item ${productId} not found on opportunity`,
});
}
const response = apiResponse.successful(
input.quantityCancelled === 0
? "Product uncancelled successfully!"
: "Product cancellation updated successfully!",
updated.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
);
@@ -1,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/products */
export default createRoute(
@@ -0,0 +1,51 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { procurement } from "../../../../../managers/procurement";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/products/labor/options */
export default createRoute(
"get",
["/opportunities/:identifier/products/labor/options"],
async (c) => {
const identifier = c.req.param("identifier");
await opportunities.fetchRecord(identifier);
const laborCatalog = await procurement.fetchLaborCatalogItems();
const response = apiResponse.successful(
"Labor product options fetched successfully!",
{
defaults: {
customerType: "corporate",
rates: {
corporate: 100,
residential: 85,
},
cpuMultiplier: 0.5,
quantity: 1,
},
options: {
field: {
cwCatalogId: laborCatalog.field.cwCatalogId,
identifier: laborCatalog.field.identifier,
name: laborCatalog.field.name,
taxableFlag: laborCatalog.field.salesTaxable,
},
tech: {
cwCatalogId: laborCatalog.tech.cwCatalogId,
identifier: laborCatalog.tech.identifier,
name: laborCatalog.tech.name,
taxableFlag: laborCatalog.tech.salesTaxable,
},
},
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
);
@@ -1,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */
@@ -0,0 +1,242 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
import { z } from "zod";
const PRODUCT_NARRATIVE_FIELD_ID = 46;
const PROCUREMENT_NOTES_FIELD_ID = 29;
const updateProductSchema = z
.object({
productDescription: z.string().min(1).optional(),
quantity: z.number().positive().optional(),
unitPrice: z.number().min(0).optional(),
unitCost: z.number().min(0).optional(),
customerDescription: z.string().nullable().optional(),
productNarrative: z.string().nullable().optional(),
procurementNotes: z.string().nullable().optional(),
})
.strict()
.refine(
(value) =>
Object.values(value).some((item) => item !== undefined && item !== null),
"At least one editable field is required",
);
const upsertCustomTextField = (
fields: Array<Record<string, unknown>>,
fieldId: number,
caption: string,
value: string,
) => {
const next = [...fields];
const idx = next.findIndex((f) => Number(f.id) === fieldId);
const field = {
id: fieldId,
caption,
type: "Text",
entryMethod: "EntryField",
value,
};
if (idx === -1) {
next.push(field);
return next;
}
next[idx] = {
...next[idx],
...field,
};
return next;
};
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/edit */
export default createRoute(
"patch",
["/opportunities/:identifier/products/:productId/edit"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
const body = await c.req.json();
if (!Number.isInteger(productId) || productId <= 0) {
throw new GenericError({
status: 400,
name: "InvalidProductId",
message: "productId must be a positive integer",
});
}
const input = updateProductSchema.parse(body);
const opportunity = await opportunities.fetchRecord(identifier);
const forecastItems = await opportunity.fetchProducts();
const forecastItem = forecastItems.find(
(item) => item.cwForecastId === productId,
);
if (!forecastItem) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
message: `Forecast item ${productId} not found on opportunity`,
});
}
const forecastJson = forecastItem.toJson();
const effectiveQuantity = input.quantity ?? forecastJson.quantity ?? 1;
const forecastPatch: Record<string, unknown> = {};
if (input.productDescription !== undefined) {
forecastPatch.productDescription = input.productDescription;
}
if (input.quantity !== undefined) {
forecastPatch.quantity = input.quantity;
}
if (
input.customerDescription !== undefined &&
input.customerDescription !== null
) {
forecastPatch.customerDescription = input.customerDescription;
}
if (input.unitPrice !== undefined) {
forecastPatch.revenue = Number(
(input.unitPrice * effectiveQuantity).toFixed(2),
);
}
if (input.unitCost !== undefined) {
forecastPatch.cost = Number(
(input.unitCost * effectiveQuantity).toFixed(2),
);
}
const existingProcurement =
await opportunity.fetchProcurementProductByForecastItem(productId);
if (
(input.productNarrative !== undefined ||
input.procurementNotes !== undefined) &&
!existingProcurement
) {
throw new GenericError({
status: 400,
name: "ProcurementLinkRequired",
message:
"Product Narrative and Procurement Notes can only be updated on products linked to a procurement record",
});
}
let updatedProcurement = existingProcurement;
if (existingProcurement) {
const procurementPatch: Record<string, unknown> = {};
if (input.productDescription !== undefined) {
procurementPatch.description = input.productDescription;
}
if (input.quantity !== undefined) {
procurementPatch.quantity = input.quantity;
}
if (input.unitPrice !== undefined) {
procurementPatch.price = input.unitPrice;
}
if (input.unitCost !== undefined) {
procurementPatch.cost = input.unitCost;
}
if (
input.customerDescription !== undefined &&
input.customerDescription !== null
) {
procurementPatch.customerDescription = input.customerDescription;
}
const existingFields = Array.isArray(existingProcurement.customFields)
? existingProcurement.customFields.map((field) => ({ ...field }))
: [];
let updatedFields = existingFields as Array<Record<string, unknown>>;
if (
input.procurementNotes !== undefined &&
input.procurementNotes !== null
) {
updatedFields = upsertCustomTextField(
updatedFields,
PROCUREMENT_NOTES_FIELD_ID,
"Procurement Notes",
input.procurementNotes,
);
}
if (
input.productNarrative !== undefined &&
input.productNarrative !== null
) {
updatedFields = upsertCustomTextField(
updatedFields,
PRODUCT_NARRATIVE_FIELD_ID,
"Product Narrative",
input.productNarrative,
);
}
if (
(input.procurementNotes !== undefined &&
input.procurementNotes !== null) ||
(input.productNarrative !== undefined &&
input.productNarrative !== null)
) {
procurementPatch.customFields = updatedFields;
}
if (Object.keys(procurementPatch).length > 0) {
updatedProcurement =
await opportunity.updateProcurementProductByForecastItem(
productId,
procurementPatch,
);
}
}
let updatedForecast = forecastJson;
if (Object.keys(forecastPatch).length > 0) {
const patched = await opportunity.updateProduct(productId, forecastPatch);
updatedForecast = patched.toJson();
}
const updatedFields = Array.isArray(updatedProcurement?.customFields)
? updatedProcurement.customFields
: [];
const procurementNotes =
updatedFields.find(
(field: any) => field?.id === PROCUREMENT_NOTES_FIELD_ID,
)?.value ?? null;
const productNarrative =
updatedFields.find(
(field: any) => field?.id === PRODUCT_NARRATIVE_FIELD_ID,
)?.value ?? null;
const quantity =
updatedProcurement?.quantity ?? updatedForecast.quantity ?? null;
const unitPrice = updatedProcurement?.price ?? null;
const unitCost = updatedProcurement?.cost ?? null;
const response = apiResponse.successful("Product updated successfully!", {
...updatedForecast,
productDescription:
updatedProcurement?.description ?? updatedForecast.productDescription,
customerDescription:
updatedProcurement?.customerDescription ??
updatedForecast.customerDescription ??
null,
quantity,
unitPrice,
unitCost,
procurementNotes,
productNarrative,
});
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
);
@@ -0,0 +1,39 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod";
const commitQuoteSchema = z
.object({
lineItemPricing: z.boolean().optional(),
includeQuoteNarrative: z.boolean().optional(),
includeItemNarratives: z.boolean().optional(),
})
.strict()
.optional();
/* POST /v1/sales/opportunities/:identifier/quote/commit */
export default createRoute(
"post",
["/opportunities/:identifier/quote/commit"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json().catch(() => undefined);
const opts = commitQuoteSchema.parse(body);
const item = await opportunities.fetchRecord(identifier);
const user = c.get("user");
const quote = await item.commitQuote(opts ?? {}, user);
const response = apiResponse.created(
"Quote committed successfully!",
quote.toJson({ includeRegenData: true, includeRegenParams: true }),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.commit"] }),
);
@@ -0,0 +1,55 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { injectPdfMetadata } from "../../../../../modules/pdf-utils";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
const VALID_FETCH_ACTIONS = ["download", "print"] as const;
type FetchAction = (typeof VALID_FETCH_ACTIONS)[number];
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/download?fetchAction=download|print */
export default createRoute(
"get",
["/opportunities/:identifier/quote/:quoteId/download"],
async (c) => {
const quoteId = c.req.param("quoteId");
const user = c.get("user");
const fetchAction = c.req.query("fetchAction") as FetchAction | undefined;
if (!fetchAction || !VALID_FETCH_ACTIONS.includes(fetchAction)) {
throw new GenericError({
status: 400,
name: "InvalidFetchAction",
message: `Query parameter 'fetchAction' is required and must be one of: ${VALID_FETCH_ACTIONS.join(", ")}`,
});
}
const downloadedAt = new Date().toISOString();
const quote = await generatedQuotes.recordDownload(quoteId, {
id: user.id,
name: user.name,
email: user.email,
fetchAction,
});
// Inject download-time metadata into the PDF's document properties
const pdfWithMetadata = await injectPdfMetadata(quote.quoteFile, {
downloadedAt,
downloadedById: user.id,
downloadedByName: user.name ?? undefined,
downloadedByEmail: user.email ?? undefined,
});
const response = apiResponse.successful("Quote downloaded successfully!", {
id: quote.id,
quoteFileName: quote.quoteFileName,
mimeType: "application/pdf",
contentBase64: Buffer.from(pdfWithMetadata).toString("base64"),
});
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.download"] }),
);
@@ -0,0 +1,27 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/quotes */
export default createRoute(
"get",
["/opportunities/:identifier/quotes"],
async (c) => {
const identifier = c.req.param("identifier");
const includeRegenData = c.req.query("includeRegenData") === "true";
const includeRegenParams = c.req.query("includeRegenParams") === "true";
const item = await opportunities.fetchRecord(identifier);
const quotes = await generatedQuotes.fetchByOpportunity(item.id);
const response = apiResponse.successful(
"Committed quotes fetched successfully!",
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams })),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.fetch"] }),
);
@@ -0,0 +1,33 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/quotes/downloads */
export default createRoute(
"get",
["/opportunities/:identifier/quotes/downloads"],
async (c) => {
const identifier = c.req.param("identifier");
const opportunity = await opportunities.fetchRecord(identifier);
const quotes = await generatedQuotes.fetchByOpportunity(opportunity.id);
const data = quotes.map((quote) => ({
quoteId: quote.id,
quoteFileName: quote.quoteFileName,
createdById: quote.createdById,
createdAt: quote.createdAt,
downloads: quote.downloads,
}));
const response = apiResponse.successful(
"Quote download history fetched successfully!",
data,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.fetch_downloads"] }),
);
@@ -0,0 +1,61 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/preview */
export default createRoute(
"get",
["/opportunities/:identifier/quote/:quoteId/preview"],
async (c) => {
const identifier = c.req.param("identifier");
const quoteId = c.req.param("quoteId");
const item = await opportunities.fetchRecord(identifier);
const quote = await generatedQuotes.fetch(quoteId);
const regenData =
quote.quoteRegenData && typeof quote.quoteRegenData === "object"
? (quote.quoteRegenData as Record<string, unknown>)
: {};
const options =
regenData.options && typeof regenData.options === "object"
? (regenData.options as Record<string, unknown>)
: {};
const creator = await quote.fetchCreatedBy();
const previewBuffer = await item.generateQuote({
lineItemPricing: options.lineItemPricing as boolean | undefined,
includeQuoteNarrative: options.includeQuoteNarrative as
| boolean
| undefined,
includeItemNarratives: options.includeItemNarratives as
| boolean
| undefined,
showPreview: true,
metadata: {
quoteId: quote.id,
createdById: quote.createdById ?? undefined,
createdByName: creator?.name ?? undefined,
createdByEmail: creator?.email ?? undefined,
createdAt: quote.createdAt?.toISOString(),
},
});
const previewBase64 = Buffer.from(previewBuffer).toString("base64");
const response = apiResponse.successful(
"Quote preview generated successfully!",
{
mimeType: "application/pdf",
contentBase64: previewBase64,
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.preview"] }),
);
@@ -1,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { authMiddleware } from "../../../middleware/authorization";
/* POST /v1/sales/opportunities/:identifier/refresh */
export default createRoute(
@@ -0,0 +1,93 @@
import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
import GenericError from "../../../../Errors/GenericError";
import { z } from "zod";
const updateSchema = z
.object({
name: z.string().min(1).optional(),
notes: z.string().optional(),
rating: z.object({ id: z.number() }).optional(),
type: z.object({ id: z.number() }).optional(),
stage: z.object({ id: z.number() }).optional(),
status: z.object({ id: z.number() }).optional(),
priority: z.object({ id: z.number() }).optional(),
campaign: z.object({ id: z.number() }).optional(),
primarySalesRep: z.object({ id: z.number() }).optional(),
secondarySalesRep: z.object({ id: z.number() }).nullable().optional(),
company: z.object({ id: z.number() }).optional(),
contact: z.object({ id: z.number() }).nullable().optional(),
site: z.object({ id: z.number() }).nullable().optional(),
expectedCloseDate: z
.string()
.optional()
.transform((v) => (v ? new Date(v).toISOString() : v)),
customerPO: z.string().nullable().optional(),
source: z.string().nullable().optional(),
locationId: z.number().optional(),
businessUnitId: z.number().optional(),
})
.refine((d) => Object.values(d).some((v) => v !== undefined), {
message: "At least one field must be provided",
});
/* PATCH /v1/sales/opportunities/:identifier */
export default createRoute(
"patch",
["/opportunities/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const data = updateSchema.parse(body);
const item = await opportunities.fetchRecord(identifier);
try {
const updated = await item.updateOpportunity(data);
const response = apiResponse.successful(
"Opportunity updated successfully!",
updated.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
const isAxios =
err != null && typeof err === "object" && "isAxiosError" in err;
if (isAxios) {
const axiosErr = err as any;
const cwStatus: number = axiosErr.response?.status ?? 502;
const cwData = axiosErr.response?.data;
const cwMessage: string =
cwData?.message ?? "Failed to update the opportunity in ConnectWise";
const cwErrors: unknown[] | undefined = Array.isArray(cwData?.errors)
? cwData.errors
: undefined;
return c.json(
{
status: cwStatus,
message: cwMessage,
error: "ConnectWiseUpdateError",
successful: false,
errors: cwErrors,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
);
}
throw new GenericError({
status: 500,
name: "OpportunitySaveError",
message: "Failed to save opportunity data",
cause: err instanceof Error ? err.message : String(err),
});
}
},
authMiddleware({ permissions: ["sales.opportunity.update"] }),
);
@@ -1,8 +1,8 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { opportunities } from "../../managers/opportunities";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { authMiddleware } from "../../middleware/authorization";
/* GET /v1/sales/opportunities/count */
export default createRoute(
+86
View File
@@ -0,0 +1,86 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { z } from "zod";
const createSchema = z.object({
name: z.string().min(1),
expectedCloseDate: z
.string()
.min(1)
.transform((v) => new Date(v).toISOString().replace(/\.\d{3}Z$/, "Z")),
notes: z.string().optional(),
rating: z.object({ id: z.number() }).optional(),
type: z.object({ id: z.number() }).optional(),
stage: z.object({ id: z.number() }).optional(),
status: z.object({ id: z.number() }).optional(),
priority: z.object({ id: z.number() }).optional(),
campaign: z.object({ id: z.number() }).optional(),
primarySalesRep: z.object({ id: z.number() }),
secondarySalesRep: z.object({ id: z.number() }).nullable().optional(),
company: z.object({ id: z.number() }),
contact: z.object({ id: z.number() }),
site: z.object({ id: z.number() }).nullable().optional(),
source: z.string().nullable().optional(),
customerPO: z.string().nullable().optional(),
locationId: z.number().optional(),
businessUnitId: z.number().optional(),
});
/* POST /v1/sales/opportunities */
export default createRoute(
"post",
["/opportunities"],
async (c) => {
const body = await c.req.json();
const data = createSchema.parse(body);
try {
const item = await opportunities.createItem(data);
const response = apiResponse.created(
"Opportunity created successfully!",
item.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
const isAxios =
err != null && typeof err === "object" && "isAxiosError" in err;
if (isAxios) {
const axiosErr = err as any;
const cwStatus: number = axiosErr.response?.status ?? 502;
const cwData = axiosErr.response?.data;
const cwMessage: string =
cwData?.message ?? "Failed to create the opportunity in ConnectWise";
const cwErrors: unknown[] | undefined = Array.isArray(cwData?.errors)
? cwData.errors
: undefined;
return c.json(
{
status: cwStatus,
message: cwMessage,
error: "ConnectWiseCreateError",
successful: false,
errors: cwErrors,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
);
}
throw new GenericError({
status: 500,
name: "OpportunityCreateError",
message: "Failed to create opportunity",
cause: err instanceof Error ? err.message : String(err),
});
}
},
authMiddleware({ permissions: ["sales.opportunity.create"] }),
);
@@ -1,9 +1,9 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { opportunities } from "../../managers/opportunities";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* GET /v1/sales/opportunities */
export default createRoute(
+102
View File
@@ -0,0 +1,102 @@
import { Socket } from "socket.io";
import { attachSocketEventPermissions } from "../middleware/authorization";
import { opportunities } from "../../../managers/opportunities";
const LIVE_QUOTE_PREVIEW_PERMISSION = "sales.opportunity.fetch";
export const registerLiveQuotePreviewHandlers = (socket: Socket) => {
attachSocketEventPermissions(socket, {
"opp:live_quote_preview": [LIVE_QUOTE_PREVIEW_PERMISSION],
});
const registeredLivePreviewEvents = new Set<string>();
socket.on(
"opp:live_quote_preview",
async (
payload: { id?: string | number },
ack?: (response: { ok: boolean; event?: string; error?: string }) => void,
) => {
const oppId = payload?.id;
const normalizedId =
typeof oppId === "string" || typeof oppId === "number"
? `${oppId}`
: "";
if (!normalizedId) {
if (ack) return ack({ ok: false, error: "Missing opportunity id" });
socket.emit("opp:live_quote_preview:error", {
message: "Missing opportunity id",
});
return;
}
const dataEvent = `opp:live_quote_preview:${normalizedId}:data`;
const previewEvent = `opp:live_quote_preview:${normalizedId}:preview`;
const roomName = `opp:live_quote_preview:${normalizedId}`;
if (!registeredLivePreviewEvents.has(dataEvent)) {
registeredLivePreviewEvents.add(dataEvent);
socket.join(roomName);
socket.on(dataEvent, async (data: any) => {
socket.to(roomName).emit(dataEvent, data);
try {
const opportunity = await opportunities.fetchRecord(normalizedId);
const opts =
data?.options && typeof data.options === "object"
? data.options
: data;
const previewBuffer = await opportunity.generateQuote({
lineItemPricing: opts?.lineItemPricing,
includeQuoteNarrative: opts?.includeQuoteNarrative,
includeItemNarratives: opts?.includeItemNarratives,
logoPath: opts?.logoPath,
showPreview: true,
});
const previewBase64 = Buffer.from(previewBuffer).toString("base64");
socket.to(roomName).emit(previewEvent, {
id: normalizedId,
mimeType: "application/pdf",
contentBase64: previewBase64,
});
socket.to(roomName).emit(dataEvent, {
id: normalizedId,
mimeType: "application/pdf",
contentBase64: previewBase64,
});
socket.emit(previewEvent, {
id: normalizedId,
mimeType: "application/pdf",
contentBase64: previewBase64,
});
socket.emit(dataEvent, {
id: normalizedId,
mimeType: "application/pdf",
contentBase64: previewBase64,
});
} catch (err: any) {
socket.emit("opp:live_quote_preview:error", {
message: err?.message ?? "Failed to generate live quote preview",
id: normalizedId,
});
}
});
}
if (ack) return ack({ ok: true, event: dataEvent });
socket.emit("opp:live_quote_preview:ready", {
id: normalizedId,
event: dataEvent,
});
},
);
};
+5
View File
@@ -0,0 +1,5 @@
import { setupSecureNamespace } from "./secure";
export const setupSockets = () => {
setupSecureNamespace();
};
@@ -0,0 +1,71 @@
import { Socket } from "socket.io";
import UserController from "../../../controllers/UserController";
type SecureSocket = Socket & {
data: {
user?: UserController;
[key: string]: unknown;
};
};
export const attachSocketEventPermissions = (
socket: Socket,
eventPermissions: Record<string, string[]>,
): boolean => {
const user = (socket.data?.user as UserController | undefined) ?? undefined;
if (!user) return false;
socket.use(async (packet, packetNext) => {
const eventName = packet[0];
if (typeof eventName !== "string") return packetNext();
const eventRequiredPermissions = eventPermissions[eventName] ?? [];
if (eventRequiredPermissions.length === 0) return packetNext();
const eventChecks = await Promise.all(
eventRequiredPermissions.map((permission) =>
user.hasPermission(permission),
),
);
if (eventChecks.includes(false)) {
return packetNext(new Error("Forbidden: insufficient permissions"));
}
return packetNext();
});
return true;
};
export const socketAuthMiddleware = (permParams?: {
permissions?: string[];
eventPermissions?: Record<string, string[]>;
}) => {
return async (socket: SecureSocket, next: (err?: Error) => void) => {
const user = socket.data.user;
if (!user) return next(new Error("Unauthorized"));
const requiredPermissions = permParams?.permissions ?? [];
if (requiredPermissions.length > 0) {
const permissionChecks = await Promise.all(
requiredPermissions.map((permission) => user.hasPermission(permission)),
);
if (permissionChecks.includes(false)) {
return next(new Error("Forbidden: insufficient permissions"));
}
}
const eventPermissions = permParams?.eventPermissions;
if (eventPermissions) {
const attached = attachSocketEventPermissions(socket, eventPermissions);
if (!attached) return next(new Error("Unauthorized"));
}
return next();
};
};
+128
View File
@@ -0,0 +1,128 @@
import { Namespace } from "socket.io";
import { io, prisma } from "../../constants";
import { sessions } from "../../managers/sessions";
import { socketAuthMiddleware } from "./middleware/authorization";
import { registerLiveQuotePreviewHandlers } from "./events/liveQuotePreview";
const SESSION_ENFORCEMENT_INTERVAL_MS = 60 * 1000;
const MAX_TIMEOUT_MS = 2_147_483_647;
const AUTH_HEADER_REGEX =
/^(Bearer|Key)\s([a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+)$/;
const resolveAuthorization = (handshake: {
auth?: Record<string, unknown>;
headers?: Record<string, unknown>;
}): string | null => {
const headerAuth = handshake.headers?.authorization;
if (typeof headerAuth === "string" && headerAuth.length > 0)
return headerAuth;
const authAuthorization = handshake.auth?.authorization;
if (typeof authAuthorization === "string" && authAuthorization.length > 0)
return authAuthorization;
const authToken = handshake.auth?.token;
if (typeof authToken === "string" && authToken.length > 0)
return `Bearer ${authToken}`;
return null;
};
export const setupSecureNamespace = (): Namespace => {
const secureNamespace = io.of("/secure");
secureNamespace.use(async (socket, next) => {
try {
const authorization = resolveAuthorization(socket.handshake as any);
if (!authorization)
return next(new Error("Unauthorized: missing authorization"));
const components = authorization.match(AUTH_HEADER_REGEX);
if (!components)
return next(new Error("Unauthorized: invalid authorization format"));
const authValue = components[2] ?? "";
const session = await sessions.fetch({ accessToken: authValue });
const user = await session.fetchUser();
socket.data.user = user;
socket.data.session = session;
return next();
} catch {
return next(new Error("Unauthorized"));
}
});
secureNamespace.use(socketAuthMiddleware());
secureNamespace.on("connection", (socket) => {
const sessionId = socket.data.session?.id as string | undefined;
const sessionExpiresAt = socket.data.session?.expires
? new Date(socket.data.session.expires).getTime()
: null;
const disconnectForSession = () => {
if (socket.disconnected) return;
socket.emit("secure:session:expired");
socket.disconnect(true);
};
let expiryTimeout: ReturnType<typeof setTimeout> | null = null;
const scheduleExpiryDisconnect = () => {
if (sessionExpiresAt === null) return;
const remainingMs = sessionExpiresAt - Date.now();
if (remainingMs <= 0) {
disconnectForSession();
return;
}
const delayMs = Math.min(remainingMs, MAX_TIMEOUT_MS);
expiryTimeout = setTimeout(scheduleExpiryDisconnect, delayMs);
};
scheduleExpiryDisconnect();
const interval = setInterval(async () => {
if (!sessionId) {
disconnectForSession();
return;
}
const session = await prisma.session.findFirst({
where: { id: sessionId },
select: { id: true, expires: true, invalidatedAt: true },
});
if (!session) {
disconnectForSession();
return;
}
if (session.invalidatedAt) {
disconnectForSession();
return;
}
if (session.expires.getTime() <= Date.now()) {
disconnectForSession();
}
}, SESSION_ENFORCEMENT_INTERVAL_MS);
socket.on("disconnect", () => {
clearInterval(interval);
if (expiryTimeout) clearTimeout(expiryTimeout);
});
registerLiveQuotePreviewHandlers(socket);
socket.emit("secure:connected", {
userId: socket.data.user?.id ?? null,
});
});
return secureNamespace;
};
+86
View File
@@ -0,0 +1,86 @@
import type { CwMember } from "../../generated/prisma/client";
import type { CWMember } from "../modules/cw-utils/members/fetchAllMembers";
/**
* CW Member Controller
*
* Domain model class that encapsulates a ConnectWise Member entity,
* providing access to member data and serialization for the API.
*/
export class CwMemberController {
public readonly id: string;
public readonly cwMemberId: number;
public readonly identifier: string;
public firstName: string;
public lastName: string;
public officeEmail: string | null;
public inactiveFlag: boolean;
public apiKey: string | null;
public cwLastUpdated: Date | null;
public readonly createdAt: Date;
public readonly updatedAt: Date;
constructor(data: CwMember) {
this.id = data.id;
this.cwMemberId = data.cwMemberId;
this.identifier = data.identifier;
this.firstName = data.firstName;
this.lastName = data.lastName;
this.officeEmail = data.officeEmail;
this.inactiveFlag = data.inactiveFlag;
this.apiKey = data.apiKey;
this.cwLastUpdated = data.cwLastUpdated;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
/**
* Full Name
*
* Returns the member's full name, falling back to the identifier.
*/
public get fullName(): string {
const name = `${this.firstName} ${this.lastName}`.trim();
return name || this.identifier;
}
/**
* Map CW Member Prisma create/update payload
*
* Static helper used by both the controller and the refresh sync.
*/
public static mapCwToDb(item: CWMember) {
return {
identifier: item.identifier,
firstName: item.firstName ?? "",
lastName: item.lastName ?? "",
officeEmail: item.officeEmail ?? null,
inactiveFlag: item.inactiveFlag ?? false,
cwLastUpdated: item._info?.lastUpdated
? new Date(item._info.lastUpdated)
: new Date(),
};
}
/**
* To JSON
*
* Serializes the member into a safe, API-friendly object.
*/
public toJson(): Record<string, any> {
return {
id: this.id,
cwMemberId: this.cwMemberId,
identifier: this.identifier,
firstName: this.firstName,
lastName: this.lastName,
fullName: this.fullName,
officeEmail: this.officeEmail,
inactiveFlag: this.inactiveFlag,
apiKey: this.apiKey,
cwLastUpdated: this.cwLastUpdated,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}
@@ -23,6 +23,8 @@ export class ForecastProductController {
public catalogItemIdentifier: string | null;
public productDescription: string;
public customerDescription: string | null;
public productNarrative: string | null;
public productClass: string;
public forecastType: string;
@@ -74,6 +76,9 @@ export class ForecastProductController {
this.catalogItemIdentifier = data.catalogItem?.identifier ?? null;
this.productDescription = data.productDescription;
this.customerDescription = data.customerDescription ?? null;
this.productNarrative =
data.customFields?.find((f) => f.id === 46)?.value?.toString() ?? null;
this.productClass = data.productClass;
this.forecastType = data.forecastType;
@@ -118,6 +123,24 @@ export class ForecastProductController {
* Enriches this forecast product with cancellation data from the
* procurement products endpoint.
*/
/**
* Apply Procurement Custom Fields
*
* Enriches this forecast product with custom field data from the
* procurement products endpoint (the forecast endpoint does not
* return customFields).
*/
public applyProcurementCustomFields(data: {
customFields?: Array<{ id: number; value?: unknown }>;
}): void {
const narrative = data.customFields
?.find((f) => f.id === 46)
?.value?.toString();
if (narrative) {
this.productNarrative = narrative;
}
}
public applyCancellationData(data: {
cancelledFlag?: boolean;
quantityCancelled?: number;
@@ -154,6 +177,38 @@ export class ForecastProductController {
return this.revenue - this.cost;
}
/**
* Effective Quantity
*
* Returns the quantity adjusted for cancellations (minimum 0).
*/
public get effectiveQuantity(): number {
if (this.cancellationType === "full") return 0;
return Math.max(0, this.quantity - this.quantityCancelled);
}
/**
* Effective Revenue
*
* Returns the revenue adjusted proportionally for cancelled units.
*/
public get effectiveRevenue(): number {
if (this.cancellationType === "full" || this.quantity <= 0) return 0;
const unitPrice = this.revenue / this.quantity;
return unitPrice * this.effectiveQuantity;
}
/**
* Effective Cost
*
* Returns the cost adjusted proportionally for cancelled units.
*/
public get effectiveCost(): number {
if (this.cancellationType === "full" || this.quantity <= 0) return 0;
const unitCost = this.cost / this.quantity;
return unitCost * this.effectiveQuantity;
}
/**
* Cancelled
*
@@ -201,12 +256,17 @@ export class ForecastProductController {
? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier }
: null,
productDescription: this.productDescription,
customerDescription: this.customerDescription,
productNarrative: this.productNarrative,
productClass: this.productClass,
forecastType: this.forecastType,
revenue: this.revenue,
cost: this.cost,
margin: this.margin,
profit: this.profit,
effectiveQuantity: this.effectiveQuantity,
effectiveRevenue: this.effectiveRevenue,
effectiveCost: this.effectiveCost,
percentage: this.percentage,
includeFlag: this.includeFlag,
linkFlag: this.linkFlag,
+131
View File
@@ -0,0 +1,131 @@
import {
GeneratedQuotes,
Opportunity,
Role,
User,
} from "../../generated/prisma/client";
import { prisma } from "../constants";
import { OpportunityController } from "./OpportunityController";
import UserController from "./UserController";
export class GeneratedQuoteController {
public readonly id: string;
public quoteRegenData: unknown;
public quoteRegenParams: unknown;
public quoteRegenHash: string;
public downloads: unknown[];
public quoteFile: Uint8Array;
public quoteFileName: string;
public opportunityId: string;
public createdById: string | null;
public createdAt: Date;
public updatedAt: Date;
private _opportunity: OpportunityController | null;
private _createdBy: UserController | null;
constructor(
data: GeneratedQuotes & {
opportunity?: Opportunity | null;
createdBy?: (User & { roles: Role[] }) | null;
},
) {
this.id = data.id;
this.quoteRegenData = data.quoteRegenData;
this.quoteRegenParams = data.quoteRegenParams;
this.quoteRegenHash = data.quoteRegenHash;
this.downloads = Array.isArray(data.downloads)
? (data.downloads as unknown[])
: [];
this.quoteFile = data.quoteFile;
this.quoteFileName = data.quoteFileName;
this.opportunityId = data.opportunityId;
this.createdById = data.createdById;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
this._opportunity = data.opportunity
? new OpportunityController(data.opportunity)
: null;
this._createdBy = data.createdBy
? new UserController(data.createdBy)
: null;
}
public async fetchOpportunity(): Promise<OpportunityController | null> {
if (this._opportunity) return this._opportunity;
const opportunity = await prisma.opportunity.findFirst({
where: { id: this.opportunityId },
});
if (!opportunity) return null;
this._opportunity = new OpportunityController(opportunity);
return this._opportunity;
}
public async fetchCreatedBy(): Promise<UserController | null> {
if (this._createdBy) return this._createdBy;
if (!this.createdById) return null;
const user = await prisma.user.findFirst({
where: { id: this.createdById },
include: { roles: true },
});
if (!user) return null;
this._createdBy = new UserController(user);
return this._createdBy;
}
public toJson(opts?: {
includeFile?: boolean;
encodeFileAsBase64?: boolean;
includeRegenData?: boolean;
includeRegenParams?: boolean;
includeDownloads?: boolean;
includeOpportunity?: boolean;
includeCreatedBy?: boolean;
}): Record<string, any> {
return {
id: this.id,
quoteFileName: this.quoteFileName,
quoteRegenHash: this.quoteRegenHash,
opportunityId: this.opportunityId,
createdById: this.createdById,
downloads: opts?.includeDownloads ? this.downloads : undefined,
quoteRegenData: opts?.includeRegenData ? this.quoteRegenData : undefined,
quoteRegenParams: opts?.includeRegenParams
? this.quoteRegenParams
: undefined,
quoteFile: !opts?.includeFile
? undefined
: opts?.encodeFileAsBase64
? Buffer.from(this.quoteFile).toString("base64")
: this.quoteFile,
opportunity:
opts?.includeOpportunity && this._opportunity
? this._opportunity.toJson()
: undefined,
createdBy:
opts?.includeCreatedBy && this._createdBy
? this._createdBy.toJson()
: undefined,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}
+625 -4
View File
@@ -14,15 +14,20 @@ import {
CWForecastItemCreate,
CWOpportunity,
CWOpportunityNote,
CWOpportunityUpdate,
CWProcurementProduct,
CWProcurementProductCreate,
} from "../modules/cw-utils/opportunities/opportunity.types";
import {
resolveMember,
resolveMembers,
getMemberCache,
} from "../modules/cw-utils/members/memberCache";
import { ForecastProductController } from "./ForecastProductController";
import GenericError from "../Errors/GenericError";
import { computeSubResourceCacheTTL } from "../modules/algorithms/computeSubResourceCacheTTL";
import { computeProductsCacheTTL } from "../modules/algorithms/computeProductsCacheTTL";
import UserController from "./UserController";
import {
getCachedNotes,
getCachedContacts,
@@ -35,6 +40,11 @@ import {
invalidateNotesCache,
invalidateProductsCache,
} from "../modules/cache/opportunityCache";
import {
generateQuote as generateQuotePdf,
type QuoteMetadata,
} from "../modules/pdf-utils";
import { generatedQuotes } from "../managers/generatedQuotes";
/**
* Opportunity Controller
@@ -79,6 +89,7 @@ export class OpportunityController {
public customerPO: string | null;
public totalSalesTax: number;
public probability: number;
public locationName: string | null;
public locationCwId: number | null;
@@ -129,6 +140,29 @@ export class OpportunityController {
});
}
/**
* Resolve primary sales rep info for quote generation.
*
* Looks up the primary sales rep in the CW member cache and returns
* their name and email. Returns undefined if no rep is assigned.
*/
private async _resolveSalesRep(): Promise<
{ name: string; email?: string } | undefined
> {
if (!this.primarySalesRepIdentifier) return undefined;
const cache = await getMemberCache();
const member = cache.get(this.primarySalesRepIdentifier);
const name = member
? `${member.firstName} ${member.lastName}`.trim() ||
this.primarySalesRepName ||
this.primarySalesRepIdentifier
: (this.primarySalesRepName ?? this.primarySalesRepIdentifier);
return {
name,
email: member?.officeEmail ?? undefined,
};
}
constructor(
data: Opportunity & { company?: Company | null },
opts?: {
@@ -172,6 +206,7 @@ export class OpportunityController {
this.customerPO = data.customerPO;
this.totalSalesTax = data.totalSalesTax;
this.probability = data.probability;
this.locationName = data.locationName;
this.locationCwId = data.locationCwId;
@@ -201,6 +236,18 @@ export class OpportunityController {
this._activities = opts?.activities ?? null;
}
/**
* Hydrate Custom Fields
*
* Lazily fetches the opportunity's custom fields from ConnectWise
* if they haven't been loaded yet.
*/
private async _hydrateCustomFields(): Promise<void> {
if (this._customFields !== null) return;
const cwData = await fetchOpportunity(this.cwOpportunityId);
this._customFields = cwData.customFields ?? [];
}
/**
* Fetch Company
*
@@ -246,6 +293,30 @@ export class OpportunityController {
return new OpportunityController(updated);
}
/**
* Update Opportunity
*
* Patches the opportunity in ConnectWise with the provided fields,
* then syncs the updated data back to the local database.
*
* @param data Partial fields to update on the CW opportunity
* @returns A fresh OpportunityController with the updated data
*/
public async updateOpportunity(
data: CWOpportunityUpdate,
): Promise<OpportunityController> {
const cwData = await opportunityCw.update(this.cwOpportunityId, data);
const mapped = OpportunityController.mapCwToDb(cwData);
const updated = await prisma.opportunity.update({
where: { id: this.id },
data: mapped,
include: { company: true },
});
return new OpportunityController(updated);
}
/**
* Fetch raw CW data
*
@@ -295,6 +366,7 @@ export class OpportunityController {
customerPO: item.customerPO ?? null,
totalSalesTax: item.totalSalesTax ?? 0,
probability: Number(item.probability?.name) || 0,
locationName: item.location?.name ?? null,
locationCwId: item.location?.id ?? null,
@@ -534,6 +606,372 @@ export class OpportunityController {
return this._buildProductControllers(forecast, procProducts);
}
/**
* Generate Quote PDF
*
* Builds a customer-facing quote PDF using the opportunity, company, site,
* and product data available to this controller.
*/
public async generateQuote(opts?: {
lineItemPricing?: boolean;
includeQuoteNarrative?: boolean;
includeItemNarratives?: boolean;
showPreview?: boolean; // INTERNAL ONLY
logoPath?: string;
metadata?: QuoteMetadata;
}): Promise<Buffer> {
const options = {
lineItemPricing: opts?.lineItemPricing ?? true,
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
includeItemNarratives: opts?.includeItemNarratives ?? true,
showPreview: opts?.showPreview ?? false,
logoPath: opts?.logoPath,
};
const products = await this.fetchProducts();
const activeProducts = products.filter(
(item) => item.includeFlag && item.cancellationType !== "full",
);
if (activeProducts.length === 0) {
throw new GenericError({
status: 400,
name: "QuoteGenerationError",
message: "Cannot generate a quote with no included line items",
});
}
const company = await this.fetchCompany();
const companyJson = company?.toJson({
includeAddress: true,
includePrimaryContact: true,
includeAllContacts: false,
});
const site = await this.fetchSite();
const siteAddress = [
site?.address?.line1,
site?.address?.line2,
[site?.address?.city, site?.address?.state, site?.address?.zip]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const companyAddress = [
companyJson?.cw_Data?.address?.line1,
companyJson?.cw_Data?.address?.line2,
[
companyJson?.cw_Data?.address?.city,
companyJson?.cw_Data?.address?.state,
companyJson?.cw_Data?.address?.zip,
]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const addressLines = siteAddress.length > 0 ? siteAddress : companyAddress;
const lineItems = activeProducts.map((item) => {
const isLabor = item.productClass === "Service";
const quantity = item.effectiveQuantity > 0 ? item.effectiveQuantity : 1;
const lineTotal = Number.isFinite(item.revenue) ? item.revenue : 0;
const unitPrice = isLabor ? lineTotal : lineTotal / quantity;
const itemNarrative = item.productNarrative || null;
const shouldIncludeNarrative =
options.includeItemNarratives && !!itemNarrative;
return {
qty: isLabor ? 1 : quantity,
description: item.productDescription || "Line Item",
unitPrice,
narrative: shouldIncludeNarrative ? itemNarrative : undefined,
};
});
const quoteDescription = this.name;
const primaryContactFullName = [
companyJson?.cw_Data?.primaryContact?.firstName,
companyJson?.cw_Data?.primaryContact?.lastName,
]
.filter(Boolean)
.join(" ")
.trim();
const customerName =
this.contactName ||
primaryContactFullName ||
this.companyName ||
"Customer";
const subTotal = lineItems.reduce(
(sum, item) => sum + item.qty * item.unitPrice,
0,
);
const normalizedTaxRate =
subTotal > 0 ? Math.max(0, this.totalSalesTax / subTotal) : 0;
const taxLabel =
normalizedTaxRate > 0
? `Sales Tax (${(normalizedTaxRate * 100).toFixed(2)}%)`
: "Sales Tax";
await this._hydrateCustomFields();
const quoteNarrative = options.includeQuoteNarrative
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
undefined
: undefined;
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
// Only show attention if it differs from the customer name
const attention =
this.contactName && this.contactName !== customerName
? this.contactName
: undefined;
// Only show company if it's meaningfully different from the customer name
// (catches "Patterson, Diane" vs "Diane Patterson" style duplicates)
const normalise = (s: string) =>
s
.toLowerCase()
.replace(/[,.\s]+/g, " ")
.trim()
.split(" ")
.sort()
.join(" ");
const showCompany = normalise(companyLine) !== normalise(customerName);
return generateQuotePdf(
{
customer: {
name: customerName,
company: showCompany ? companyLine : undefined,
attention,
address:
addressLines.length > 0 ? addressLines : ["Address unavailable"],
},
contact: {
email: companyJson?.cw_Data?.primaryContact?.email ?? undefined,
phone: companyJson?.cw_Data?.primaryContact?.phone ?? undefined,
},
salesRep: await this._resolveSalesRep(),
quote: {
quoteNumber: this.cwOpportunityId.toString(),
date: new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}),
description: quoteDescription,
},
lineItems,
quoteNarrative,
tax: {
rate: normalizedTaxRate,
label: taxLabel,
},
isPreview: options.showPreview,
showLineItemPricing: options.lineItemPricing,
metadata: opts?.metadata,
},
{},
options.logoPath,
);
}
/**
* Commit Quote
*
* Generates a non-preview quote PDF and stores it in the GeneratedQuotes
* table with a full data snapshot for exact reproduction, regeneration
* metadata, and creator attribution.
*/
public async commitQuote(
opts: {
lineItemPricing?: boolean;
includeQuoteNarrative?: boolean;
includeItemNarratives?: boolean;
logoPath?: string;
} = {},
user: UserController,
) {
const quoteOptions = {
lineItemPricing: opts?.lineItemPricing ?? true,
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
includeItemNarratives: opts?.includeItemNarratives ?? true,
logoPath: opts?.logoPath,
};
// ── Fetch all data sources BEFORE generating ──────────────────────
const products = await this.fetchProducts();
const company = await this.fetchCompany();
const companyJson = company?.toJson({
includeAddress: true,
includePrimaryContact: true,
includeAllContacts: false,
});
const site = await this.fetchSite();
const salesRep = await this._resolveSalesRep();
await this._hydrateCustomFields();
const quoteNarrative = quoteOptions.includeQuoteNarrative
? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ??
null)
: null;
// ── Pre-generate IDs & timestamps for metadata ───────────────────
const quoteId = crypto.randomUUID();
const createdAt = new Date().toISOString();
// ── Generate the PDF ──────────────────────────────────────────────
const quoteBuffer = await this.generateQuote({
...quoteOptions,
showPreview: false,
metadata: {
quoteId,
createdById: user.id,
createdByName: user.name ?? undefined,
createdByEmail: user.email ?? undefined,
createdAt,
},
});
const fileTimestamp = createdAt.replace(/[:.]/g, "-");
const quoteFileName = `OPP-${this.cwOpportunityId}-${fileTimestamp}.pdf`;
// ── Build the full data snapshot ──────────────────────────────────
const siteAddress = [
site?.address?.line1,
site?.address?.line2,
[site?.address?.city, site?.address?.state, site?.address?.zip]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const companyAddress = [
companyJson?.cw_Data?.address?.line1,
companyJson?.cw_Data?.address?.line2,
[
companyJson?.cw_Data?.address?.city,
companyJson?.cw_Data?.address?.state,
companyJson?.cw_Data?.address?.zip,
]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const primaryContactFullName = [
companyJson?.cw_Data?.primaryContact?.firstName,
companyJson?.cw_Data?.primaryContact?.lastName,
]
.filter(Boolean)
.join(" ")
.trim();
const regenData = {
// Generation options
options: {
lineItemPricing: quoteOptions.lineItemPricing,
includeQuoteNarrative: quoteOptions.includeQuoteNarrative,
includeItemNarratives: quoteOptions.includeItemNarratives,
},
// Opportunity metadata
opportunity: {
id: this.id,
cwOpportunityId: this.cwOpportunityId,
name: this.name,
totalSalesTax: this.totalSalesTax,
contactName: this.contactName,
companyName: this.companyName,
},
// Customer / company / site snapshot
customer: {
preparedFor:
this.contactName ||
primaryContactFullName ||
this.companyName ||
"Customer",
companyName: this.companyName ?? company?.name ?? null,
primaryContact: companyJson?.cw_Data?.primaryContact
? {
firstName: companyJson.cw_Data.primaryContact.firstName ?? null,
lastName: companyJson.cw_Data.primaryContact.lastName ?? null,
email: companyJson.cw_Data.primaryContact.email ?? null,
phone: companyJson.cw_Data.primaryContact.phone ?? null,
}
: null,
siteAddress: siteAddress.length > 0 ? siteAddress : null,
companyAddress: companyAddress.length > 0 ? companyAddress : null,
},
// Sales rep snapshot
salesRep: salesRep ?? null,
// Quote narrative
quoteNarrative: quoteNarrative ?? null,
// Full product snapshot
products: products.map((p) => ({
cwForecastId: p.cwForecastId,
forecastDescription: p.forecastDescription,
productDescription: p.productDescription,
customerDescription: p.customerDescription,
productNarrative: p.productNarrative,
productClass: p.productClass,
forecastType: p.forecastType,
catalogItem: p.catalogItemCwId
? { id: p.catalogItemCwId, identifier: p.catalogItemIdentifier }
: null,
quantity: p.quantity,
effectiveQuantity: p.effectiveQuantity,
revenue: p.revenue,
cost: p.cost,
margin: p.margin,
percentage: p.percentage,
includeFlag: p.includeFlag,
taxableFlag: p.taxableFlag,
recurringFlag: p.recurringFlag,
recurringRevenue: p.recurringRevenue,
recurringCost: p.recurringCost,
sequenceNumber: p.sequenceNumber,
cancelledFlag: p.cancelledFlag,
cancellationType: p.cancellationType,
quantityCancelled: p.quantityCancelled,
cancelledReason: p.cancelledReason,
cancelledDate: p.cancelledDate,
})),
// Timestamp of when this snapshot was taken
snapshotTimestamp: new Date().toISOString(),
};
const regenParams = {
opportunityId: this.id,
cwOpportunityId: this.cwOpportunityId,
};
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(JSON.stringify({ regenData, regenParams }));
const quoteRegenHash = hasher.digest("hex");
return generatedQuotes.create({
id: quoteId,
quoteRegenData: regenData,
quoteRegenParams: regenParams,
quoteRegenHash,
quoteFile: quoteBuffer,
quoteFileName,
opportunityId: this.id,
createdById: user.id,
});
}
/**
* Build ForecastProductController[] from raw CW data.
*
@@ -547,15 +985,25 @@ export class OpportunityController {
// Build a map of forecastDetailId → procurement product cancellation data
const cancellationMap = new Map<number, Record<string, unknown>>();
for (const pp of procProducts) {
const forecastDetailId = pp.forecastDetailId as number | undefined;
if (forecastDetailId) {
const rawForecastDetailId = (pp as any)?.forecastDetailId;
const forecastDetailId =
typeof rawForecastDetailId === "number"
? rawForecastDetailId
: Number(rawForecastDetailId);
if (Number.isFinite(forecastDetailId) && forecastDetailId > 0) {
cancellationMap.set(forecastDetailId, pp);
}
}
// Procurement-only view: only include forecast items that have a
// matching procurement record (via forecastDetailId).
const forecastItems = (forecast.forecastItems ?? []).filter((fi: any) =>
cancellationMap.has(fi.id),
);
// Apply local ordering if productSequence is set, otherwise fall back
// to CW sequenceNumber.
const forecastItems = forecast.forecastItems ?? [];
let ordered: typeof forecastItems;
if (this.productSequence.length > 0) {
@@ -581,6 +1029,7 @@ export class OpportunityController {
const procData = cancellationMap.get(item.id);
if (procData) {
ctrl.applyCancellationData(procData as any);
ctrl.applyProcurementCustomFields(procData as any);
}
return ctrl;
},
@@ -770,6 +1219,41 @@ export class OpportunityController {
return this.fetchProducts();
}
/**
* Append Product Sequence IDs
*
* Adds newly created forecast item IDs to the end of the local
* productSequence array, preserving existing order and avoiding duplicates.
*/
private async appendProductSequenceIds(ids: number[]): Promise<void> {
const normalizedIds = ids.filter(
(id): id is number => Number.isInteger(id) && id > 0,
);
if (normalizedIds.length === 0) return;
const current = await prisma.opportunity.findUnique({
where: { id: this.id },
select: { productSequence: true },
});
const existing = current?.productSequence ?? [];
const existingSet = new Set(existing);
const idsToAppend = normalizedIds.filter((id) => !existingSet.has(id));
if (idsToAppend.length === 0) {
this.productSequence = existing;
return;
}
const updatedSequence = [...existing, ...idsToAppend];
await prisma.opportunity.update({
where: { id: this.id },
data: { productSequence: updatedSequence },
});
this.productSequence = updatedSequence;
}
/**
* Add Products
*
@@ -788,6 +1272,7 @@ export class OpportunityController {
this.cwOpportunityId,
data,
);
await this.appendProductSequenceIds(created.map((item) => item.id));
await invalidateProductsCache(this.cwOpportunityId);
return created.map((item) => new ForecastProductController(item));
} catch (err: any) {
@@ -816,6 +1301,141 @@ export class OpportunityController {
}
}
/**
* Add Procurement Products
*
* Creates one or more procurement products linked to this opportunity.
* Use this when payloads include procurement-only fields such as customFields.
*/
public async addProcurementProducts(
data: CWProcurementProductCreate | CWProcurementProductCreate[],
): Promise<CWProcurementProduct[]> {
try {
const items = Array.isArray(data) ? data : [data];
const normalized = items.map((item) => ({
...item,
opportunity: { id: this.cwOpportunityId },
}));
const created = await opportunityCw.createProcurementProducts(normalized);
await this.appendProductSequenceIds(
created
.map((item) => item.forecastDetailId)
.filter((id): id is number => typeof id === "number"),
);
await invalidateProductsCache(this.cwOpportunityId);
return created;
} catch (err: any) {
console.error(
`[addProcurementProducts] Failed to create procurement product(s) on opportunity ${this.cwOpportunityId}`,
JSON.stringify(
{
data,
status: err?.response?.status,
statusText: err?.response?.statusText,
responseData: err?.response?.data,
message: err?.message,
},
null,
2,
),
);
throw new GenericError({
status: err?.response?.status ?? 500,
name: "AddProcurementProductFailed",
message:
err?.response?.data?.message ??
"Failed to add procurement product(s) to opportunity",
cause: err?.message,
});
}
}
/**
* Fetch Procurement Product By Forecast Item
*
* Returns the linked procurement product for a forecast item ID,
* or null when no procurement record exists.
*/
public async fetchProcurementProductByForecastItem(
forecastItemId: number,
): Promise<CWProcurementProduct | null> {
return opportunityCw.fetchProcurementProductByForecastDetail(
this.cwOpportunityId,
forecastItemId,
);
}
/**
* Update Procurement Product By Forecast Item
*
* Finds the linked procurement product for a forecast item and updates it.
* Returns null when no linked procurement product exists.
*/
public async updateProcurementProductByForecastItem(
forecastItemId: number,
data: Record<string, unknown>,
): Promise<CWProcurementProduct | null> {
const linked =
await this.fetchProcurementProductByForecastItem(forecastItemId);
if (!linked?.id) return null;
const updated = await opportunityCw.updateProcurementProduct(
linked.id,
data,
);
await invalidateProductsCache(this.cwOpportunityId);
return updated;
}
/**
* Set Product Cancellation
*
* Updates cancellation fields on the procurement product linked to a
* forecast item. A quantity of 0 is treated as uncancelled.
*/
public async setProductCancellation(
forecastItemId: number,
opts: { quantityCancelled: number; cancellationReason?: string | null },
): Promise<CWProcurementProduct> {
const linked =
await this.fetchProcurementProductByForecastItem(forecastItemId);
if (!linked?.id) {
throw new GenericError({
status: 404,
name: "ProcurementProductNotFound",
message:
"No linked procurement product found for the specified forecast item",
});
}
const quantityCancelled = Math.max(0, Math.trunc(opts.quantityCancelled));
const cancelledFlag = quantityCancelled > 0;
const updated = await this.updateProcurementProductByForecastItem(
forecastItemId,
{
quantityCancelled,
cancelledFlag,
cancelledReason: cancelledFlag
? (opts.cancellationReason ?? null)
: null,
},
);
if (!updated) {
throw new GenericError({
status: 404,
name: "ProcurementProductNotFound",
message:
"No linked procurement product found for the specified forecast item",
});
}
return updated;
}
/**
* Add Note
*
@@ -881,7 +1501,7 @@ export class OpportunityController {
id: this.id,
cwOpportunityId: this.cwOpportunityId,
name: this.name,
notes: this.notes,
description: this.notes,
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
stage: this.stageCwId
? { id: this.stageCwId, name: this.stageName }
@@ -932,6 +1552,7 @@ export class OpportunityController {
: null,
customerPO: this.customerPO,
totalSalesTax: this.totalSalesTax,
probability: this.probability,
location: this.locationCwId
? { id: this.locationCwId, name: this.locationName }
: null,
+26 -7
View File
@@ -1,5 +1,6 @@
import { refresh } from "./api/auth";
import app from "./api/server";
import { setupSockets } from "./api/sockets";
import {
engine,
PORT,
@@ -16,6 +17,7 @@ import { listenInventoryAdjustments } from "./modules/cw-utils/procurement/liste
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
import { refreshCwMembers } from "./modules/cw-utils/members/refreshCwMembers";
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
import { events, setupEventDebugger } from "./modules/globalEvents";
import { signPermissions } from "./modules/permission-utils/signPermissions";
@@ -68,6 +70,9 @@ Bun.serve({
console.log(`[startup] Server listening on port ${PORT}`);
setupSockets();
console.log("[startup] Socket namespaces initialized");
// ---------------------------------------------------------------------------
// Background initialisation — none of this blocks the server.
// ---------------------------------------------------------------------------
@@ -149,13 +154,16 @@ setInterval(() => {
// NOTE: Do NOT await — register the interval immediately so the cache refresh
// is never blocked by a slow/stuck startup task above.
safeStartup("refreshOpportunityCache", refreshOpportunityCache);
setInterval(() => {
return refreshOpportunityCache().catch((err) => {
console.error(
`[interval] refreshOpportunityCache failed: ${briefErr(err)}`,
);
});
}, 20 * 60 * 1000);
setInterval(
() => {
return refreshOpportunityCache().catch((err) => {
console.error(
`[interval] refreshOpportunityCache failed: ${briefErr(err)}`,
);
});
},
20 * 60 * 1000,
);
// Refresh User Defined Fields every 5 minutes
await safeStartup("refreshUDFs", () => userDefinedFieldsCw.refresh());
@@ -181,6 +189,17 @@ setInterval(
30 * 60 * 1000,
);
// Refresh CW members DB table every hour
await safeStartup("refreshCwMembers", refreshCwMembers);
setInterval(
() => {
return refreshCwMembers().catch((err) =>
console.error(`[interval] refreshCwMembers failed: ${briefErr(err)}`),
);
},
60 * 60 * 1000,
);
await safeStartup("syncSites", () => unifiSites.syncSites());
setInterval(() => {
return unifiSites
+77
View File
@@ -0,0 +1,77 @@
import { prisma } from "../constants";
import { CwMemberController } from "../controllers/CwMemberController";
import GenericError from "../Errors/GenericError";
/**
* CW Members Manager
*
* Thin persistence layer wrapping Prisma calls for the CwMember model.
* Returns CwMemberController instances as domain objects.
*/
export const cwMembers = {
/**
* Fetch a single CW member by internal ID, CW member ID, or identifier.
*/
fetch: async (idOrIdentifier: string): Promise<CwMemberController> => {
const isNumeric = /^\d+$/.test(idOrIdentifier);
const record = await prisma.cwMember.findFirst({
where: isNumeric
? { cwMemberId: Number(idOrIdentifier) }
: {
OR: [{ id: idOrIdentifier }, { identifier: idOrIdentifier }],
},
});
if (!record) {
throw new GenericError({
status: 404,
name: "CwMemberNotFound",
message: `CW Member "${idOrIdentifier}" not found`,
});
}
return new CwMemberController(record);
},
/**
* Fetch all CW members with optional filtering.
*/
fetchAll: async (opts?: {
includeInactive?: boolean;
}): Promise<CwMemberController[]> => {
const where = opts?.includeInactive ? {} : { inactiveFlag: false };
const records = await prisma.cwMember.findMany({
where,
orderBy: { lastName: "asc" },
});
return records.map((r) => new CwMemberController(r));
},
/**
* Count CW members.
*/
count: async (opts?: { includeInactive?: boolean }): Promise<number> => {
const where = opts?.includeInactive ? {} : { inactiveFlag: false };
return prisma.cwMember.count({ where });
},
/**
* Update the API key for a CW member.
*/
updateApiKey: async (
idOrIdentifier: string,
apiKey: string | null,
): Promise<CwMemberController> => {
const member = await cwMembers.fetch(idOrIdentifier);
const updated = await prisma.cwMember.update({
where: { id: member.id },
data: { apiKey },
});
return new CwMemberController(updated);
},
};
+188
View File
@@ -0,0 +1,188 @@
import { prisma } from "../constants";
import GenericError from "../Errors/GenericError";
import { GeneratedQuoteController } from "../controllers/GeneratedQuoteController";
const generatedQuoteInclude = {
opportunity: true,
createdBy: {
include: {
roles: true,
},
},
} as const;
export const generatedQuotes = {
async fetch(id: string): Promise<GeneratedQuoteController> {
const quote = await prisma.generatedQuotes.findFirst({
where: { id },
include: generatedQuoteInclude,
});
if (!quote) {
throw new GenericError({
message: "Generated quote not found",
name: "GeneratedQuoteNotFound",
cause: `No generated quote exists with ID '${id}'`,
status: 404,
});
}
return new GeneratedQuoteController(quote);
},
async fetchByOpportunity(
opportunityId: string,
): Promise<GeneratedQuoteController[]> {
const rows = await prisma.generatedQuotes.findMany({
where: { opportunityId },
include: generatedQuoteInclude,
orderBy: { createdAt: "desc" },
});
return rows.map((row) => new GeneratedQuoteController(row));
},
async fetchByCreator(
createdById: string,
): Promise<GeneratedQuoteController[]> {
const rows = await prisma.generatedQuotes.findMany({
where: { createdById },
include: generatedQuoteInclude,
orderBy: { createdAt: "desc" },
});
return rows.map((row) => new GeneratedQuoteController(row));
},
async fetchByHash(
quoteRegenHash: string,
): Promise<GeneratedQuoteController | null> {
const quote = await prisma.generatedQuotes.findUnique({
where: { quoteRegenHash },
include: generatedQuoteInclude,
});
return quote ? new GeneratedQuoteController(quote) : null;
},
async create(data: {
id?: string;
quoteRegenData: unknown;
quoteRegenParams: unknown;
quoteRegenHash: string;
quoteFile: Buffer | Uint8Array;
quoteFileName: string;
opportunityId: string;
createdById: string;
}): Promise<GeneratedQuoteController> {
const opportunity = await prisma.opportunity.findFirst({
where: { id: data.opportunityId },
select: { id: true },
});
if (!opportunity) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with ID '${data.opportunityId}'`,
status: 404,
});
}
const createdBy = await prisma.user.findFirst({
where: { id: data.createdById },
select: { id: true },
});
if (!createdBy) {
throw new GenericError({
message: "User not found",
name: "UserNotFound",
cause: `No user exists with ID '${data.createdById}'`,
status: 404,
});
}
const quote = await prisma.generatedQuotes.create({
data: {
...(data.id ? { id: data.id } : {}),
quoteRegenData: data.quoteRegenData as any,
quoteRegenParams: data.quoteRegenParams as any,
quoteRegenHash: data.quoteRegenHash,
quoteFile: Buffer.from(data.quoteFile),
quoteFileName: data.quoteFileName,
opportunityId: data.opportunityId,
createdById: data.createdById,
},
include: generatedQuoteInclude,
});
return new GeneratedQuoteController(quote);
},
async delete(id: string): Promise<void> {
const existing = await prisma.generatedQuotes.findFirst({
where: { id },
select: { id: true },
});
if (!existing) {
throw new GenericError({
message: "Generated quote not found",
name: "GeneratedQuoteNotFound",
cause: `No generated quote exists with ID '${id}'`,
status: 404,
});
}
await prisma.generatedQuotes.delete({
where: { id },
});
},
async recordDownload(
id: string,
user: {
id: string;
name?: string | null;
email: string;
fetchAction: string;
},
): Promise<GeneratedQuoteController> {
const existing = await prisma.generatedQuotes.findFirst({
where: { id },
select: { id: true, downloads: true },
});
if (!existing) {
throw new GenericError({
message: "Generated quote not found",
name: "GeneratedQuoteNotFound",
cause: `No generated quote exists with ID '${id}'`,
status: 404,
});
}
const currentDownloads = Array.isArray(existing.downloads)
? (existing.downloads as unknown[])
: [];
const downloadRecord = {
downloadedAt: new Date().toISOString(),
fetchAction: user.fetchAction,
userId: user.id,
userName: user.name ?? null,
userEmail: user.email,
};
const updated = await prisma.generatedQuotes.update({
where: { id },
data: {
downloads: [...currentDownloads, downloadRecord] as any,
},
include: generatedQuoteInclude,
});
return new GeneratedQuoteController(updated);
},
};
+41 -32
View File
@@ -6,6 +6,7 @@ import { OpportunityController } from "../controllers/OpportunityController";
import GenericError from "../Errors/GenericError";
import { activityCw } from "../modules/cw-utils/activities/activities";
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
import { CWOpportunityCreate } from "../modules/cw-utils/opportunities/opportunity.types";
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
import {
getCachedActivities,
@@ -42,7 +43,6 @@ async function buildCompanyController(
ttlMs?: number;
},
): Promise<CompanyController> {
const _ct0 = performance.now();
const strategy = opts?.strategy ?? "cache-then-cw";
const ctrl = new CompanyController(company);
@@ -82,10 +82,6 @@ async function buildCompanyController(
} else {
await ctrl.hydrateCwData();
}
console.log(
`[PERF:buildCompany] ${(performance.now() - _ct0).toFixed(0)}ms (strategy=${strategy}, hit=miss)`,
);
return ctrl;
}
@@ -105,7 +101,6 @@ async function buildActivities(
ttlMs?: number;
},
): Promise<ActivityController[]> {
const _at0 = performance.now();
const strategy = opts?.strategy ?? "cache-then-cw";
// ── cw-first: always fetch from CW (and cache the result) ──────────
@@ -129,13 +124,49 @@ async function buildActivities(
const arr = opts?.ttlMs
? await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs)
: await activityCw.fetchByOpportunityDirect(cwOpportunityId);
console.log(
`[PERF:buildActivities] ${(performance.now() - _at0).toFixed(0)}ms (strategy=${strategy}, hit=miss, count=${arr.length})`,
);
return arr.map((item) => new ActivityController(item));
}
export const opportunities = {
/**
* Create Opportunity
*
* Creates a new opportunity in ConnectWise, then stores the resulting
* record in the local database and returns an OpportunityController.
*
* @param data Fields required by the ConnectWise `POST /sales/opportunities` endpoint
* @returns {Promise<OpportunityController>}
*/
async createItem(data: CWOpportunityCreate): Promise<OpportunityController> {
const cwData = await opportunityCw.create(data);
const mapped = OpportunityController.mapCwToDb(cwData);
// Resolve optional local company relation
const companyId = cwData.company?.id
? ((
await prisma.company.findFirst({
where: { cw_CompanyId: cwData.company.id },
select: { id: true },
})
)?.id ?? null)
: null;
const record = await prisma.opportunity.create({
data: {
cwOpportunityId: cwData.id,
...mapped,
companyId,
},
include: { company: true },
});
return new OpportunityController(record, {
company: record.company
? new CompanyController(record.company)
: undefined,
});
},
/**
* Fetch Record (lightweight)
*
@@ -202,7 +233,6 @@ export const opportunities = {
identifier: string | number,
opts?: { fresh?: boolean },
): Promise<OpportunityController> {
const _t0 = performance.now();
const strategy: "cache-only" | "cache-then-cw" | "cw-first" = opts?.fresh
? "cw-first"
: "cache-then-cw";
@@ -216,8 +246,6 @@ export const opportunities = {
: { id: identifier as string },
include: { company: true },
});
const _t1 = performance.now();
console.log(`[PERF:fetchItem] DB lookup: ${(_t1 - _t0).toFixed(0)}ms`);
if (!existing) {
throw new GenericError({
@@ -245,10 +273,6 @@ export const opportunities = {
// Try the Redis cache first
cwData = await getCachedOppCwData(existing.cwOpportunityId);
}
const _t2 = performance.now();
console.log(
`[PERF:fetchItem] Redis cache check: ${(_t2 - _t1).toFixed(0)}ms (hit=${!!cwData})`,
);
// ── Parallel block: CW opp fetch + activities + company ────────────
// Activities and company hydration only need existing.cwOpportunityId
@@ -261,10 +285,6 @@ export const opportunities = {
cwData = ttlMs
? await fetchAndCacheOppCwData(existing.cwOpportunityId, ttlMs)
: await opportunityCw.fetch(existing.cwOpportunityId);
const _t2b = performance.now();
console.log(
`[PERF:fetchItem] CW opp fetch: ${(_t2b - _t2).toFixed(0)}ms`,
);
if (!cwData) {
throw new GenericError({
@@ -291,12 +311,8 @@ export const opportunities = {
data: { ...mapped, companyId },
include: { company: true },
});
console.log(
`[PERF:fetchItem] DB update: ${(performance.now() - _t2b).toFixed(0)}ms`,
);
})();
const _t3 = performance.now();
// Hydrate activities and company in parallel with CW opp fetch
const [, activities, company] = await Promise.all([
cwOppPromise,
@@ -305,13 +321,6 @@ export const opportunities = {
? buildCompanyController(existing.company, { strategy, ttlMs })
: Promise.resolve(undefined),
]);
const _t4 = performance.now();
console.log(
`[PERF:fetchItem] parallel block (cw+activities+company): ${(_t4 - _t3).toFixed(0)}ms`,
);
console.log(
`[PERF:fetchItem] TOTAL: ${(_t4 - _t0).toFixed(0)}ms (strategy=${strategy}, ttl=${ttlMs}ms)`,
);
return new OpportunityController(record, {
company,
@@ -408,7 +417,7 @@ export const opportunities = {
include: { company: true },
skip,
take: rpp,
orderBy: { expectedCloseDate: "asc" },
orderBy: { createdAt: "desc" },
});
return Promise.all(
+152 -5
View File
@@ -2,9 +2,11 @@ import { prisma } from "../constants";
import { CatalogItemController } from "../controllers/CatalogItemController";
import GenericError from "../Errors/GenericError";
import {
CATEGORY_TREE,
getSubcategoriesForCategory,
getSubcategoriesForGroup,
ECOSYSTEM_TREE,
isCategoryGroup,
} from "../modules/catalog-categories/catalogCategories";
/**
@@ -15,6 +17,61 @@ const catalogItemInclude = {
linkedItems: true,
} as const;
const LABOR_STYLE_CANDIDATES = {
field: ["LABOR & INSTALLATION - FIELD", "LABOR - FIELD", "LABOR FIELD"],
tech: ["LABOR & INSTALLATION - TECH", "LABOR - TECH", "LABOR TECH"],
} as const;
async function findCatalogByExactCandidates(
candidates: readonly string[],
): Promise<CatalogItemController | null> {
for (const candidate of candidates) {
const item = await prisma.catalogItem.findFirst({
where: {
inactive: false,
OR: [
{ identifier: { equals: candidate, mode: "insensitive" } },
{ name: { equals: candidate, mode: "insensitive" } },
],
},
include: catalogItemInclude,
});
if (item) return new CatalogItemController(item);
}
return null;
}
async function findCatalogByLaborStyle(
style: "field" | "tech",
): Promise<CatalogItemController | null> {
const fallback = await prisma.catalogItem.findFirst({
where: {
inactive: false,
AND: [
{
OR: [
{ identifier: { contains: "labor", mode: "insensitive" } },
{ name: { contains: "labor", mode: "insensitive" } },
],
},
{
OR: [
{ identifier: { contains: style, mode: "insensitive" } },
{ name: { contains: style, mode: "insensitive" } },
],
},
],
},
include: catalogItemInclude,
orderBy: { name: "asc" },
});
if (!fallback) return null;
return new CatalogItemController(fallback);
}
/**
* Filter options for catalog item queries.
*/
@@ -36,22 +93,82 @@ export interface CatalogFilterOpts {
function buildFilterWhere(opts: CatalogFilterOpts = {}) {
const conditions: Record<string, unknown>[] = [];
const parseNumericId = (value?: string): number | null => {
if (!value) return null;
if (!/^\d+$/.test(value)) return null;
return Number(value);
};
const resolveCategoryNameById = (cwId: number): string | null => {
return CATEGORY_TREE.find((c) => c.cwId === cwId)?.name ?? null;
};
const resolveSubcategoryNameById = (cwId: number): string | null => {
for (const category of CATEGORY_TREE) {
for (const entry of category.entries) {
if (isCategoryGroup(entry)) {
const child = entry.children.find((c) => c.cwId === cwId);
if (child) return child.name;
continue;
}
if (entry.cwId === cwId) return entry.name;
}
}
return null;
};
const categoryId = parseNumericId(opts.category);
const subcategoryId = parseNumericId(opts.subcategory);
const resolvedCategoryName = categoryId
? resolveCategoryNameById(categoryId)
: opts.category;
if (!opts.includeInactive) {
conditions.push({ inactive: false });
}
if (opts.category) {
conditions.push({ category: opts.category });
if (categoryId) {
const categoryOr: Record<string, unknown>[] = [
{ categoryCwId: categoryId },
];
if (resolvedCategoryName) {
categoryOr.push({ category: resolvedCategoryName });
}
conditions.push({ OR: categoryOr });
} else {
conditions.push({ category: opts.category });
}
}
if (opts.subcategory) {
conditions.push({ subcategory: opts.subcategory });
if (subcategoryId) {
const resolvedSubcategoryName = resolveSubcategoryNameById(subcategoryId);
const subcategoryOr: Record<string, unknown>[] = [
{ subcategoryCwId: subcategoryId },
];
if (resolvedSubcategoryName) {
subcategoryOr.push({ subcategory: resolvedSubcategoryName });
}
conditions.push({ OR: subcategoryOr });
} else {
conditions.push({ subcategory: opts.subcategory });
}
}
if (opts.group && opts.category) {
const subcats = getSubcategoriesForGroup(opts.category, opts.group);
if (subcats.length > 0) {
conditions.push({ subcategory: { in: subcats } });
if (!resolvedCategoryName) {
conditions.push({ category: "__unknown_category__" });
}
if (resolvedCategoryName) {
const subcats = getSubcategoriesForGroup(
resolvedCategoryName,
opts.group,
);
if (subcats.length > 0) {
conditions.push({ subcategory: { in: subcats } });
}
}
} else if (opts.group && !opts.category) {
// Try to find the group in any category
@@ -142,6 +259,36 @@ export const procurement = {
return new CatalogItemController(item);
},
/**
* Fetch Labor Catalog Items
*
* Resolves canonical Field and Tech labor products from the local catalog.
* Prefers exact identifier/name matches, then falls back to keyword matching.
*/
async fetchLaborCatalogItems(): Promise<{
field: CatalogItemController;
tech: CatalogItemController;
}> {
const fieldItem =
(await findCatalogByExactCandidates(LABOR_STYLE_CANDIDATES.field)) ??
(await findCatalogByLaborStyle("field"));
const techItem =
(await findCatalogByExactCandidates(LABOR_STYLE_CANDIDATES.tech)) ??
(await findCatalogByLaborStyle("tech"));
if (!fieldItem || !techItem) {
throw new GenericError({
message: "Labor catalog products are not configured",
name: "LaborCatalogProductsNotFound",
cause:
"Expected active FIELD and TECH labor catalog items in the local catalog",
status: 500,
});
}
return { field: fieldItem, tech: techItem };
},
/**
* Fetch All Catalog Items (Paginated)
*
@@ -17,20 +17,26 @@ export interface CWMember {
* Fetches every member from ConnectWise using pagination and returns them
* in a Collection keyed by their identifier (e.g. "jroberts").
*
* @param opts.conditions - Optional CW conditions string to filter members
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
*/
export const fetchAllCwMembers = async (): Promise<
Collection<string, CWMember>
> => {
export const fetchAllCwMembers = async (opts?: {
conditions?: string;
}): Promise<Collection<string, CWMember>> => {
const members = new Collection<string, CWMember>();
const pageSize = 1000;
const conditionsParam = opts?.conditions
? `&conditions=${encodeURIComponent(opts.conditions)}`
: "";
const { data: countData } = await connectWiseApi.get("/system/members/count");
const { data: countData } = await connectWiseApi.get(
`/system/members/count${conditionsParam ? `?${conditionsParam.slice(1)}` : ""}`,
);
const totalPages = Math.ceil(countData.count / pageSize);
for (let page = 0; page < totalPages; page++) {
const { data } = await connectWiseApi.get<CWMember[]>(
`/system/members?page=${page + 1}&pageSize=${pageSize}`,
`/system/members?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
);
for (const member of data) {
@@ -0,0 +1,106 @@
import { prisma } from "../../../constants";
import { events } from "../../globalEvents";
import { fetchAllCwMembers, type CWMember } from "./fetchAllMembers";
import { setMemberCache } from "./memberCache";
import { CwMemberController } from "../../../controllers/CwMemberController";
/**
* Is Regular User
*
* Returns true if the CW member looks like a real person rather than
* a service account (e.g. "labtech", "Admin"). A regular user must
* have a last name and an email address.
*/
const isRegularUser = (member: CWMember): boolean =>
!member.inactiveFlag &&
Boolean(member.lastName?.trim()) &&
Boolean(member.officeEmail?.trim());
/**
* Refresh CW Members
*
* Syncs local CwMember records with ConnectWise using a stale-check
* pattern:
* 1. Fetch all members from CW
* 2. Filter to regular users (active, non-service accounts)
* 3. Compare against local cwLastUpdated timestamps
* 4. Upsert stale/new records
* 5. Also refreshes the in-memory member cache
*/
export const refreshCwMembers = async () => {
events.emit("cw:members:db:refresh:check");
// 1. Fetch all members from CW
const allCwMembers = await fetchAllCwMembers();
// Also refresh the in-memory cache with ALL members (used for name resolution)
await setMemberCache(allCwMembers);
// 2. Filter to regular users only (active, has last name + email)
const cwMembers = allCwMembers.filter(isRegularUser);
// 2. Fetch all DB records with their identifier and cwLastUpdated
const dbItems = await prisma.cwMember.findMany({
select: { cwMemberId: true, cwLastUpdated: true },
});
const dbMap = new Map(
dbItems.map((item) => [item.cwMemberId, item.cwLastUpdated]),
);
// 3. Determine stale / new IDs
const staleIds: number[] = [];
for (const [, member] of cwMembers) {
const cwLastUpdated = member._info?.lastUpdated
? new Date(member._info.lastUpdated)
: null;
const dbLastUpdated = dbMap.get(member.id) ?? null;
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
staleIds.push(member.id);
}
}
if (staleIds.length === 0) {
events.emit("cw:members:db:refresh:skipped", {
totalCw: cwMembers.size,
totalDb: dbItems.length,
staleCount: 0,
});
return;
}
events.emit("cw:members:db:refresh:started", {
totalCw: cwMembers.size,
totalDb: dbItems.length,
staleCount: staleIds.length,
});
// 4. Upsert stale/new items
const staleIdSet = new Set(staleIds);
const updatedCount = (
await Promise.all(
[...cwMembers.values()]
.filter((m) => staleIdSet.has(m.id))
.map(async (member) => {
const mapped = CwMemberController.mapCwToDb(member);
return prisma.cwMember.upsert({
where: { cwMemberId: member.id },
create: {
cwMemberId: member.id,
...mapped,
},
update: mapped,
});
}),
)
).filter(Boolean).length;
events.emit("cw:members:db:refresh:completed", {
totalCw: cwMembers.size,
totalDb: dbItems.length,
staleCount: staleIds.length,
itemsUpdated: updatedCount,
});
};
@@ -2,14 +2,18 @@ import { Collection } from "@discordjs/collection";
import { connectWiseApi } from "../../../constants";
import {
CWOpportunity,
CWOpportunityCreate,
CWOpportunitySummary,
CWForecast,
CWForecastItem,
CWForecastItemCreate,
CWProcurementProduct,
CWProcurementProductCreate,
CWOpportunityNote,
CWOpportunityNoteCreate,
CWOpportunityNoteUpdate,
CWOpportunityContact,
CWOpportunityUpdate,
} from "./opportunity.types";
export const opportunityCw = {
@@ -98,6 +102,45 @@ export const opportunityCw = {
return response.data;
},
/**
* Create Opportunity
*
* Creates a new opportunity in ConnectWise via POST.
* Strips null/undefined values from the payload CW rejects
* null reference objects on create; omitting them lets CW apply
* its own defaults.
*/
create: async (data: CWOpportunityCreate): Promise<CWOpportunity> => {
const cleaned = Object.fromEntries(
Object.entries(data).filter(([, v]) => v != null),
);
const response = await connectWiseApi.post("/sales/opportunities", cleaned);
return response.data;
},
/**
* Update Opportunity
*
* Applies a JSON Patch update to an opportunity record in ConnectWise.
* Each key in `data` produces a replace operation.
*/
update: async (
opportunityId: number,
data: CWOpportunityUpdate,
): Promise<CWOpportunity> => {
const operations = Object.entries(data).map(([key, value]) => ({
op: "replace" as const,
path: key,
value,
}));
const response = await connectWiseApi.patch(
`/sales/opportunities/${opportunityId}`,
operations,
);
return response.data;
},
/**
* Fetch Opportunities by Company
*
@@ -352,8 +395,72 @@ export const opportunityCw = {
opportunityId: number,
): Promise<Record<string, unknown>[]> => {
const response = await connectWiseApi.get(
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate`,
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate,customFields`,
);
return response.data;
},
/**
* Create Procurement Products
*
* Creates one or more procurement products linked to an opportunity.
* This endpoint supports procurement customFields (unlike forecast items).
*/
createProcurementProducts: async (
data: CWProcurementProductCreate | CWProcurementProductCreate[],
): Promise<CWProcurementProduct[]> => {
const productsToCreate = Array.isArray(data) ? data : [data];
const created: CWProcurementProduct[] = [];
for (const product of productsToCreate) {
const response = await connectWiseApi.post(
`/procurement/products`,
product,
);
created.push(response.data as CWProcurementProduct);
}
return created;
},
/**
* Fetch Procurement Product by Forecast Detail
*
* Finds the procurement product linked to a given forecast item ID
* on an opportunity.
*/
fetchProcurementProductByForecastDetail: async (
opportunityId: number,
forecastDetailId: number,
): Promise<CWProcurementProduct | null> => {
const conditions = `opportunity/id=${opportunityId} and forecastDetailId=${forecastDetailId}`;
const response = await connectWiseApi.get(
`/procurement/products?conditions=${encodeURIComponent(conditions)}&fields=id,forecastDetailId,description,customerDescription,quantity,price,cost,taxableFlag,specialOrderFlag,customFields`,
);
const items = (response.data ?? []) as CWProcurementProduct[];
return items[0] ?? null;
},
/**
* Update Procurement Product
*
* Applies a JSON Patch update to a procurement product record.
*/
updateProcurementProduct: async (
procurementProductId: number,
data: Record<string, unknown>,
): Promise<CWProcurementProduct> => {
const operations = Object.entries(data).map(([key, value]) => ({
op: "replace" as const,
path: key,
value,
}));
const response = await connectWiseApi.patch(
`/procurement/products/${procurementProductId}`,
operations,
);
return response.data as CWProcurementProduct;
},
};
@@ -68,6 +68,7 @@ export interface CWOpportunity {
closedDate: string;
closedBy: CWMemberReference;
totalSalesTax: number;
probability: CWReference;
shipToCompany: CWCompanyReference;
shipToContact: CWContactReference;
shipToSite: CWSiteReference;
@@ -113,6 +114,7 @@ export interface CWForecastItem {
_info?: Record<string, string>;
};
productDescription: string;
customerDescription?: string;
productClass: string;
revenue: number;
cost: number;
@@ -129,6 +131,7 @@ export interface CWForecastItem {
sequenceNumber: number;
subNumber: number;
taxableFlag: boolean;
customFields?: CWCustomField[];
_info?: Record<string, string>;
}
@@ -210,6 +213,7 @@ export interface CWForecastItemCreate {
catalogItem?: { id: number };
forecastDescription?: string;
productDescription?: string;
customerDescription?: string;
quantity?: number;
status?: { id: number };
productClass?: string;
@@ -224,6 +228,81 @@ export interface CWForecastItemCreate {
recurringCost?: number;
cycles?: number;
sequenceNumber?: number;
customFields?: Array<
Partial<Omit<CWCustomField, "connectWiseId" | "rowNum" | "podId">>
>;
}
export interface CWProcurementProductCreate {
opportunity?: { id: number };
catalogItem: { id: number };
description: string;
customerDescription?: string;
quantity?: number;
price?: number;
cost?: number;
taxableFlag?: boolean;
dropshipFlag?: boolean;
billableOption?: string;
customFields?: Array<
Partial<Omit<CWCustomField, "connectWiseId" | "rowNum" | "podId">>
>;
}
export interface CWProcurementProduct {
id: number;
forecastDetailId?: number;
description?: string;
customerDescription?: string;
quantity?: number;
price?: number;
cost?: number;
taxableFlag?: boolean;
specialOrderFlag?: boolean;
customFields?: CWCustomField[];
_info?: Record<string, string>;
}
export interface CWOpportunityUpdate {
name?: string;
notes?: string;
rating?: { id: number };
type?: { id: number };
stage?: { id: number };
status?: { id: number };
priority?: { id: number };
campaign?: { id: number };
primarySalesRep?: { id: number };
secondarySalesRep?: { id: number } | null;
company?: { id: number };
contact?: { id: number } | null;
site?: { id: number } | null;
expectedCloseDate?: string;
customerPO?: string | null;
source?: string | null;
locationId?: number;
businessUnitId?: number;
}
export interface CWOpportunityCreate {
name: string;
expectedCloseDate: string;
primarySalesRep: { id: number };
company: { id: number };
contact: { id: number };
type?: { id: number };
stage?: { id: number };
status?: { id: number };
priority?: { id: number };
campaign?: { id: number };
secondarySalesRep?: { id: number } | null;
site?: { id: number } | null;
notes?: string;
rating?: { id: number };
source?: string | null;
customerPO?: string | null;
locationId?: number;
businessUnitId?: number;
}
export interface CWOpportunitySummary {
@@ -14,6 +14,7 @@ export const processOpportunityResponse = (opportunity: CWOpportunity) => ({
expectedCloseDate: opportunity.expectedCloseDate,
closedDate: opportunity.closedDate,
closedFlag: opportunity.closedFlag,
probability: Number(opportunity.probability?.name) || 0,
type: opportunity.type
? { id: opportunity.type.id, name: opportunity.type.name }
: null,
+19
View File
@@ -205,6 +205,25 @@ interface EventTypes {
totalUsers: number;
usersUpdated: number;
}) => void;
// ConnectWise Members DB Sync Events
"cw:members:db:refresh:check": () => void;
"cw:members:db:refresh:started": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
}) => void;
"cw:members:db:refresh:completed": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
itemsUpdated: number;
}) => void;
"cw:members:db:refresh:skipped": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
}) => void;
}
export const events = new Eventra<EventTypes>();
+782
View File
@@ -0,0 +1,782 @@
import PdfPrinter from "pdfmake/src/Printer";
import { readFileSync } from "node:fs";
import { join } from "node:path";
export interface QuoteLineItem {
qty: number;
description: string;
unitPrice: number;
narrative?: string;
}
export interface CustomerInfo {
name: string;
company?: string;
attention?: string;
address: string[];
}
export interface CustomerContact {
email?: string;
phone?: string;
}
export interface QuoteDetails {
quoteNumber: string;
date: string;
description: string;
}
export interface TaxConfig {
rate: number;
label: string;
}
export interface SalesRepInfo {
name: string;
email?: string;
}
export interface QuoteMetadata {
quoteId?: string;
createdById?: string;
createdByName?: string;
createdByEmail?: string;
createdAt?: string;
downloadedAt?: string;
downloadedById?: string;
downloadedByName?: string;
downloadedByEmail?: string;
}
export interface QuoteData {
customer: CustomerInfo;
contact: CustomerContact;
quote: QuoteDetails;
lineItems: QuoteLineItem[];
tax: TaxConfig;
salesRep?: SalesRepInfo;
quoteNarrative?: string;
isPreview?: boolean;
showLineItemPricing?: boolean;
metadata?: QuoteMetadata;
}
export interface QuoteTheme {
brandPrimary: string;
brandDark: string;
brandLight: string;
accent: string;
headerBg: string;
footerBg: string;
}
const DEFAULT_THEME: QuoteTheme = {
brandPrimary: "#8B5E0B",
brandDark: "#5C3D07",
brandLight: "#F5EDE0",
accent: "#C67F17",
headerBg: "#2D2317",
footerBg: "#F5EDE0",
};
const SLATE = "#3A3A3A";
const SLATE_MID = "#636363";
const SLATE_LIGHT = "#8E8E8E";
const WHITE = "#FFFFFF";
const ROW_ALT = "#FAF7F2";
const DIVIDER = "#D4C5A9";
const PAGE_H = 792;
const PAGE_W = 612;
const MARGIN_L = 40;
const MARGIN_R = 40;
const MARGIN_TOP = 26;
const MARGIN_BOTTOM = 65;
const CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R;
const DEFAULT_DISCLAIMER =
"Prices valid for 30 days from quote date. Taxes invoiced per jurisdiction regardless of presence on this quote.";
const COMPANY = {
name: "Total Tech Solutions LLC",
contactPerson: "Courtney Stevens",
address: ["PO Box 331", "Murray, KY 42071"],
phone: "(270) 761-8324",
email: "courtney.stevens@totaltech.net",
licenseInfo: "Licensed in Kentucky & Tennessee · TN License #2173",
} as const;
const DEFAULT_LOGO_PATH = join(process.cwd(), "logo.png");
const fontDir = join(process.cwd(), "node_modules/pdfmake/build/fonts/Roboto");
const fonts = {
Roboto: {
normal: join(fontDir, "Roboto-Regular.ttf"),
bold: join(fontDir, "Roboto-Medium.ttf"),
italics: join(fontDir, "Roboto-Italic.ttf"),
bolditalics: join(fontDir, "Roboto-MediumItalic.ttf"),
},
};
const printer = new PdfPrinter(fonts as never);
const fmt = (n: number) =>
"$" + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const hr = (color = DIVIDER, weight = 0.75) => ({
canvas: [
{
type: "line",
x1: 0,
y1: 0,
x2: CONTENT_W,
y2: 0,
lineWidth: weight,
lineColor: color,
},
],
});
function loadLogoDataUrl(logoPath: string): string | null {
try {
const raw = readFileSync(logoPath);
const ext = logoPath.toLowerCase().endsWith(".png") ? "png" : "jpeg";
return `data:image/${ext};base64,${raw.toString("base64")}`;
} catch {
return null;
}
}
export async function generateQuote(
data: QuoteData,
theme: Partial<QuoteTheme> = {},
logoPath = DEFAULT_LOGO_PATH,
): Promise<Buffer> {
const t: QuoteTheme = { ...DEFAULT_THEME, ...theme };
const subTotal = data.lineItems.reduce(
(sum, item) => sum + item.qty * item.unitPrice,
0,
);
const taxAmount = subTotal * data.tax.rate;
const total = subTotal + taxAmount;
const logoDataUrl = loadLogoDataUrl(logoPath);
const showPricing = data.showLineItemPricing ?? false;
const tableHeader = [
{ text: "Qty", style: "thCell", alignment: "center" },
{ text: "Description", style: "thCell" },
...(showPricing
? [
{ text: "Unit Price", style: "thCell", alignment: "right" },
{ text: "Total", style: "thCell", alignment: "right" },
]
: []),
];
const colCount = showPricing ? 4 : 2;
const tableRows: Record<string, unknown>[][] = [];
for (const item of data.lineItems) {
// Build the description cell — stack description + narrative so they
// are a single cell and pdfmake never splits them across pages.
const descriptionCell: Record<string, unknown> = item.narrative
? {
stack: [
{ text: item.description, style: "tdCell" },
{
text: item.narrative,
style: "narrative",
margin: [0, 2, 8, 0],
},
],
}
: { text: item.description, style: "tdCell" };
tableRows.push([
{ text: String(item.qty), style: "tdCell", alignment: "center" },
descriptionCell,
...(showPricing
? [
{
text: fmt(item.unitPrice),
style: "tdCell",
alignment: "right",
noWrap: true,
},
{
text: fmt(item.qty * item.unitPrice),
style: "tdCell",
alignment: "right",
noWrap: true,
},
]
: []),
]);
}
const headerImage = logoDataUrl
? { image: logoDataUrl, width: 200 }
: {
stack: [{ text: COMPANY.name, style: "companyName" }],
width: 200,
};
const docDefinition = {
pageSize: "LETTER" as const,
pageMargins: [MARGIN_L, MARGIN_TOP, MARGIN_R, MARGIN_BOTTOM] as [
number,
number,
number,
number,
],
info: {
title: `Quote ${data.quote.quoteNumber}`,
author: data.metadata?.createdByName ?? COMPANY.name,
subject: data.quote.description,
creator: COMPANY.name,
producer: COMPANY.name,
keywords: [
data.metadata?.quoteId ? `quoteId:${data.metadata.quoteId}` : null,
data.metadata?.createdById
? `createdById:${data.metadata.createdById}`
: null,
data.metadata?.createdByEmail
? `createdByEmail:${data.metadata.createdByEmail}`
: null,
data.metadata?.createdAt
? `createdAt:${data.metadata.createdAt}`
: null,
data.metadata?.downloadedAt
? `downloadedAt:${data.metadata.downloadedAt}`
: null,
data.metadata?.downloadedById
? `downloadedById:${data.metadata.downloadedById}`
: null,
data.metadata?.downloadedByName
? `downloadedByName:${data.metadata.downloadedByName}`
: null,
data.metadata?.downloadedByEmail
? `downloadedByEmail:${data.metadata.downloadedByEmail}`
: null,
data.isPreview ? "preview:true" : null,
]
.filter(Boolean)
.join("; "),
},
defaultStyle: {
font: "Roboto",
fontSize: 9.5,
color: SLATE,
lineHeight: 1.3,
},
styles: {
companyName: { fontSize: 18, bold: true, color: t.brandDark },
quoteLabel: { fontSize: 24, color: t.accent, bold: true, opacity: 0.12 },
sectionTitle: {
fontSize: 8.5,
bold: true,
color: t.brandPrimary,
characterSpacing: 1.2,
},
sectionBody: { fontSize: 9, color: SLATE },
sectionMuted: { fontSize: 8.5, color: SLATE_MID },
infoLabel: {
fontSize: 8,
bold: true,
color: SLATE_LIGHT,
characterSpacing: 0.5,
},
infoValue: { fontSize: 10, bold: true, color: t.brandDark },
contactLabel: { fontSize: 8, bold: true, color: SLATE_LIGHT },
contactValue: { fontSize: 9, color: SLATE },
thCell: {
fontSize: 8.5,
bold: true,
color: WHITE,
characterSpacing: 0.5,
},
tdCell: { fontSize: 9, color: SLATE },
narrative: {
fontSize: 8,
color: SLATE_MID,
italics: true,
lineHeight: 1.2,
},
totalsLabel: { fontSize: 9, color: SLATE_MID },
totalsValue: { fontSize: 9, color: SLATE, bold: true },
totalFinalLabel: { fontSize: 11, bold: true, color: WHITE },
totalFinalValue: { fontSize: 12, bold: true, color: t.brandDark },
footerText: { fontSize: 7.5, color: SLATE_MID },
footerBold: { fontSize: 7.5, color: t.brandPrimary, bold: true },
disclaimer: { fontSize: 7, color: SLATE_LIGHT, italics: true },
},
...(data.isPreview
? {
watermark: {
text: "PREVIEW",
color: t.brandDark,
opacity: 0.15,
bold: true,
},
}
: {}),
background: () => ({
canvas: [
{ type: "rect", x: 0, y: 0, w: PAGE_W, h: 6, color: t.accent },
{ type: "rect", x: 0, y: 6, w: 4, h: 786, color: t.brandLight },
],
}),
content: [
{
margin: [0, 4, 0, 0],
columns: [
headerImage,
{
stack: [
{ text: COMPANY.name, style: "companyName", alignment: "right" },
{
text: "QUOTE",
style: "quoteLabel",
alignment: "right",
margin: [0, -4, 0, 0],
},
],
width: "*",
},
],
},
{ ...hr(t.accent, 1.5), margin: [0, 8, 0, 0] },
{
margin: [0, 7, 0, 7],
columns: [
{
width: "auto",
stack: [
{ text: "QUOTE NUMBER", style: "infoLabel" },
{
text: data.quote.quoteNumber,
style: "infoValue",
margin: [0, 2, 0, 0],
},
],
},
{
width: "auto",
margin: [30, 0, 0, 0],
stack: [
{ text: "DATE", style: "infoLabel" },
{
text: data.quote.date,
style: "infoValue",
margin: [0, 2, 0, 0],
},
],
},
{
width: "*",
margin: [30, 0, 0, 0],
stack: [
{ text: "DESCRIPTION", style: "infoLabel" },
{
text: data.quote.description,
style: "infoValue",
margin: [0, 2, 0, 0],
},
],
},
],
},
{ ...hr(), margin: [0, 0, 0, 10] },
{
columns: [
{
width: 155,
stack: [
{ text: "FROM", style: "sectionTitle", margin: [0, 0, 0, 6] },
{
text: data.salesRep?.name ?? COMPANY.contactPerson,
style: "sectionBody",
bold: true,
},
{
text: COMPANY.name,
style: "sectionMuted",
margin: [0, 2, 0, 0],
},
...COMPANY.address.map((line) => ({
text: line,
style: "sectionMuted",
})),
{
text: COMPANY.phone,
style: "sectionBody",
margin: [0, 4, 0, 0],
},
{
text: data.salesRep?.email ?? COMPANY.email,
style: "sectionMuted",
margin: [0, 1, 0, 0],
},
],
},
{
width: 175,
margin: [25, 0, 0, 0],
stack: [
{
text: "PREPARED FOR",
style: "sectionTitle",
margin: [0, 0, 0, 6],
},
{ text: data.customer.name, style: "sectionBody", bold: true },
...(data.customer.company
? [
{
text: data.customer.company,
style: "sectionMuted",
margin: [0, 2, 0, 0],
},
]
: []),
...(data.customer.attention
? [{ text: data.customer.attention, style: "sectionMuted" }]
: []),
...data.customer.address.map((line) => ({
text: line,
style: "sectionMuted",
})),
],
},
...(data.contact.email || data.contact.phone
? [
{
width: "*" as const,
margin: [20, 0, 0, 0] as [number, number, number, number],
stack: [
{
text: "CONTACT",
style: "sectionTitle",
margin: [0, 0, 0, 6],
},
...(data.contact.email
? [
{
columns: [
{
text: "Email",
style: "contactLabel",
width: 40,
},
{
text: data.contact.email,
style: "contactValue",
width: "*",
},
],
},
]
: []),
...(data.contact.phone
? [
{
columns: [
{
text: "Mobile",
style: "contactLabel",
width: 40,
},
{
text: data.contact.phone,
style: "contactValue",
width: "*",
},
],
margin: [0, 4, 0, 0],
},
]
: []),
],
},
]
: []),
],
},
{ ...hr(), margin: [0, 10, 0, 0] },
...(data.quoteNarrative
? [
{
margin: [0, 8, 0, 6] as [number, number, number, number],
table: {
widths: [2, "*"],
body: [
[
{
text: "",
fillColor: t.accent,
border: [false, false, false, false],
},
{
text: data.quoteNarrative,
fontSize: 9,
color: SLATE_MID,
italics: true,
lineHeight: 1.4,
margin: [8, 6, 8, 6],
fillColor: ROW_ALT,
border: [false, false, false, false],
},
],
],
},
layout: {
hLineWidth: () => 0,
vLineWidth: () => 0,
paddingLeft: () => 0,
paddingRight: () => 0,
paddingTop: () => 0,
paddingBottom: () => 0,
},
},
]
: []),
{
margin: [0, 10, 0, 0],
table: {
headerRows: 1,
dontBreakRows: true,
widths: showPricing ? [40, "*", 75, 75] : [40, "*"],
body: [tableHeader, ...tableRows],
},
layout: {
fillColor: (rowIndex: number) => {
if (rowIndex === 0) return t.headerBg;
return rowIndex % 2 === 0 ? ROW_ALT : null;
},
hLineWidth: (i: number, node: { table: { body: unknown[] } }) => {
if (i === 0 || i === 1) return 0;
if (i === node.table.body.length) return 1;
return 0.5;
},
vLineWidth: () => 0,
hLineColor: (i: number, node: { table: { body: unknown[] } }) =>
i === node.table.body.length ? t.headerBg : "#E8E0D0",
paddingLeft: (col: number) => (col === 0 ? 6 : 8),
paddingRight: () => 8,
paddingTop: () => 4,
paddingBottom: () => 4,
},
},
{
unbreakable: true,
stack: [
{
margin: [0, 6, 0, 0],
columns: [
{ width: "*", text: "" },
{
width: 250,
table: {
widths: ["*", 110],
body: [
[
{
text: "Subtotal",
style: "totalsLabel",
margin: [0, 5, 0, 5],
border: [false, false, false, true],
},
{
text: fmt(subTotal),
style: "totalsValue",
alignment: "right",
noWrap: true,
margin: [0, 5, 0, 5],
border: [false, false, false, true],
},
],
[
{
text: data.tax.label,
style: "totalsLabel",
margin: [0, 5, 0, 5],
border: [false, false, false, true],
},
{
text: fmt(taxAmount),
style: "totalsValue",
alignment: "right",
noWrap: true,
margin: [0, 5, 0, 5],
border: [false, false, false, true],
},
],
[
{
text: "TOTAL",
style: "totalFinalLabel",
fillColor: t.headerBg,
margin: [10, 8, 6, 8],
border: [false, false, false, false],
},
{
text: fmt(total),
style: "totalFinalValue",
alignment: "right",
noWrap: true,
fillColor: t.brandLight,
margin: [6, 7, 8, 7],
border: [false, false, false, false],
},
],
],
},
layout: {
hLineWidth: (i: number) => (i >= 1 && i <= 2 ? 0.5 : 0),
vLineWidth: () => 0,
hLineColor: () => "#E0D6C6",
},
},
],
},
{
margin: [0, 40, 0, 0],
columns: [
{
width: "50%",
stack: [
{
canvas: [
{
type: "line",
x1: 0,
y1: 0,
x2: 220,
y2: 0,
lineWidth: 0.75,
lineColor: "#999",
},
],
},
{
text: "Authorized Signature",
fontSize: 7,
color: "#888",
margin: [0, 3, 0, 0],
},
],
},
{
width: "50%",
stack: [
{
canvas: [
{
type: "line",
x1: 0,
y1: 0,
x2: 160,
y2: 0,
lineWidth: 0.75,
lineColor: "#999",
},
],
},
{
text: "Date",
fontSize: 7,
color: "#888",
margin: [0, 3, 0, 0],
},
],
},
],
},
],
},
],
footer: (currentPage: number, pageCount: number) => ({
margin: [0, 0, 0, 0],
stack: [
{
canvas: [
{ type: "rect", x: 0, y: 0, w: PAGE_W, h: 44, color: t.footerBg },
],
},
{
margin: [MARGIN_L, -38, MARGIN_R, 0],
columns: [
{
width: "*",
stack: [
{
text: [
{ text: COMPANY.name, style: "footerBold" },
{
text: ` · ${COMPANY.licenseInfo}`,
style: "footerText",
},
],
},
],
},
{
width: "auto",
text: `Page ${currentPage} of ${pageCount}`,
style: "footerText",
alignment: "right",
},
],
},
{
margin: [MARGIN_L, 4, MARGIN_R, 0],
text: DEFAULT_DISCLAIMER,
style: "disclaimer",
},
],
}),
};
const maybeDoc = printer.createPdfKitDocument(docDefinition as never) as any;
const pdfDoc =
maybeDoc && typeof maybeDoc.then === "function" ? await maybeDoc : maybeDoc;
if (!pdfDoc || typeof pdfDoc.on !== "function") {
throw new Error("Failed to initialize PDF document stream");
}
return await new Promise<Buffer>((resolve, reject) => {
try {
const chunks: Buffer[] = [];
pdfDoc.on("data", (chunk: Buffer) => chunks.push(chunk));
pdfDoc.on("end", () => resolve(Buffer.concat(chunks)));
pdfDoc.on("error", reject);
if (typeof pdfDoc.end === "function") {
pdfDoc.end();
} else {
reject(new Error("PDF document stream does not support end()"));
}
} catch (err) {
reject(err);
}
});
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./generateQuote";
export * from "./injectPdfMetadata";
@@ -0,0 +1,48 @@
import { PDFDocument } from "pdf-lib";
export interface DownloadMetadata {
downloadedAt: string;
downloadedById: string;
downloadedByName?: string;
downloadedByEmail?: string;
}
/**
* Injects download-time metadata into an existing PDF's document properties.
*
* Appends download-specific key:value pairs to the PDF's Keywords field
* (matching the semicolon-delimited format used at commit time) and updates
* the ModificationDate.
*
* Returns the modified PDF as a `Uint8Array`.
*/
export async function injectPdfMetadata(
pdfBytes: Buffer | Uint8Array,
metadata: DownloadMetadata,
): Promise<Uint8Array> {
const pdfDoc = await PDFDocument.load(pdfBytes);
// Build new keyword entries in the same format used by generateQuote
const newKeywordPairs = [
`downloadedAt:${metadata.downloadedAt}`,
`downloadedById:${metadata.downloadedById}`,
metadata.downloadedByName
? `downloadedByName:${metadata.downloadedByName}`
: null,
metadata.downloadedByEmail
? `downloadedByEmail:${metadata.downloadedByEmail}`
: null,
].filter(Boolean) as string[];
// Append to existing keywords (preserve commit-time metadata)
const existingKeywords = pdfDoc.getKeywords() ?? "";
const separator = existingKeywords.length > 0 ? "; " : "";
pdfDoc.setKeywords([
existingKeywords + separator + newKeywordPairs.join("; "),
]);
// Update modification date to download time
pdfDoc.setModificationDate(new Date(metadata.downloadedAt));
return pdfDoc.save();
}
+98 -13
View File
@@ -399,11 +399,12 @@ export const PERMISSION_NODES = {
description:
"Fetch a single opportunity and its sub-resources (products, notes, contacts)",
usedIn: [
"src/api/sales/[id]/fetch.ts",
"src/api/sales/[id]/products.ts",
"src/api/sales/[id]/notes.ts",
"src/api/sales/[id]/fetchNote.ts",
"src/api/sales/[id]/contacts.ts",
"src/api/sales/opportunities/[id]/fetch.ts",
"src/api/sales/opportunities/[id]/products/fetchAll.ts",
"src/api/sales/opportunities/[id]/notes/fetchAll.ts",
"src/api/sales/opportunities/[id]/notes/fetch.ts",
"src/api/sales/opportunities/[id]/contacts.ts",
"src/api/sockets/events/liveQuotePreview.ts",
],
},
{
@@ -411,47 +412,63 @@ export const PERMISSION_NODES = {
description:
"Fetch multiple opportunities, count, or opportunity types",
usedIn: [
"src/api/sales/fetchAll.ts",
"src/api/sales/count.ts",
"src/api/sales/opportunities/fetchAll.ts",
"src/api/sales/opportunities/count.ts",
"src/api/sales/fetchOpportunityTypes.ts",
],
},
{
node: "sales.opportunity.refresh",
description: "Refresh a single opportunity from ConnectWise",
usedIn: ["src/api/sales/[id]/refresh.ts"],
usedIn: ["src/api/sales/opportunities/[id]/refresh.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.update",
description:
"Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise",
usedIn: ["src/api/sales/opportunities/[id]/update.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.create",
description: "Create a new opportunity in ConnectWise",
usedIn: ["src/api/sales/opportunities/create.ts"],
},
{
node: "sales.opportunity.note.create",
description: "Create a new note on an opportunity",
usedIn: ["src/api/sales/[id]/createNote.ts"],
usedIn: ["src/api/sales/opportunities/[id]/notes/create.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.note.update",
description: "Update an existing note on an opportunity",
usedIn: ["src/api/sales/[id]/updateNote.ts"],
usedIn: ["src/api/sales/opportunities/[id]/notes/update.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.note.delete",
description: "Delete a note from an opportunity",
usedIn: ["src/api/sales/[id]/deleteNote.ts"],
usedIn: ["src/api/sales/opportunities/[id]/notes/delete.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.product.update",
description:
"Update products (forecast items) on an opportunity, including resequencing",
usedIn: ["src/api/sales/[id]/resequenceProducts.ts"],
usedIn: [
"src/api/sales/opportunities/[id]/products/resequence.ts",
"src/api/sales/opportunities/[id]/products/update.ts",
"src/api/sales/opportunities/[id]/products/cancel.ts",
],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.product.add",
description:
"Add a new product (forecast item) to an opportunity. Individual fields are gated by sales.opportunity.product.field.<field> permissions.",
usedIn: ["src/api/sales/[id]/addProduct.ts"],
usedIn: ["src/api/sales/opportunities/[id]/products/add.ts"],
dependencies: ["sales.opportunity.fetch"],
fieldLevelPermissions: [
"sales.opportunity.product.field.catalogItem",
@@ -473,6 +490,73 @@ export const PERMISSION_NODES = {
"sales.opportunity.product.field.sequenceNumber",
],
},
{
node: "sales.opportunity.product.add.specialOrder",
description:
'Add one or more "SPECIAL ORDER" products to an opportunity via the dedicated special-order route.',
usedIn: [
"src/api/sales/opportunities/[id]/products/addSpecialOrder.ts",
],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.product.add.labor",
description:
"Add labor products to an opportunity using the dedicated labor route with Field/Tech catalog selection and pricing inputs.",
usedIn: [
"src/api/sales/opportunities/[id]/products/addLabor.ts",
"src/api/sales/opportunities/[id]/products/laborOptions.ts",
],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.quote.fetch",
description: "Fetch all committed quotes for an opportunity.",
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchAll.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.quote.commit",
description:
"Generate and store a finalized quote PDF for an opportunity with regeneration metadata and creator attribution.",
usedIn: ["src/api/sales/opportunities/[id]/quotes/commit.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.quote.preview",
description:
"Generate a preview-stamped quote PDF for an opportunity without storing it.",
usedIn: ["src/api/sales/opportunities/[id]/quotes/preview.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.quote.download",
description:
"Download a committed quote PDF. Each download is recorded with timestamp and user info.",
usedIn: ["src/api/sales/opportunities/[id]/quotes/download.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.quote.fetch_downloads",
description:
"Fetch download/print history for all quotes on an opportunity. Admin-level permission.",
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.view_margin",
description:
"View margin and markup data on opportunity products. Controls visibility of margin %, markup %, and related progress bars in the UI.",
usedIn: [],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.view_cost",
description:
"View cost data on opportunity products. Controls visibility of unit cost, total cost, and recurring cost in the UI.",
usedIn: [],
dependencies: ["sales.opportunity.fetch"],
},
],
},
@@ -933,6 +1017,7 @@ export const PERMISSION_NODES = {
"obj.opportunity.site",
"obj.opportunity.customerPO",
"obj.opportunity.totalSalesTax",
"obj.opportunity.probability",
"obj.opportunity.location",
"obj.opportunity.department",
"obj.opportunity.expectedCloseDate",
+17
View File
@@ -368,6 +368,23 @@ export function buildMockCWForecastItem(overrides: Record<string, any> = {}) {
};
}
/** Build a minimal Prisma-shaped GeneratedQuotes row. */
export function buildMockGeneratedQuote(overrides: Record<string, any> = {}) {
return {
id: "quote-1",
quoteRegenData: { theme: "default" },
quoteFile: new Uint8Array([0x25, 0x50, 0x44, 0x46]),
quoteFileName: "Quote-TestOpp.pdf",
opportunityId: "opp-1",
createdById: "user-1",
createdAt: new Date("2026-03-01"),
updatedAt: new Date("2026-03-01"),
opportunity: null,
createdBy: null,
...overrides,
};
}
/** Build a minimal Prisma-shaped CatalogItem row. */
export function buildMockCatalogItem(overrides: Record<string, any> = {}) {
return {
@@ -0,0 +1,223 @@
import { describe, test, expect } from "bun:test";
import { CatalogItemController } from "../../../src/controllers/CatalogItemController";
import { buildMockCatalogItem } from "../../setup";
describe("CatalogItemController", () => {
// -------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------
describe("constructor", () => {
test("sets core identification fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.id).toBe("cat-1");
expect(ctrl.cwCatalogId).toBe(500);
expect(ctrl.identifier).toBe("USW-Pro-24");
});
test("sets name and description fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.name).toBe("UniFi Switch Pro 24");
expect(ctrl.description).toBe("24-port managed switch");
expect(ctrl.customerDescription).toBe("Enterprise switch");
expect(ctrl.internalNotes).toBeNull();
});
test("sets category and subcategory fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.category).toBe("Technology");
expect(ctrl.categoryCwId).toBe(18);
expect(ctrl.subcategory).toBe("Network-Switch");
expect(ctrl.subcategoryCwId).toBe(112);
});
test("sets manufacturer fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.manufacturer).toBe("Ubiquiti");
expect(ctrl.manufactureCwId).toBe(248);
expect(ctrl.partNumber).toBe("USW-Pro-24");
});
test("sets vendor fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.vendorName).toBe("Ubiquiti Inc");
expect(ctrl.vendorSku).toBe("USW-Pro-24");
expect(ctrl.vendorCwId).toBe(100);
});
test("sets financial fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.price).toBe(500.0);
expect(ctrl.cost).toBe(360.0);
});
test("sets boolean flags", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.inactive).toBe(false);
expect(ctrl.salesTaxable).toBe(true);
});
test("sets inventory fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.onHand).toBe(10);
});
test("sets timestamps", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.cwLastUpdated).toBeInstanceOf(Date);
expect(ctrl.createdAt).toBeInstanceOf(Date);
expect(ctrl.updatedAt).toBeInstanceOf(Date);
});
test("builds linked items recursively", () => {
const linked = buildMockCatalogItem({
id: "cat-2",
name: "Linked Item",
linkedItems: undefined,
});
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked] }),
);
const items = ctrl.getLinkedItems();
expect(items).toHaveLength(1);
expect(items[0].id).toBe("cat-2");
expect(items[0]).toBeInstanceOf(CatalogItemController);
});
test("defaults to empty linked items when undefined", () => {
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: undefined }),
);
expect(ctrl.getLinkedItems()).toHaveLength(0);
});
test("handles null optional fields", () => {
const ctrl = new CatalogItemController(
buildMockCatalogItem({
description: null,
customerDescription: null,
identifier: null,
category: null,
categoryCwId: null,
subcategory: null,
subcategoryCwId: null,
manufacturer: null,
manufactureCwId: null,
partNumber: null,
vendorName: null,
vendorSku: null,
vendorCwId: null,
cwLastUpdated: null,
}),
);
expect(ctrl.description).toBeNull();
expect(ctrl.customerDescription).toBeNull();
expect(ctrl.identifier).toBeNull();
expect(ctrl.category).toBeNull();
expect(ctrl.manufacturer).toBeNull();
expect(ctrl.cwLastUpdated).toBeNull();
});
});
// -------------------------------------------------------------------
// getLinkedItems
// -------------------------------------------------------------------
describe("getLinkedItems()", () => {
test("returns empty array when no linked items", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.getLinkedItems()).toEqual([]);
});
test("returns array of CatalogItemController instances", () => {
const linked1 = buildMockCatalogItem({ id: "cat-2", name: "Item 2" });
const linked2 = buildMockCatalogItem({ id: "cat-3", name: "Item 3" });
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked1, linked2] }),
);
const items = ctrl.getLinkedItems();
expect(items).toHaveLength(2);
expect(items[0].name).toBe("Item 2");
expect(items[1].name).toBe("Item 3");
});
});
// -------------------------------------------------------------------
// toJson
// -------------------------------------------------------------------
describe("toJson()", () => {
test("returns all core fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.id).toBe("cat-1");
expect(json.cwCatalogId).toBe(500);
expect(json.identifier).toBe("USW-Pro-24");
expect(json.name).toBe("UniFi Switch Pro 24");
expect(json.description).toBe("24-port managed switch");
expect(json.customerDescription).toBe("Enterprise switch");
expect(json.internalNotes).toBeNull();
});
test("returns classification fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.category).toBe("Technology");
expect(json.categoryCwId).toBe(18);
expect(json.subcategory).toBe("Network-Switch");
expect(json.subcategoryCwId).toBe(112);
expect(json.manufacturer).toBe("Ubiquiti");
expect(json.manufactureCwId).toBe(248);
expect(json.partNumber).toBe("USW-Pro-24");
});
test("returns financial fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.price).toBe(500.0);
expect(json.cost).toBe(360.0);
});
test("returns boolean flags", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.inactive).toBe(false);
expect(json.salesTaxable).toBe(true);
});
test("returns timestamps", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.createdAt).toBeInstanceOf(Date);
expect(json.updatedAt).toBeInstanceOf(Date);
expect(json.cwLastUpdated).toBeInstanceOf(Date);
});
test("excludes linkedItems when includeLinkedItems not set", () => {
const linked = buildMockCatalogItem({ id: "cat-2" });
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked] }),
);
const json = ctrl.toJson();
expect(json.linkedItems).toBeUndefined();
});
test("includes linkedItems when includeLinkedItems is true", () => {
const linked = buildMockCatalogItem({ id: "cat-2", name: "Linked" });
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked] }),
);
const json = ctrl.toJson({ includeLinkedItems: true });
expect(json.linkedItems).toHaveLength(1);
expect(json.linkedItems[0].id).toBe("cat-2");
expect(json.linkedItems[0].name).toBe("Linked");
});
test("linked items toJson does not recursively include their linked items", () => {
const linked = buildMockCatalogItem({ id: "cat-2" });
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked] }),
);
const json = ctrl.toJson({ includeLinkedItems: true });
// Nested linked items called without opts, so linkedItems is undefined
expect(json.linkedItems[0].linkedItems).toBeUndefined();
});
});
});
@@ -138,6 +138,40 @@ describe("ForecastProductController", () => {
});
});
// -------------------------------------------------------------------
// applyProcurementCustomFields
// -------------------------------------------------------------------
describe("applyProcurementCustomFields()", () => {
test("sets productNarrative from custom field id 46", () => {
const ctrl = new ForecastProductController(buildMockCWForecastItem());
ctrl.applyProcurementCustomFields({
customFields: [{ id: 46, value: "Custom narrative text" }],
});
expect(ctrl.productNarrative).toBe("Custom narrative text");
});
test("does not overwrite productNarrative when field 46 is missing", () => {
const ctrl = new ForecastProductController(buildMockCWForecastItem());
ctrl.productNarrative = "existing";
ctrl.applyProcurementCustomFields({
customFields: [{ id: 99, value: "other" }],
});
expect(ctrl.productNarrative).toBe("existing");
});
test("handles empty customFields array", () => {
const ctrl = new ForecastProductController(buildMockCWForecastItem());
ctrl.applyProcurementCustomFields({ customFields: [] });
expect(ctrl.productNarrative).toBeNull();
});
test("handles undefined customFields", () => {
const ctrl = new ForecastProductController(buildMockCWForecastItem());
ctrl.applyProcurementCustomFields({});
expect(ctrl.productNarrative).toBeNull();
});
});
// -------------------------------------------------------------------
// applyInventoryData
// -------------------------------------------------------------------
@@ -203,6 +237,67 @@ describe("ForecastProductController", () => {
});
expect(ctrl.cancellationType).toBe("partial");
});
test("effectiveQuantity returns full quantity when not cancelled", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5 }),
);
expect(ctrl.effectiveQuantity).toBe(5);
});
test("effectiveQuantity returns reduced quantity for partial cancel", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 2 });
expect(ctrl.effectiveQuantity).toBe(3);
});
test("effectiveQuantity returns 0 for full cancellation", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 5 });
expect(ctrl.effectiveQuantity).toBe(0);
});
test("effectiveRevenue returns full revenue when not cancelled", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, revenue: 2500 }),
);
expect(ctrl.effectiveRevenue).toBe(2500);
});
test("effectiveRevenue returns proportional revenue for partial cancel", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, revenue: 2500 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 2 });
expect(ctrl.effectiveRevenue).toBe(1500);
});
test("effectiveRevenue returns 0 for full cancellation", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, revenue: 2500 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 5 });
expect(ctrl.effectiveRevenue).toBe(0);
});
test("effectiveCost returns full cost when not cancelled", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, cost: 1800 }),
);
expect(ctrl.effectiveCost).toBe(1800);
});
test("effectiveCost returns 0 for full cancellation", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, cost: 1800 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 5 });
expect(ctrl.effectiveCost).toBe(0);
});
});
// -------------------------------------------------------------------
@@ -279,5 +374,24 @@ describe("ForecastProductController", () => {
expect(json.subNumber).toBe(0);
expect(json.cwLastUpdated).toBeInstanceOf(Date);
});
test("includes customerDescription and productNarrative", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ customerDescription: "Customer desc" }),
);
const json = ctrl.toJson();
expect(json.customerDescription).toBe("Customer desc");
expect(json.productNarrative).toBeNull();
});
test("includes effective* computed fields", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, revenue: 2500, cost: 1800 }),
);
const json = ctrl.toJson();
expect(json.effectiveQuantity).toBe(5);
expect(json.effectiveRevenue).toBe(2500);
expect(json.effectiveCost).toBe(1800);
});
});
});
@@ -0,0 +1,171 @@
import { describe, test, expect } from "bun:test";
import { GeneratedQuoteController } from "../../../src/controllers/GeneratedQuoteController";
import {
buildMockGeneratedQuote,
buildMockOpportunity,
buildMockUser,
} from "../../setup";
describe("GeneratedQuoteController", () => {
// -------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------
describe("constructor", () => {
test("sets core identification fields", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
expect(ctrl.id).toBe("quote-1");
expect(ctrl.quoteFileName).toBe("Quote-TestOpp.pdf");
expect(ctrl.opportunityId).toBe("opp-1");
expect(ctrl.createdById).toBe("user-1");
});
test("sets quoteRegenData", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
expect(ctrl.quoteRegenData).toEqual({ theme: "default" });
});
test("sets quoteFile as Uint8Array", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
expect(ctrl.quoteFile).toBeInstanceOf(Uint8Array);
expect(ctrl.quoteFile.length).toBe(4);
});
test("sets timestamps", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
expect(ctrl.createdAt).toBeInstanceOf(Date);
expect(ctrl.updatedAt).toBeInstanceOf(Date);
});
test("wraps included opportunity in OpportunityController", () => {
const opp = buildMockOpportunity();
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ opportunity: opp }),
);
const json = ctrl.toJson({ includeOpportunity: true });
expect(json.opportunity).toBeDefined();
expect(json.opportunity.id).toBe("opp-1");
});
test("wraps included createdBy in UserController", () => {
const user = buildMockUser({ roles: [] });
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdBy: user }),
);
const json = ctrl.toJson({ includeCreatedBy: true });
expect(json.createdBy).toBeDefined();
expect(json.createdBy.id).toBe("user-1");
});
test("sets _opportunity to null when opportunity not included", () => {
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ opportunity: null }),
);
const json = ctrl.toJson({ includeOpportunity: true });
expect(json.opportunity).toBeUndefined();
});
test("sets _createdBy to null when createdBy not included", () => {
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdBy: null }),
);
const json = ctrl.toJson({ includeCreatedBy: true });
expect(json.createdBy).toBeUndefined();
});
test("handles null createdById", () => {
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdById: null }),
);
expect(ctrl.createdById).toBeNull();
});
});
// -------------------------------------------------------------------
// toJson
// -------------------------------------------------------------------
describe("toJson()", () => {
test("returns core fields by default", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson();
expect(json.id).toBe("quote-1");
expect(json.quoteFileName).toBe("Quote-TestOpp.pdf");
expect(json.opportunityId).toBe("opp-1");
expect(json.createdById).toBe("user-1");
expect(json.createdAt).toBeInstanceOf(Date);
expect(json.updatedAt).toBeInstanceOf(Date);
});
test("excludes quoteFile by default", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson();
expect(json.quoteFile).toBeUndefined();
});
test("excludes quoteRegenData by default", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson();
expect(json.quoteRegenData).toBeUndefined();
});
test("includes quoteRegenData when requested", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson({ includeRegenData: true });
expect(json.quoteRegenData).toEqual({ theme: "default" });
});
test("includes quoteFile as raw Uint8Array when includeFile is true", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson({ includeFile: true });
expect(json.quoteFile).toBeInstanceOf(Uint8Array);
});
test("includes quoteFile as base64 when both flags set", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson({
includeFile: true,
encodeFileAsBase64: true,
});
expect(typeof json.quoteFile).toBe("string");
// Should be valid base64
expect(() => Buffer.from(json.quoteFile, "base64")).not.toThrow();
});
test("excludes opportunity when not requested", () => {
const opp = buildMockOpportunity();
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ opportunity: opp }),
);
const json = ctrl.toJson();
expect(json.opportunity).toBeUndefined();
});
test("includes opportunity when requested and available", () => {
const opp = buildMockOpportunity();
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ opportunity: opp }),
);
const json = ctrl.toJson({ includeOpportunity: true });
expect(json.opportunity).toBeDefined();
expect(json.opportunity.name).toBe("Test Opportunity");
});
test("excludes createdBy when not requested", () => {
const user = buildMockUser({ roles: [] });
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdBy: user }),
);
const json = ctrl.toJson();
expect(json.createdBy).toBeUndefined();
});
test("includes createdBy when requested and available", () => {
const user = buildMockUser({ roles: [] });
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdBy: user }),
);
const json = ctrl.toJson({ includeCreatedBy: true });
expect(json.createdBy).toBeDefined();
expect(json.createdBy.name).toBe("Test User");
});
});
});
+241
View File
@@ -0,0 +1,241 @@
import { describe, test, expect, beforeEach } from "bun:test";
import { Hono } from "hono";
import { apiResponse } from "../../src/modules/api-utils/apiResponse";
import type { ContentfulStatusCode } from "hono/utils/http-status";
/**
* Tests for the CW callback route handler.
*
* We import the route handler and mount it on a Hono app to test via
* the app.request() convenience method.
*/
// We need to test the internal helper functions. Since they are not
// exported, we test them through the route handler's observable behavior.
import callbackRoute from "../../src/api/cw/callback";
describe("CW callback route handler", () => {
let app: Hono;
beforeEach(() => {
app = new Hono();
// Replicate the error handling from server.ts
app.onError((err, c) => {
if ((err as any).status) {
const body = apiResponse.error(err);
return c.json(body, body.status as ContentfulStatusCode);
}
return c.json(apiResponse.internalError(), 500);
});
app.route("/", callbackRoute);
// Clear the env var before each test
delete (process.env as Record<string, any>).CW_CALLBACK_SECRET;
});
// -------------------------------------------------------------------
// Secret validation
// -------------------------------------------------------------------
test("rejects when secret does not match CW_CALLBACK_SECRET", async () => {
process.env.CW_CALLBACK_SECRET = "correct-secret";
const res = await app.request("/callback/wrong-secret/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body.message).toContain("Invalid callback secret");
});
test("accepts when secret matches CW_CALLBACK_SECRET", async () => {
process.env.CW_CALLBACK_SECRET = "correct-secret";
const res = await app.request("/callback/correct-secret/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ Action: "updated", ID: 123 }),
});
expect(res.status).toBe(200);
});
test("accepts any secret when CW_CALLBACK_SECRET is not configured", async () => {
const res = await app.request("/callback/any-secret/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ Action: "created" }),
});
expect(res.status).toBe(200);
});
// -------------------------------------------------------------------
// Resource validation
// -------------------------------------------------------------------
test("accepts 'opportunity' resource", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.resource).toBe("opportunity");
});
test("accepts 'ticket' resource", async () => {
const res = await app.request("/callback/test/ticket", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.resource).toBe("ticket");
});
test("accepts 'company' resource", async () => {
const res = await app.request("/callback/test/company", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.resource).toBe("company");
});
test("accepts 'activity' resource", async () => {
const res = await app.request("/callback/test/activity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.resource).toBe("activity");
});
test("rejects invalid resource type", async () => {
const res = await app.request("/callback/test/invalidtype", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
// Zod validation should fail
expect(res.status).toBeGreaterThanOrEqual(400);
});
// -------------------------------------------------------------------
// Body parsing
// -------------------------------------------------------------------
test("parses JSON body fields", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Action: "updated",
Type: "opportunity",
ID: 42,
MemberId: "jroberts",
MessageId: "msg-123",
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary.action).toBe("updated");
expect(body.data.summary.type).toBe("opportunity");
expect(body.data.summary.id).toBe(42);
expect(body.data.summary.memberId).toBe("jroberts");
expect(body.data.summary.messageId).toBe("msg-123");
});
test("parses Entity field from JSON string", async () => {
const entity = {
CompanyName: "Acme Corp",
StatusName: "Active",
UpdatedBy: "admin",
};
const res = await app.request("/callback/test/company", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Action: "updated",
Entity: JSON.stringify(entity),
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary.entitySummary).toBe("Acme Corp");
expect(body.data.summary.entityStatus).toBe("Active");
expect(body.data.summary.entityUpdatedBy).toBe("admin");
});
test("handles Entity as inline object", async () => {
const res = await app.request("/callback/test/company", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Action: "created",
Entity: { CompanyName: "Direct Corp", Status: "New" },
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary.entitySummary).toBe("Direct Corp");
expect(body.data.summary.entityStatus).toBe("New");
});
test("returns secretValidated field based on env presence", async () => {
delete (process.env as Record<string, any>).CW_CALLBACK_SECRET;
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body = await res.json();
expect(body.data.secretValidated).toBe(false);
process.env.CW_CALLBACK_SECRET = "secret";
const res2 = await app.request("/callback/secret/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body2 = await res2.json();
expect(body2.data.secretValidated).toBe(true);
});
test("returns receivedAt timestamp", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body = await res.json();
expect(body.data.receivedAt).toBeDefined();
// Should be a valid ISO date string
expect(new Date(body.data.receivedAt).toISOString()).toBe(
body.data.receivedAt,
);
});
test("handles non-JSON body gracefully", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: "this is not json",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary).toBeNull();
});
test("handles empty body gracefully", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary).toBeNull();
});
});
+184
View File
@@ -0,0 +1,184 @@
import { describe, test, expect, mock } from "bun:test";
import { attachCwConcurrencyLimiter } from "../../src/modules/cw-utils/cwConcurrencyLimiter";
/**
* Build a minimal fake Axios instance with interceptor registration.
* Collect registered interceptors so we can invoke them in tests.
*/
function createMockAxios() {
const requestHandlers: Array<(config: any) => any> = [];
const responseSuccessHandlers: Array<(res: any) => any> = [];
const responseErrorHandlers: Array<(err: any) => any> = [];
return {
interceptors: {
request: {
use(fn: (config: any) => any) {
requestHandlers.push(fn);
},
},
response: {
use(onSuccess: (res: any) => any, onError: (err: any) => any) {
responseSuccessHandlers.push(onSuccess);
responseErrorHandlers.push(onError);
},
},
},
_requestHandlers: requestHandlers,
_responseSuccessHandlers: responseSuccessHandlers,
_responseErrorHandlers: responseErrorHandlers,
};
}
describe("attachCwConcurrencyLimiter", () => {
test("attaches request and response interceptors", () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any);
expect(api._requestHandlers).toHaveLength(1);
expect(api._responseSuccessHandlers).toHaveLength(1);
expect(api._responseErrorHandlers).toHaveLength(1);
});
test("request interceptor resolves immediately when under limit", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 2);
const config = { url: "/test" };
const result = await api._requestHandlers[0](config);
expect(result).toEqual(config);
});
test("response success interceptor passes through response", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 2);
// Acquire a slot first
await api._requestHandlers[0]({});
const response = { data: "ok", status: 200 };
const result = api._responseSuccessHandlers[0](response);
expect(result).toEqual(response);
});
test("response error interceptor rejects with the error and releases slot", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 2);
// Acquire a slot
await api._requestHandlers[0]({});
const error = new Error("fail");
try {
await api._responseErrorHandlers[0](error);
expect(true).toBe(false); // should not reach
} catch (e) {
expect(e).toBe(error);
}
});
test("queues requests when at max concurrency", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 1);
// First request acquires the single slot
await api._requestHandlers[0]({ id: 1 });
// Second request should be queued (not resolved yet)
let secondResolved = false;
const secondPromise = api._requestHandlers[0]({ id: 2 }).then(
(config: any) => {
secondResolved = true;
return config;
},
);
// Give the event loop a tick — second should still be pending
await new Promise((r) => setTimeout(r, 10));
expect(secondResolved).toBe(false);
// Release the first slot via response handler
api._responseSuccessHandlers[0]({ status: 200 });
// Now the second should resolve
const result = await secondPromise;
expect(secondResolved).toBe(true);
expect(result).toEqual({ id: 2 });
});
test("multiple requests under limit all proceed immediately", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 3);
const results = await Promise.all([
api._requestHandlers[0]({ id: 1 }),
api._requestHandlers[0]({ id: 2 }),
api._requestHandlers[0]({ id: 3 }),
]);
expect(results).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
});
test("FIFO ordering: queued requests resolve in order", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 1);
// Fill the single slot
await api._requestHandlers[0]({ id: 1 });
const order: number[] = [];
const p2 = api._requestHandlers[0]({ id: 2 }).then(() => order.push(2));
const p3 = api._requestHandlers[0]({ id: 3 }).then(() => order.push(3));
// Release slot → should wake request 2
api._responseSuccessHandlers[0]({});
await p2;
// Release again → should wake request 3
api._responseSuccessHandlers[0]({});
await p3;
expect(order).toEqual([2, 3]);
});
test("error release also unblocks queued requests", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 1);
await api._requestHandlers[0]({ id: 1 });
let secondResolved = false;
const secondPromise = api._requestHandlers[0]({ id: 2 }).then(() => {
secondResolved = true;
});
// Release via error path
try {
await api._responseErrorHandlers[0](new Error("fail"));
} catch {}
await secondPromise;
expect(secondResolved).toBe(true);
});
test("defaults to max 6 concurrency", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any); // default max = 6
// 6 requests should all proceed immediately
const promises = [];
for (let i = 0; i < 6; i++) {
promises.push(api._requestHandlers[0]({ id: i }));
}
const results = await Promise.all(promises);
expect(results).toHaveLength(6);
// 7th should queue
let seventhResolved = false;
const seventh = api._requestHandlers[0]({ id: 7 }).then(() => {
seventhResolved = true;
});
await new Promise((r) => setTimeout(r, 10));
expect(seventhResolved).toBe(false);
// Release one to unblock
api._responseSuccessHandlers[0]({});
await seventh;
expect(seventhResolved).toBe(true);
});
});
+261
View File
@@ -0,0 +1,261 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { GeneratedQuoteController } from "../../src/controllers/GeneratedQuoteController";
import {
buildMockGeneratedQuote,
buildMockOpportunity,
buildMockUser,
} from "../setup";
/**
* The Proxy-based prisma mock in setup.ts creates a fresh mock() for every
* property access, so we cannot use `(prisma.x.findFirst as any).mockReturnValueOnce()`
* because the manager's import access gets a different mock.
*
* Instead, we mock the entire constants module per-test with stable mock functions.
*/
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy(
{},
{
get() {
return mock(() => Promise.resolve(null));
},
},
);
},
},
);
}
describe("generatedQuotes manager", () => {
// -------------------------------------------------------------------
// fetch
// -------------------------------------------------------------------
describe("fetch()", () => {
test("returns a GeneratedQuoteController when found", async () => {
const mockData = buildMockGeneratedQuote();
const findFirst = mock(() => Promise.resolve(mockData));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findFirst },
}),
}));
// Re-import to pick up the fresh mock
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.fetch("quote-1");
expect(result).toBeInstanceOf(GeneratedQuoteController);
expect(result.id).toBe("quote-1");
});
test("throws GenericError with 404 when not found", async () => {
const findFirst = mock(() => Promise.resolve(null));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findFirst },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
try {
await generatedQuotes.fetch("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("GeneratedQuoteNotFound");
expect(e.status).toBe(404);
}
});
});
// -------------------------------------------------------------------
// fetchByOpportunity
// -------------------------------------------------------------------
describe("fetchByOpportunity()", () => {
test("returns array of GeneratedQuoteController", async () => {
const rows = [
buildMockGeneratedQuote({ id: "q-1" }),
buildMockGeneratedQuote({ id: "q-2" }),
];
const findMany = mock(() => Promise.resolve(rows));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findMany },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.fetchByOpportunity("opp-1");
expect(result).toHaveLength(2);
expect(result[0]).toBeInstanceOf(GeneratedQuoteController);
expect(result[0].id).toBe("q-1");
});
test("returns empty array when none found", async () => {
const findMany = mock(() => Promise.resolve([]));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findMany },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.fetchByOpportunity("opp-999");
expect(result).toEqual([]);
});
});
// -------------------------------------------------------------------
// fetchByCreator
// -------------------------------------------------------------------
describe("fetchByCreator()", () => {
test("returns array of GeneratedQuoteController", async () => {
const rows = [buildMockGeneratedQuote({ id: "q-1" })];
const findMany = mock(() => Promise.resolve(rows));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findMany },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.fetchByCreator("user-1");
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(GeneratedQuoteController);
});
});
// -------------------------------------------------------------------
// create
// -------------------------------------------------------------------
describe("create()", () => {
test("throws 404 when opportunity not found", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
opportunity: { findFirst: mock(() => Promise.resolve(null)) },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
try {
await generatedQuotes.create({
quoteRegenData: {},
quoteFile: Buffer.from("pdf"),
quoteFileName: "test.pdf",
opportunityId: "nonexistent-opp",
createdById: "user-1",
});
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("OpportunityNotFound");
expect(e.status).toBe(404);
}
});
test("throws 404 when user not found", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
opportunity: {
findFirst: mock(() => Promise.resolve({ id: "opp-1" })),
},
user: { findFirst: mock(() => Promise.resolve(null)) },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
try {
await generatedQuotes.create({
quoteRegenData: {},
quoteFile: Buffer.from("pdf"),
quoteFileName: "test.pdf",
opportunityId: "opp-1",
createdById: "nonexistent-user",
});
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("UserNotFound");
expect(e.status).toBe(404);
}
});
test("creates and returns GeneratedQuoteController", async () => {
const mockQuote = buildMockGeneratedQuote();
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
opportunity: {
findFirst: mock(() => Promise.resolve({ id: "opp-1" })),
},
user: { findFirst: mock(() => Promise.resolve({ id: "user-1" })) },
generatedQuotes: { create: mock(() => Promise.resolve(mockQuote)) },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.create({
quoteRegenData: { theme: "default" },
quoteFile: Buffer.from("pdf-content"),
quoteFileName: "Quote.pdf",
opportunityId: "opp-1",
createdById: "user-1",
});
expect(result).toBeInstanceOf(GeneratedQuoteController);
expect(result.id).toBe("quote-1");
});
});
// -------------------------------------------------------------------
// delete
// -------------------------------------------------------------------
describe("delete()", () => {
test("throws 404 when quote not found", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findFirst: mock(() => Promise.resolve(null)) },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
try {
await generatedQuotes.delete("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("GeneratedQuoteNotFound");
expect(e.status).toBe(404);
}
});
test("deletes successfully when quote exists", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: {
findFirst: mock(() => Promise.resolve({ id: "quote-1" })),
delete: mock(() => Promise.resolve(undefined)),
},
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
await expect(generatedQuotes.delete("quote-1")).resolves.toBeUndefined();
});
});
});
+8
View File
@@ -32,9 +32,17 @@ describe("PermissionNodes", () => {
expect(PERMISSION_NODES).toHaveProperty("global");
expect(PERMISSION_NODES).toHaveProperty("company");
expect(PERMISSION_NODES).toHaveProperty("credential");
expect(PERMISSION_NODES).toHaveProperty("credentialType");
expect(PERMISSION_NODES).toHaveProperty("sales");
expect(PERMISSION_NODES).toHaveProperty("procurement");
expect(PERMISSION_NODES).toHaveProperty("objectTypes");
expect(PERMISSION_NODES).toHaveProperty("permission");
expect(PERMISSION_NODES).toHaveProperty("role");
expect(PERMISSION_NODES).toHaveProperty("user");
expect(PERMISSION_NODES).toHaveProperty("uiNavigation");
expect(PERMISSION_NODES).toHaveProperty("adminUI");
expect(PERMISSION_NODES).toHaveProperty("cwCallbacks");
expect(PERMISSION_NODES).toHaveProperty("unifi");
});
test("each category has name, description, and permissions", () => {
+1
View File
@@ -61,6 +61,7 @@ describe("procurement manager", () => {
expect(typeof procurement.fetchDistinctValues).toBe("function");
expect(typeof procurement.linkItems).toBe("function");
expect(typeof procurement.unlinkItems).toBe("function");
expect(typeof procurement.fetchLaborCatalogItems).toBe("function");
});
test("fetchPages calls through without errors (mock absorbs)", async () => {
+160
View File
@@ -0,0 +1,160 @@
import { describe, test, expect } from "bun:test";
import { generateSecureValue } from "../../src/modules/credentials/generateSecureValue";
import { readSecureValue } from "../../src/modules/credentials/readSecureValue";
/**
* Tests for the secure value encryption/decryption round-trip.
*
* The test setup.ts mocks the constants module with a matching RSA key pair,
* so generateSecureValue (uses public key) and readSecureValue (uses private key)
* will work correctly together.
*/
describe("generateSecureValue", () => {
test("returns an object with encrypted and hash fields", () => {
const result = generateSecureValue("my-secret-password");
expect(result).toHaveProperty("encrypted");
expect(result).toHaveProperty("hash");
expect(typeof result.encrypted).toBe("string");
expect(typeof result.hash).toBe("string");
});
test("encrypted is a valid base64 string", () => {
const result = generateSecureValue("test-value");
expect(() => Buffer.from(result.encrypted, "base64")).not.toThrow();
const decoded = Buffer.from(result.encrypted, "base64");
expect(decoded.length).toBeGreaterThan(0);
});
test("hash follows BLAKE2s format", () => {
const result = generateSecureValue("test-value");
expect(result.hash).toMatch(/^BLAKE2s\$/);
const parts = result.hash.split("$");
expect(parts[0]).toBe("BLAKE2s");
expect(parts[1].length).toBeGreaterThan(0); // hex hash
});
test("different inputs produce different encrypted values", () => {
const a = generateSecureValue("password-a");
const b = generateSecureValue("password-b");
expect(a.encrypted).not.toBe(b.encrypted);
});
test("different inputs produce different hashes", () => {
const a = generateSecureValue("password-a");
const b = generateSecureValue("password-b");
expect(a.hash).not.toBe(b.hash);
});
test("encrypts empty string without error", () => {
const result = generateSecureValue("");
expect(result.encrypted.length).toBeGreaterThan(0);
expect(result.hash.length).toBeGreaterThan(0);
});
test("encrypts special characters", () => {
const result = generateSecureValue('p@$$w0rd!#%^&*(){}[]|\\:";<>?,./~`');
expect(result.encrypted.length).toBeGreaterThan(0);
});
test("encrypts Unicode content", () => {
const result = generateSecureValue("密码测试 🔐");
expect(result.encrypted.length).toBeGreaterThan(0);
});
});
describe("readSecureValue", () => {
test("decrypts a value encrypted by generateSecureValue", () => {
const original = "my-secret-password";
const { encrypted } = generateSecureValue(original);
const decrypted = readSecureValue(encrypted);
expect(decrypted).toBe(original);
});
test("decrypts empty string", () => {
const { encrypted } = generateSecureValue("");
const decrypted = readSecureValue(encrypted);
expect(decrypted).toBe("");
});
test("decrypts special characters", () => {
const original = 'p@$$w0rd!#%^&*(){}[]|\\:";<>?,./~`';
const { encrypted } = generateSecureValue(original);
const decrypted = readSecureValue(encrypted);
expect(decrypted).toBe(original);
});
test("decrypts Unicode content", () => {
const original = "密码测试 🔐";
const { encrypted } = generateSecureValue(original);
const decrypted = readSecureValue(encrypted);
expect(decrypted).toBe(original);
});
test("validates hash when provided and correct", () => {
const original = "test-value";
const { encrypted, hash } = generateSecureValue(original);
const decrypted = readSecureValue(encrypted, hash);
expect(decrypted).toBe(original);
});
test("throws when hash validation fails", () => {
const original = "test-value";
const { encrypted } = generateSecureValue(original);
const badHash =
"BLAKE2s$0000000000000000000000000000000000000000000000000000000000000000$salt";
expect(() => readSecureValue(encrypted, badHash)).toThrow(
"Secure value hash validation failed",
);
});
test("throws GenericError on invalid encrypted content", () => {
try {
readSecureValue("not-valid-encrypted-data");
expect(true).toBe(false); // should not reach
} catch (e: any) {
expect(e.name).toBe("SecureValueDecryptionError");
expect(e.status).toBe(422);
}
});
test("throws GenericError with descriptive message on key mismatch", () => {
try {
readSecureValue("dGhpcyBpcyBub3QgZW5jcnlwdGVk"); // base64 but not RSA
expect(true).toBe(false);
} catch (e: any) {
expect(e.message).toContain("Unable to decrypt secure value");
expect(e.cause).toContain("RSA key mismatch");
}
});
test("skips hash validation when hash is not provided", () => {
const original = "no-hash-check";
const { encrypted } = generateSecureValue(original);
// Should not throw even though no hash is passed
const decrypted = readSecureValue(encrypted);
expect(decrypted).toBe(original);
});
});
describe("generateSecureValue + readSecureValue round-trip", () => {
test("round-trips various string types", () => {
const testValues = [
"simple",
"",
"a".repeat(100),
"line1\nline2\ttab",
'{"json": true}',
"null",
"undefined",
"0",
" leading-trailing ",
];
for (const original of testValues) {
const { encrypted, hash } = generateSecureValue(original);
const decrypted = readSecureValue(encrypted, hash);
expect(decrypted).toBe(original);
}
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, test, expect } from "bun:test";
import jwt from "jsonwebtoken";
import crypto from "crypto";
import { signPermissions } from "../../src/modules/permission-utils/signPermissions";
// The test setup mocks the constants module with a test RSA key pair.
// signPermissions imports permissionsPrivateKey from constants, which
// is the test private key generated in setup.ts. We can verify with the
// corresponding test public key.
// Re-generate the same key pair used in setup.ts from constants mock:
// The mock uses _testPrivateKey/_testPublicKey, but we can decode the JWT
// to verify its contents without needing the public key directly.
describe("signPermissions", () => {
test("returns a string (JWT)", () => {
const token = signPermissions({
issuer: "optima",
subject: "user-1",
permissions: ["company.fetch.many"],
});
expect(typeof token).toBe("string");
expect(token.split(".")).toHaveLength(3); // header.payload.signature
});
test("JWT payload contains permissions array", () => {
const permissions = ["company.fetch.many", "credential.read"];
const token = signPermissions({
issuer: "optima",
subject: "user-1",
permissions,
});
const decoded = jwt.decode(token) as any;
expect(decoded.permissions).toEqual(permissions);
});
test("JWT contains issuer claim", () => {
const token = signPermissions({
issuer: "optima",
subject: "user-1",
permissions: ["*"],
});
const decoded = jwt.decode(token, { complete: true }) as any;
expect(decoded.payload.iss).toBe("optima");
});
test("JWT contains subject claim", () => {
const token = signPermissions({
issuer: "optima",
subject: "role-abc",
permissions: ["*"],
});
const decoded = jwt.decode(token, { complete: true }) as any;
expect(decoded.payload.sub).toBe("role-abc");
});
test("JWT uses RS256 algorithm", () => {
const token = signPermissions({
issuer: "optima",
subject: "user-1",
permissions: [],
});
const decoded = jwt.decode(token, { complete: true }) as any;
expect(decoded.header.alg).toBe("RS256");
});
test("handles empty permissions array", () => {
const token = signPermissions({
issuer: "optima",
subject: "user-1",
permissions: [],
});
const decoded = jwt.decode(token) as any;
expect(decoded.permissions).toEqual([]);
});
test("handles large permissions arrays", () => {
const permsList = Array.from({ length: 100 }, (_, i) => `perm.${i}`);
const token = signPermissions({
issuer: "optima",
subject: "user-1",
permissions: permsList,
});
const decoded = jwt.decode(token) as any;
expect(decoded.permissions).toHaveLength(100);
expect(decoded.permissions[0]).toBe("perm.0");
expect(decoded.permissions[99]).toBe("perm.99");
});
});
+245
View File
@@ -0,0 +1,245 @@
import { describe, test, expect, mock } from "bun:test";
import { withCwRetry } from "../../src/modules/cw-utils/withCwRetry";
describe("withCwRetry", () => {
// -------------------------------------------------------------------
// Successful execution
// -------------------------------------------------------------------
test("returns result on first successful call", async () => {
const fn = mock(() => Promise.resolve("ok"));
const result = await withCwRetry(fn);
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
});
test("returns result when call succeeds after transient failure", async () => {
let attempt = 0;
const fn = mock(() => {
attempt++;
if (attempt === 1) {
const err: any = new Error("timeout");
err.isAxiosError = true;
err.code = "ETIMEDOUT";
return Promise.reject(err);
}
return Promise.resolve("recovered");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("recovered");
expect(fn).toHaveBeenCalledTimes(2);
});
// -------------------------------------------------------------------
// Retry on transient errors
// -------------------------------------------------------------------
test("retries on ECONNABORTED", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls < 3) {
const err: any = new Error("aborted");
err.isAxiosError = true;
err.code = "ECONNABORTED";
return Promise.reject(err);
}
return Promise.resolve("done");
});
const result = await withCwRetry(fn, { maxAttempts: 3, baseDelayMs: 1 });
expect(result).toBe("done");
expect(fn).toHaveBeenCalledTimes(3);
});
test("retries on ECONNRESET", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls === 1) {
const err: any = new Error("reset");
err.isAxiosError = true;
err.code = "ECONNRESET";
return Promise.reject(err);
}
return Promise.resolve("ok");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("ok");
});
test("retries on ECONNREFUSED", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls === 1) {
const err: any = new Error("refused");
err.isAxiosError = true;
err.code = "ECONNREFUSED";
return Promise.reject(err);
}
return Promise.resolve("ok");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("ok");
});
test("retries on ERR_NETWORK", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls === 1) {
const err: any = new Error("network");
err.isAxiosError = true;
err.code = "ERR_NETWORK";
return Promise.reject(err);
}
return Promise.resolve("ok");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("ok");
});
test("retries on ENETUNREACH", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls === 1) {
const err: any = new Error("unreachable");
err.isAxiosError = true;
err.code = "ENETUNREACH";
return Promise.reject(err);
}
return Promise.resolve("ok");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("ok");
});
test("retries on 5xx server errors", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls === 1) {
const err: any = new Error("server error");
err.isAxiosError = true;
err.response = { status: 502 };
return Promise.reject(err);
}
return Promise.resolve("ok");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("ok");
});
test("retries on 500 status", async () => {
let calls = 0;
const fn = mock(() => {
calls++;
if (calls === 1) {
const err: any = new Error("internal");
err.isAxiosError = true;
err.response = { status: 500 };
return Promise.reject(err);
}
return Promise.resolve("ok");
});
const result = await withCwRetry(fn, { baseDelayMs: 1 });
expect(result).toBe("ok");
});
// -------------------------------------------------------------------
// Non-retryable errors
// -------------------------------------------------------------------
test("does not retry on 4xx errors", async () => {
const err: any = new Error("not found");
err.isAxiosError = true;
err.response = { status: 404 };
const fn = mock(() => Promise.reject(err));
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toThrow();
expect(fn).toHaveBeenCalledTimes(1);
});
test("does not retry on 400 errors", async () => {
const err: any = new Error("bad request");
err.isAxiosError = true;
err.response = { status: 400 };
const fn = mock(() => Promise.reject(err));
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toThrow();
expect(fn).toHaveBeenCalledTimes(1);
});
test("does not retry on non-Axios errors", async () => {
const fn = mock(() => Promise.reject(new Error("generic")));
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toThrow(
"generic",
);
expect(fn).toHaveBeenCalledTimes(1);
});
test("does not retry on non-object errors", async () => {
const fn = mock(() => Promise.reject("string error"));
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toBe(
"string error",
);
expect(fn).toHaveBeenCalledTimes(1);
});
// -------------------------------------------------------------------
// Max attempts exhausted
// -------------------------------------------------------------------
test("throws after maxAttempts exhausted", async () => {
const err: any = new Error("timeout");
err.isAxiosError = true;
err.code = "ETIMEDOUT";
const fn = mock(() => Promise.reject(err));
await expect(
withCwRetry(fn, { maxAttempts: 2, baseDelayMs: 1 }),
).rejects.toThrow();
expect(fn).toHaveBeenCalledTimes(2);
});
test("throws the last error when retries exhausted", async () => {
const err: any = new Error("persistent timeout");
err.isAxiosError = true;
err.code = "ETIMEDOUT";
const fn = mock(() => Promise.reject(err));
try {
await withCwRetry(fn, { maxAttempts: 3, baseDelayMs: 1 });
expect(true).toBe(false); // should not reach
} catch (e: any) {
expect(e.message).toBe("persistent timeout");
}
});
// -------------------------------------------------------------------
// Options
// -------------------------------------------------------------------
test("defaults to 3 maxAttempts", async () => {
const err: any = new Error("timeout");
err.isAxiosError = true;
err.code = "ETIMEDOUT";
const fn = mock(() => Promise.reject(err));
await expect(withCwRetry(fn, { baseDelayMs: 1 })).rejects.toThrow();
expect(fn).toHaveBeenCalledTimes(3);
});
test("accepts custom maxAttempts", async () => {
const err: any = new Error("timeout");
err.isAxiosError = true;
err.code = "ETIMEDOUT";
const fn = mock(() => Promise.reject(err));
await expect(
withCwRetry(fn, { maxAttempts: 5, baseDelayMs: 1 }),
).rejects.toThrow();
expect(fn).toHaveBeenCalledTimes(5);
});
test("maxAttempts of 1 means no retries", async () => {
const err: any = new Error("timeout");
err.isAxiosError = true;
err.code = "ETIMEDOUT";
const fn = mock(() => Promise.reject(err));
await expect(
withCwRetry(fn, { maxAttempts: 1, baseDelayMs: 1 }),
).rejects.toThrow();
expect(fn).toHaveBeenCalledTimes(1);
});
});