feat: restructure sales, add PDF quote generation and WebSocket support

This commit is contained in:
2026-03-06 23:25:37 -06:00
parent 4efca6cc53
commit 1907bb433b
73 changed files with 8115 additions and 170 deletions
+407 -1
View File
@@ -8,6 +8,111 @@ This document provides a comprehensive overview of all API routes available in t
http://localhost:3000/v1 http://localhost:3000/v1
``` ```
## WebSocket Base URL
```
ws://localhost:3000/socket.io/
```
---
## WebSocket Events
### Secure Namespace
**Namespace:** `/secure`
**Authentication Required:** Yes
Provide authorization during Socket.IO handshake using one of:
- `handshake.headers.authorization` as `Bearer <accessToken>` or `Key <accessToken>`
- `handshake.auth.authorization` as `Bearer <accessToken>` or `Key <accessToken>`
- `handshake.auth.token` as raw access token
When connected, server emits `secure:connected` with `{ userId }`.
If the linked session expires, is invalidated, or no longer exists, server emits `secure:session:expired` and disconnects.
### Register Live Quote Preview Channel
**Client → Server Event:** `opp:live_quote_preview`
Registers a per-opportunity live preview channel.
**Required Permissions:** `sales.opportunity.fetch`
**Payload:**
```json
{
"id": "<opportunity-id>"
}
```
**Ack Response (success):**
```json
{
"ok": true,
"event": "opp:live_quote_preview:<id>:data"
}
```
**Ack Response (error):**
```json
{
"ok": false,
"error": "Missing opportunity id"
}
```
Server may also emit `opp:live_quote_preview:ready` and `opp:live_quote_preview:error`.
### Live Quote Preview Data Channel
**Client → Server Event:** `opp:live_quote_preview:<id>:data`
After registration, clients send live quote preview options on this dynamic event.
The server will:
1. Relay the incoming `...:data` payload to other sockets in the same preview room.
2. Fetch the target opportunity.
3. Generate a preview PDF using `generateQuote` with `showPreview: true`.
4. Emit the generated preview on `opp:live_quote_preview:<id>:preview`.
**Payload (all optional):**
```json
{
"lineItemPricing": true,
"includeQuoteNarrative": true,
"includeItemNarratives": true,
"logoPath": "/absolute/or/runtime/path/to/logo.png"
}
```
**Server → Client Event:** `opp:live_quote_preview:<id>:preview`
```json
{
"id": "<opportunity-id>",
"mimeType": "application/pdf",
"contentBase64": "JVBERi0xLjQKJ..."
}
```
**Server → Client Event (error):** `opp:live_quote_preview:error`
```json
{
"id": "<opportunity-id>",
"message": "Failed to generate live quote preview"
}
```
--- ---
## Object Type Field-Level Gating ## Object Type Field-Level Gating
@@ -2874,6 +2979,7 @@ Fetch a paginated list of opportunities. Supports search. Each opportunity inclu
"site": { "id": 50, "name": "Main Office" }, "site": { "id": 50, "name": "Main Office" },
"customerPO": null, "customerPO": null,
"totalSalesTax": 0, "totalSalesTax": 0,
"probability": 50,
"location": { "id": 1, "name": "Murray" }, "location": { "id": 1, "name": "Murray" },
"department": { "id": 5, "name": "Sales" }, "department": { "id": 5, "name": "Sales" },
"expectedCloseDate": "2026-04-15T00:00:00.000Z", "expectedCloseDate": "2026-04-15T00:00:00.000Z",
@@ -2988,7 +3094,9 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The
**Query Parameters:** **Query Parameters:**
- `include` _(optional)_ — Comma-separated list of sub-resources to embed in the response. Supported values: `notes`, `contacts`, `products`. Example: `?include=notes,contacts,products`. Sub-resources are fetched in parallel and added as top-level keys on the response object. When `notes` is included, `data.notes` is returned as an array of note objects and the original opportunity text note is preserved under `data.opportunityNoteText`. - `include` _(optional)_ — Comma-separated list of sub-resources to embed in the response. Supported values: `notes`, `contacts`, `products`, `quotes`. Example: `?include=notes,contacts,products,quotes`. Sub-resources are fetched in parallel and added as top-level keys on the response object. When `notes` is included, `data.notes` is returned as an array of note objects and the original opportunity text note is preserved under `data.opportunityNoteText`. When `quotes` is included, `data.quotes` is returned as an array of committed quote metadata objects (PDF file data is excluded).
- `includeRegenData` _(optional)_ — When `"true"`, includes `quoteRegenData` on each quote object (applies to `?include=quotes`). Default: `false`.
- `includeRegenParams` _(optional)_ — When `"true"`, includes `quoteRegenParams` on each quote object (applies to `?include=quotes`). Default: `false`.
**Response:** **Response:**
@@ -3062,6 +3170,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The
}, },
"customerPO": null, "customerPO": null,
"totalSalesTax": 0, "totalSalesTax": 0,
"probability": 50,
"location": { "id": 1, "name": "Murray" }, "location": { "id": 1, "name": "Murray" },
"department": { "id": 5, "name": "Sales" }, "department": { "id": 5, "name": "Sales" },
"expectedCloseDate": "2026-04-15T00:00:00.000Z", "expectedCloseDate": "2026-04-15T00:00:00.000Z",
@@ -3183,6 +3292,7 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise. The
"site": { "id": 50, "name": "Main Office" }, "site": { "id": 50, "name": "Main Office" },
"customerPO": null, "customerPO": null,
"totalSalesTax": 0, "totalSalesTax": 0,
"probability": 50,
"location": { "id": 1, "name": "Murray" }, "location": { "id": 1, "name": "Murray" },
"department": { "id": 5, "name": "Sales" }, "department": { "id": 5, "name": "Sales" },
"expectedCloseDate": "2026-04-15T00:00:00.000Z", "expectedCloseDate": "2026-04-15T00:00:00.000Z",
@@ -3835,6 +3945,302 @@ Add a labor line item to an opportunity using one of the two canonical labor cat
--- ---
### Fetch Committed Quotes
**GET** `/sales/opportunities/:identifier/quotes`
Fetch all committed (finalized) quotes for an opportunity, ordered by most recent first.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.quote.fetch`
**Path Parameters:**
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
**Query Parameters:**
- `includeRegenData` _(optional)_ — When `"true"`, includes `quoteRegenData` on each quote object. Default: `false`.
- `includeRegenParams` _(optional)_ — When `"true"`, includes `quoteRegenParams` on each quote object. Default: `false`.
**Response:**
```json
{
"status": 200,
"message": "Committed quotes fetched successfully!",
"data": [
{
"id": "clx9abc123...",
"quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
"quoteRegenHash": "a1b2c3d4e5f6...",
"opportunityId": "clx9xyz789...",
"createdById": "clx9user456...",
"quoteRegenData": "(included when ?includeRegenData=true)",
"quoteRegenParams": "(included when ?includeRegenParams=true)",
"createdAt": "2026-03-06T12:00:00.000Z",
"updatedAt": "2026-03-06T12:00:00.000Z"
}
],
"successful": true
}
```
---
### Commit Quote
**POST** `/sales/opportunities/:identifier/quote/commit`
Generate a finalized (non-preview) quote PDF for an opportunity and store it in the database with regeneration metadata and creator attribution.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.quote.commit`
**Path Parameters:**
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
**Request Body (optional):**
```json
{
"lineItemPricing": true,
"includeQuoteNarrative": true,
"includeItemNarratives": true
}
```
| Field | Type | Required | Default | Description |
| ----------------------- | ------- | -------- | ------- | ---------------------------------------------- |
| `lineItemPricing` | boolean | No | `true` | Include per-line-item pricing in the quote PDF |
| `includeQuoteNarrative` | boolean | No | `true` | Include the quote-level narrative section |
| `includeItemNarratives` | boolean | No | `true` | Include per-item narrative sections |
**Response:**
```json
{
"status": 201,
"message": "Quote committed successfully!",
"data": {
"id": "clx9abc123...",
"quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
"quoteRegenHash": "a1b2c3d4e5f6...",
"opportunityId": "clx9xyz789...",
"createdById": "clx9user456...",
"quoteRegenData": {
"options": {
"lineItemPricing": true,
"includeQuoteNarrative": true,
"includeItemNarratives": true
},
"opportunity": {
"id": "clx9xyz789...",
"cwOpportunityId": 12345,
"name": "Network Refresh Acme Corp",
"totalSalesTax": 245.5,
"contactName": "Jane Smith",
"companyName": "Acme Corp"
},
"customer": {
"preparedFor": "Jane Smith",
"companyName": "Acme Corp",
"primaryContact": {
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@acme.com",
"phone": "801-555-1234"
},
"siteAddress": ["123 Main St", "Salt Lake City UT 84101"],
"companyAddress": ["456 Corporate Blvd", "Murray UT 84107"]
},
"salesRep": {
"name": "John Roberts",
"email": "jroberts@example.com"
},
"quoteNarrative": "Full network infrastructure refresh including...",
"products": [
{
"cwForecastId": 101,
"forecastDescription": "UniFi U6-Pro AP",
"productDescription": "UniFi U6-Pro Access Point",
"customerDescription": "Wireless access point",
"productNarrative": "Install and validate onsite",
"productClass": "Product",
"forecastType": "Products",
"catalogItem": { "id": 500, "identifier": "U6-PRO" },
"quantity": 4,
"effectiveQuantity": 4,
"revenue": 800.0,
"cost": 520.0,
"margin": 280.0,
"percentage": 35.0,
"includeFlag": true,
"taxableFlag": true,
"recurringFlag": false,
"recurringRevenue": 0,
"recurringCost": 0,
"sequenceNumber": 1,
"cancelledFlag": false,
"cancellationType": null,
"quantityCancelled": 0,
"cancelledReason": null,
"cancelledDate": null
}
],
"snapshotTimestamp": "2026-03-06T12:00:00.000Z"
},
"quoteRegenParams": {
"opportunityId": "clx9xyz789...",
"cwOpportunityId": 12345
},
"createdAt": "2026-03-06T12:00:00.000Z",
"updatedAt": "2026-03-06T12:00:00.000Z"
},
"successful": true
}
```
---
### Preview Quote
**GET** `/sales/opportunities/:identifier/quote/:quoteId/preview`
Regenerate a preview-stamped version of an existing committed quote PDF using its stored generation parameters. The PDF is not persisted — it is returned as a base64-encoded string.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.quote.preview`
**Path Parameters:**
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
- `quoteId` — The generated quote's internal ID (uuid)
**Response:**
```json
{
"status": 200,
"message": "Quote preview generated successfully!",
"data": {
"mimeType": "application/pdf",
"contentBase64": "JVBERi0xLjQK... (base64-encoded PDF)"
},
"successful": true
}
```
---
### Download Quote
**GET** `/sales/opportunities/:identifier/quote/:quoteId/download`
Download a committed quote PDF by its ID. Returns the PDF file as a base64-encoded string. Each call automatically records a download entry with the timestamp, user info, and fetch action in the quote's `downloads` array. Download-time metadata (downloadedAt, downloadedBy) is also injected into the PDF's document properties.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.quote.download`
**Path Parameters:**
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
- `quoteId` — The generated quote's internal ID (uuid)
**Query Parameters:**
- `fetchAction` **(required)** — The action being performed. Must be one of: `download`, `print`. Tracked in the download record for audit purposes.
**Response:**
```json
{
"status": 200,
"message": "Quote downloaded successfully!",
"data": {
"id": "a1b2c3d4-e5f6-...",
"quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
"mimeType": "application/pdf",
"contentBase64": "JVBERi0xLjQK... (base64-encoded PDF)"
},
"successful": true
}
```
**Download Record Shape (stored in `downloads` JSON array):**
```json
{
"downloadedAt": "2026-03-06T15:30:00.000Z",
"fetchAction": "download",
"userId": "clx9user456...",
"userName": "John Roberts",
"userEmail": "jroberts@example.com"
}
```
**Error Responses:**
- `400` — Missing or invalid `fetchAction` query parameter.
- `404` — Generated quote not found.
---
### Fetch Quote Download History
**GET** `/sales/opportunities/:identifier/quotes/downloads`
Fetch download/print history for all committed quotes on an opportunity. Returns an array of quote summaries, each containing the full `downloads` array with every download/print record. This is an admin-level route intended for audit and tracking purposes.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.quote.fetch_downloads`
**Path Parameters:**
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
**Response:**
```json
{
"status": 200,
"message": "Download logs fetched successfully!",
"data": [
{
"quoteId": "a1b2c3d4-e5f6-...",
"quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
"createdById": "clx9user123...",
"createdAt": "2026-03-06T12:00:00.000Z",
"downloads": [
{
"downloadedAt": "2026-03-06T15:30:00.000Z",
"fetchAction": "download",
"userId": "clx9user456...",
"userName": "John Roberts",
"userEmail": "jroberts@example.com"
},
{
"downloadedAt": "2026-03-06T16:00:00.000Z",
"fetchAction": "print",
"userId": "clx9user789...",
"userName": "Jane Smith",
"userEmail": "jsmith@example.com"
}
]
}
],
"successful": true
}
```
---
### Get Opportunity Notes ### Get Opportunity Notes
**GET** `/sales/opportunities/:identifier/notes` **GET** `/sales/opportunities/:identifier/notes`
+19 -11
View File
@@ -136,18 +136,25 @@ Admin-specific UI permissions that control visibility and data loading for admin
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. 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.
**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 | | 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` | 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/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.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/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` | | `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.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.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/[id]/updateNote.ts](src/api/sales/[id]/updateNote.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/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.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/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts), [src/api/sales/[id]/updateProduct.ts](src/api/sales/[id]/updateProduct.ts), [src/api/sales/[id]/cancelProduct.ts](src/api/sales/[id]/cancelProduct.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/[id]/addProduct.ts](src/api/sales/[id]/addProduct.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/[id]/addSpecialOrderProduct.ts](src/api/sales/[id]/addSpecialOrderProduct.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/[id]/addLabor.ts](src/api/sales/[id]/addLabor.ts), [src/api/sales/[id]/laborOptions.ts](src/api/sales/[id]/laborOptions.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` |
<details> <details>
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary> <summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
@@ -349,6 +356,7 @@ All fetch and fetchAll routes gate response object keys using `processObjectValu
| `obj.opportunity.site` | View site | | `obj.opportunity.site` | View site |
| `obj.opportunity.customerPO` | View customer PO | | `obj.opportunity.customerPO` | View customer PO |
| `obj.opportunity.totalSalesTax` | View total sales tax | | `obj.opportunity.totalSalesTax` | View total sales tax |
| `obj.opportunity.probability` | View probability percentage |
| `obj.opportunity.location` | View location | | `obj.opportunity.location` | View location |
| `obj.opportunity.department` | View department | | `obj.opportunity.department` | View department |
| `obj.opportunity.expectedCloseDate` | View expected close date | | `obj.opportunity.expectedCloseDate` | View expected close date |
+58
View File
@@ -19,6 +19,8 @@
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"keypair": "^1.0.4", "keypair": "^1.0.4",
"pdf-lib": "^1.17.1",
"pdfmake": "^0.3.5",
"prisma": "^7.3.0", "prisma": "^7.3.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"zod": "^4.3.6", "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=="], "@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/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=="], "@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=="], "@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/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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"cuid": ["cuid@3.0.0", "", {}, "sha512-WZYYkHdIDnaxdeP8Misq3Lah5vFjJwGuItJuV+tvMafosMzw0nF297T7mrm8IOWiPJkV6gc7sa8pzx27+w25Zg=="], "cuid": ["cuid@3.0.0", "", {}, "sha512-WZYYkHdIDnaxdeP8Misq3Lah5vFjJwGuItJuV+tvMafosMzw0nF297T7mrm8IOWiPJkV6gc7sa8pzx27+w25Zg=="],
@@ -164,6 +180,8 @@
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], "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=="], "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=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "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=="], "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=="], "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=="], "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": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
"postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
@@ -350,12 +386,16 @@
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="], "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=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "@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=="], "nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="],
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "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=="], "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()
+5
View File
@@ -67,3 +67,8 @@ export type SecureValue = Prisma.SecureValueModel
* *
*/ */
export type Credential = Prisma.CredentialModel export type Credential = Prisma.CredentialModel
/**
* Model GeneratedQuotes
*
*/
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
+5
View File
@@ -89,3 +89,8 @@ export type SecureValue = Prisma.SecureValueModel
* *
*/ */
export type Credential = Prisma.CredentialModel export type Credential = Prisma.CredentialModel
/**
* Model GeneratedQuotes
*
*/
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
+34
View File
@@ -280,6 +280,23 @@ export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
_max?: Prisma.NestedJsonFilter<$PrismaModel> _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> = { export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
@@ -521,4 +538,21 @@ export type NestedJsonFilterBase<$PrismaModel = never> = {
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter 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
+110 -2
View File
@@ -393,7 +393,8 @@ export const ModelName = {
Opportunity: 'Opportunity', Opportunity: 'Opportunity',
CredentialType: 'CredentialType', CredentialType: 'CredentialType',
SecureValue: 'SecureValue', SecureValue: 'SecureValue',
Credential: 'Credential' Credential: 'Credential',
GeneratedQuotes: 'GeneratedQuotes'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -409,7 +410,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { meta: {
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential" modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential" | "generatedQuotes"
txIsolationLevel: TransactionIsolationLevel txIsolationLevel: TransactionIsolationLevel
} }
model: { model: {
@@ -1153,6 +1154,80 @@ 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
}
}
}
} }
} & { } & {
other: { other: {
@@ -1322,6 +1397,7 @@ export const OpportunityScalarFieldEnum = {
siteName: 'siteName', siteName: 'siteName',
customerPO: 'customerPO', customerPO: 'customerPO',
totalSalesTax: 'totalSalesTax', totalSalesTax: 'totalSalesTax',
probability: 'probability',
locationName: 'locationName', locationName: 'locationName',
locationCwId: 'locationCwId', locationCwId: 'locationCwId',
departmentName: 'departmentName', departmentName: 'departmentName',
@@ -1384,6 +1460,23 @@ export const CredentialScalarFieldEnum = {
export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof 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 SortOrder = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -1506,6 +1599,20 @@ export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'J
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'> 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 * Batch Payload for updateMany & deleteMany & createMany
*/ */
@@ -1611,6 +1718,7 @@ export type GlobalOmitConfig = {
credentialType?: Prisma.CredentialTypeOmit credentialType?: Prisma.CredentialTypeOmit
secureValue?: Prisma.SecureValueOmit secureValue?: Prisma.SecureValueOmit
credential?: Prisma.CredentialOmit credential?: Prisma.CredentialOmit
generatedQuotes?: Prisma.GeneratedQuotesOmit
} }
/* Types for Logging */ /* Types for Logging */
@@ -60,7 +60,8 @@ export const ModelName = {
Opportunity: 'Opportunity', Opportunity: 'Opportunity',
CredentialType: 'CredentialType', CredentialType: 'CredentialType',
SecureValue: 'SecureValue', SecureValue: 'SecureValue',
Credential: 'Credential' Credential: 'Credential',
GeneratedQuotes: 'GeneratedQuotes'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -209,6 +210,7 @@ export const OpportunityScalarFieldEnum = {
siteName: 'siteName', siteName: 'siteName',
customerPO: 'customerPO', customerPO: 'customerPO',
totalSalesTax: 'totalSalesTax', totalSalesTax: 'totalSalesTax',
probability: 'probability',
locationName: 'locationName', locationName: 'locationName',
locationCwId: 'locationCwId', locationCwId: 'locationCwId',
departmentName: 'departmentName', departmentName: 'departmentName',
@@ -271,6 +273,23 @@ export const CredentialScalarFieldEnum = {
export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof 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 SortOrder = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
+1
View File
@@ -18,4 +18,5 @@ export type * from './models/Opportunity.ts'
export type * from './models/CredentialType.ts' export type * from './models/CredentialType.ts'
export type * from './models/SecureValue.ts' export type * from './models/SecureValue.ts'
export type * from './models/Credential.ts' export type * from './models/Credential.ts'
export type * from './models/GeneratedQuotes.ts'
export type * from './commonInputTypes.ts' export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
+351 -1
View File
@@ -40,6 +40,7 @@ export type OpportunityAvgAggregateOutputType = {
contactCwId: number | null contactCwId: number | null
siteCwId: number | null siteCwId: number | null
totalSalesTax: number | null totalSalesTax: number | null
probability: number | null
locationCwId: number | null locationCwId: number | null
departmentCwId: number | null departmentCwId: number | null
closedByCwId: number | null closedByCwId: number | null
@@ -60,6 +61,7 @@ export type OpportunitySumAggregateOutputType = {
contactCwId: number | null contactCwId: number | null
siteCwId: number | null siteCwId: number | null
totalSalesTax: number | null totalSalesTax: number | null
probability: number | null
locationCwId: number | null locationCwId: number | null
departmentCwId: number | null departmentCwId: number | null
closedByCwId: number | null closedByCwId: number | null
@@ -98,6 +100,7 @@ export type OpportunityMinAggregateOutputType = {
siteName: string | null siteName: string | null
customerPO: string | null customerPO: string | null
totalSalesTax: number | null totalSalesTax: number | null
probability: number | null
locationName: string | null locationName: string | null
locationCwId: number | null locationCwId: number | null
departmentName: string | null departmentName: string | null
@@ -147,6 +150,7 @@ export type OpportunityMaxAggregateOutputType = {
siteName: string | null siteName: string | null
customerPO: string | null customerPO: string | null
totalSalesTax: number | null totalSalesTax: number | null
probability: number | null
locationName: string | null locationName: string | null
locationCwId: number | null locationCwId: number | null
departmentName: string | null departmentName: string | null
@@ -196,6 +200,7 @@ export type OpportunityCountAggregateOutputType = {
siteName: number siteName: number
customerPO: number customerPO: number
totalSalesTax: number totalSalesTax: number
probability: number
locationName: number locationName: number
locationCwId: number locationCwId: number
departmentName: number departmentName: number
@@ -230,6 +235,7 @@ export type OpportunityAvgAggregateInputType = {
contactCwId?: true contactCwId?: true
siteCwId?: true siteCwId?: true
totalSalesTax?: true totalSalesTax?: true
probability?: true
locationCwId?: true locationCwId?: true
departmentCwId?: true departmentCwId?: true
closedByCwId?: true closedByCwId?: true
@@ -250,6 +256,7 @@ export type OpportunitySumAggregateInputType = {
contactCwId?: true contactCwId?: true
siteCwId?: true siteCwId?: true
totalSalesTax?: true totalSalesTax?: true
probability?: true
locationCwId?: true locationCwId?: true
departmentCwId?: true departmentCwId?: true
closedByCwId?: true closedByCwId?: true
@@ -288,6 +295,7 @@ export type OpportunityMinAggregateInputType = {
siteName?: true siteName?: true
customerPO?: true customerPO?: true
totalSalesTax?: true totalSalesTax?: true
probability?: true
locationName?: true locationName?: true
locationCwId?: true locationCwId?: true
departmentName?: true departmentName?: true
@@ -337,6 +345,7 @@ export type OpportunityMaxAggregateInputType = {
siteName?: true siteName?: true
customerPO?: true customerPO?: true
totalSalesTax?: true totalSalesTax?: true
probability?: true
locationName?: true locationName?: true
locationCwId?: true locationCwId?: true
departmentName?: true departmentName?: true
@@ -386,6 +395,7 @@ export type OpportunityCountAggregateInputType = {
siteName?: true siteName?: true
customerPO?: true customerPO?: true
totalSalesTax?: true totalSalesTax?: true
probability?: true
locationName?: true locationName?: true
locationCwId?: true locationCwId?: true
departmentName?: true departmentName?: true
@@ -523,6 +533,7 @@ export type OpportunityGroupByOutputType = {
siteName: string | null siteName: string | null
customerPO: string | null customerPO: string | null
totalSalesTax: number totalSalesTax: number
probability: number
locationName: string | null locationName: string | null
locationCwId: number | null locationCwId: number | null
departmentName: string | null departmentName: string | null
@@ -596,6 +607,7 @@ export type OpportunityWhereInput = {
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
probability?: Prisma.FloatFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
@@ -612,6 +624,7 @@ export type OpportunityWhereInput = {
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
} }
@@ -647,6 +660,7 @@ export type OpportunityOrderByWithRelationInput = {
siteName?: Prisma.SortOrderInput | Prisma.SortOrder siteName?: Prisma.SortOrderInput | Prisma.SortOrder
customerPO?: Prisma.SortOrderInput | Prisma.SortOrder customerPO?: Prisma.SortOrderInput | Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrderInput | Prisma.SortOrder locationName?: Prisma.SortOrderInput | Prisma.SortOrder
locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder
departmentName?: Prisma.SortOrderInput | Prisma.SortOrder departmentName?: Prisma.SortOrderInput | Prisma.SortOrder
@@ -663,6 +677,7 @@ export type OpportunityOrderByWithRelationInput = {
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
company?: Prisma.CompanyOrderByWithRelationInput company?: Prisma.CompanyOrderByWithRelationInput
} }
@@ -701,6 +716,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
probability?: Prisma.FloatFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
@@ -717,6 +733,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
}, "id" | "cwOpportunityId"> }, "id" | "cwOpportunityId">
@@ -752,6 +769,7 @@ export type OpportunityOrderByWithAggregationInput = {
siteName?: Prisma.SortOrderInput | Prisma.SortOrder siteName?: Prisma.SortOrderInput | Prisma.SortOrder
customerPO?: Prisma.SortOrderInput | Prisma.SortOrder customerPO?: Prisma.SortOrderInput | Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrderInput | Prisma.SortOrder locationName?: Prisma.SortOrderInput | Prisma.SortOrder
locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder
departmentName?: Prisma.SortOrderInput | Prisma.SortOrder departmentName?: Prisma.SortOrderInput | Prisma.SortOrder
@@ -810,6 +828,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
siteName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null siteName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null customerPO?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number totalSalesTax?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number
probability?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null locationName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null locationCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null departmentName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
@@ -860,6 +879,7 @@ export type OpportunityCreateInput = {
siteName?: string | null siteName?: string | null
customerPO?: string | null customerPO?: string | null
totalSalesTax?: number totalSalesTax?: number
probability?: number
locationName?: string | null locationName?: string | null
locationCwId?: number | null locationCwId?: number | null
departmentName?: string | null departmentName?: string | null
@@ -875,6 +895,7 @@ export type OpportunityCreateInput = {
cwLastUpdated?: Date | string | null cwLastUpdated?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
} }
@@ -910,6 +931,7 @@ export type OpportunityUncheckedCreateInput = {
siteName?: string | null siteName?: string | null
customerPO?: string | null customerPO?: string | null
totalSalesTax?: number totalSalesTax?: number
probability?: number
locationName?: string | null locationName?: string | null
locationCwId?: number | null locationCwId?: number | null
departmentName?: string | null departmentName?: string | null
@@ -926,6 +948,7 @@ export type OpportunityUncheckedCreateInput = {
cwLastUpdated?: Date | string | null cwLastUpdated?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
} }
export type OpportunityUpdateInput = { export type OpportunityUpdateInput = {
@@ -960,6 +983,7 @@ export type OpportunityUpdateInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -975,6 +999,7 @@ export type OpportunityUpdateInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
} }
@@ -1010,6 +1035,7 @@ export type OpportunityUncheckedUpdateInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1026,6 +1052,7 @@ export type OpportunityUncheckedUpdateInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
} }
export type OpportunityCreateManyInput = { export type OpportunityCreateManyInput = {
@@ -1060,6 +1087,7 @@ export type OpportunityCreateManyInput = {
siteName?: string | null siteName?: string | null
customerPO?: string | null customerPO?: string | null
totalSalesTax?: number totalSalesTax?: number
probability?: number
locationName?: string | null locationName?: string | null
locationCwId?: number | null locationCwId?: number | null
departmentName?: string | null departmentName?: string | null
@@ -1110,6 +1138,7 @@ export type OpportunityUpdateManyMutationInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1159,6 +1188,7 @@ export type OpportunityUncheckedUpdateManyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1227,6 +1257,7 @@ export type OpportunityCountOrderByAggregateInput = {
siteName?: Prisma.SortOrder siteName?: Prisma.SortOrder
customerPO?: Prisma.SortOrder customerPO?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrder locationName?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder locationCwId?: Prisma.SortOrder
departmentName?: Prisma.SortOrder departmentName?: Prisma.SortOrder
@@ -1259,6 +1290,7 @@ export type OpportunityAvgOrderByAggregateInput = {
contactCwId?: Prisma.SortOrder contactCwId?: Prisma.SortOrder
siteCwId?: Prisma.SortOrder siteCwId?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder locationCwId?: Prisma.SortOrder
departmentCwId?: Prisma.SortOrder departmentCwId?: Prisma.SortOrder
closedByCwId?: Prisma.SortOrder closedByCwId?: Prisma.SortOrder
@@ -1297,6 +1329,7 @@ export type OpportunityMaxOrderByAggregateInput = {
siteName?: Prisma.SortOrder siteName?: Prisma.SortOrder
customerPO?: Prisma.SortOrder customerPO?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrder locationName?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder locationCwId?: Prisma.SortOrder
departmentName?: Prisma.SortOrder departmentName?: Prisma.SortOrder
@@ -1346,6 +1379,7 @@ export type OpportunityMinOrderByAggregateInput = {
siteName?: Prisma.SortOrder siteName?: Prisma.SortOrder
customerPO?: Prisma.SortOrder customerPO?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrder locationName?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder locationCwId?: Prisma.SortOrder
departmentName?: Prisma.SortOrder departmentName?: Prisma.SortOrder
@@ -1377,12 +1411,18 @@ export type OpportunitySumOrderByAggregateInput = {
contactCwId?: Prisma.SortOrder contactCwId?: Prisma.SortOrder
siteCwId?: Prisma.SortOrder siteCwId?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder locationCwId?: Prisma.SortOrder
departmentCwId?: Prisma.SortOrder departmentCwId?: Prisma.SortOrder
closedByCwId?: Prisma.SortOrder closedByCwId?: Prisma.SortOrder
productSequence?: Prisma.SortOrder productSequence?: Prisma.SortOrder
} }
export type OpportunityScalarRelationFilter = {
is?: Prisma.OpportunityWhereInput
isNot?: Prisma.OpportunityWhereInput
}
export type OpportunityCreateNestedManyWithoutCompanyInput = { export type OpportunityCreateNestedManyWithoutCompanyInput = {
create?: Prisma.XOR<Prisma.OpportunityCreateWithoutCompanyInput, Prisma.OpportunityUncheckedCreateWithoutCompanyInput> | Prisma.OpportunityCreateWithoutCompanyInput[] | Prisma.OpportunityUncheckedCreateWithoutCompanyInput[] create?: Prisma.XOR<Prisma.OpportunityCreateWithoutCompanyInput, Prisma.OpportunityUncheckedCreateWithoutCompanyInput> | Prisma.OpportunityCreateWithoutCompanyInput[] | Prisma.OpportunityUncheckedCreateWithoutCompanyInput[]
connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutCompanyInput | Prisma.OpportunityCreateOrConnectWithoutCompanyInput[] connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutCompanyInput | Prisma.OpportunityCreateOrConnectWithoutCompanyInput[]
@@ -1434,6 +1474,20 @@ export type OpportunityUpdateproductSequenceInput = {
push?: number | number[] 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 = { export type OpportunityCreateWithoutCompanyInput = {
id?: string id?: string
cwOpportunityId: number cwOpportunityId: number
@@ -1466,6 +1520,7 @@ export type OpportunityCreateWithoutCompanyInput = {
siteName?: string | null siteName?: string | null
customerPO?: string | null customerPO?: string | null
totalSalesTax?: number totalSalesTax?: number
probability?: number
locationName?: string | null locationName?: string | null
locationCwId?: number | null locationCwId?: number | null
departmentName?: string | null departmentName?: string | null
@@ -1481,6 +1536,7 @@ export type OpportunityCreateWithoutCompanyInput = {
cwLastUpdated?: Date | string | null cwLastUpdated?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
} }
export type OpportunityUncheckedCreateWithoutCompanyInput = { export type OpportunityUncheckedCreateWithoutCompanyInput = {
@@ -1515,6 +1571,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
siteName?: string | null siteName?: string | null
customerPO?: string | null customerPO?: string | null
totalSalesTax?: number totalSalesTax?: number
probability?: number
locationName?: string | null locationName?: string | null
locationCwId?: number | null locationCwId?: number | null
departmentName?: string | null departmentName?: string | null
@@ -1530,6 +1587,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
cwLastUpdated?: Date | string | null cwLastUpdated?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
} }
export type OpportunityCreateOrConnectWithoutCompanyInput = { export type OpportunityCreateOrConnectWithoutCompanyInput = {
@@ -1593,6 +1651,7 @@ export type OpportunityScalarWhereInput = {
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
probability?: Prisma.FloatFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
@@ -1611,6 +1670,226 @@ export type OpportunityScalarWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string 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 = { export type OpportunityCreateManyCompanyInput = {
id?: string id?: string
cwOpportunityId: number cwOpportunityId: number
@@ -1643,6 +1922,7 @@ export type OpportunityCreateManyCompanyInput = {
siteName?: string | null siteName?: string | null
customerPO?: string | null customerPO?: string | null
totalSalesTax?: number totalSalesTax?: number
probability?: number
locationName?: string | null locationName?: string | null
locationCwId?: number | null locationCwId?: number | null
departmentName?: string | null departmentName?: string | null
@@ -1692,6 +1972,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1707,6 +1988,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
} }
export type OpportunityUncheckedUpdateWithoutCompanyInput = { export type OpportunityUncheckedUpdateWithoutCompanyInput = {
@@ -1741,6 +2023,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1756,6 +2039,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
} }
export type OpportunityUncheckedUpdateManyWithoutCompanyInput = { export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
@@ -1790,6 +2074,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | 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<{ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -1841,6 +2155,7 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
siteName?: boolean siteName?: boolean
customerPO?: boolean customerPO?: boolean
totalSalesTax?: boolean totalSalesTax?: boolean
probability?: boolean
locationName?: boolean locationName?: boolean
locationCwId?: boolean locationCwId?: boolean
departmentName?: boolean departmentName?: boolean
@@ -1857,7 +2172,9 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
cwLastUpdated?: boolean cwLastUpdated?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs> company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
_count?: boolean | Prisma.OpportunityCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["opportunity"]> }, ExtArgs["result"]["opportunity"]>
export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ 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 siteName?: boolean
customerPO?: boolean customerPO?: boolean
totalSalesTax?: boolean totalSalesTax?: boolean
probability?: boolean
locationName?: boolean locationName?: boolean
locationCwId?: boolean locationCwId?: boolean
departmentName?: boolean departmentName?: boolean
@@ -1943,6 +2261,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
siteName?: boolean siteName?: boolean
customerPO?: boolean customerPO?: boolean
totalSalesTax?: boolean totalSalesTax?: boolean
probability?: boolean
locationName?: boolean locationName?: boolean
locationCwId?: boolean locationCwId?: boolean
departmentName?: boolean departmentName?: boolean
@@ -1994,6 +2313,7 @@ export type OpportunitySelectScalar = {
siteName?: boolean siteName?: boolean
customerPO?: boolean customerPO?: boolean
totalSalesTax?: boolean totalSalesTax?: boolean
probability?: boolean
locationName?: boolean locationName?: boolean
locationCwId?: boolean locationCwId?: boolean
departmentName?: boolean departmentName?: boolean
@@ -2012,9 +2332,11 @@ export type OpportunitySelectScalar = {
updatedAt?: boolean 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> = { 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> company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
_count?: boolean | Prisma.OpportunityCountOutputTypeDefaultArgs<ExtArgs>
} }
export type OpportunityIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type OpportunityIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs> 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> = { export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "Opportunity" name: "Opportunity"
objects: { objects: {
generatedQuotes: Prisma.$GeneratedQuotesPayload<ExtArgs>[]
company: Prisma.$CompanyPayload<ExtArgs> | null company: Prisma.$CompanyPayload<ExtArgs> | null
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
@@ -2060,6 +2383,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
siteName: string | null siteName: string | null
customerPO: string | null customerPO: string | null
totalSalesTax: number totalSalesTax: number
probability: number
locationName: string | null locationName: string | null
locationCwId: number | null locationCwId: number | null
departmentName: string | 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> { 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" 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> 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. * 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 siteName: Prisma.FieldRef<"Opportunity", 'String'>
readonly customerPO: Prisma.FieldRef<"Opportunity", 'String'> readonly customerPO: Prisma.FieldRef<"Opportunity", 'String'>
readonly totalSalesTax: Prisma.FieldRef<"Opportunity", 'Float'> readonly totalSalesTax: Prisma.FieldRef<"Opportunity", 'Float'>
readonly probability: Prisma.FieldRef<"Opportunity", 'Float'>
readonly locationName: Prisma.FieldRef<"Opportunity", 'String'> readonly locationName: Prisma.FieldRef<"Opportunity", 'String'>
readonly locationCwId: Prisma.FieldRef<"Opportunity", 'Int'> readonly locationCwId: Prisma.FieldRef<"Opportunity", 'Int'>
readonly departmentName: Prisma.FieldRef<"Opportunity", 'String'> readonly departmentName: Prisma.FieldRef<"Opportunity", 'String'>
@@ -2942,6 +3268,30 @@ export type OpportunityDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.I
limit?: number 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 * Opportunity.company
*/ */
+157
View File
@@ -240,6 +240,7 @@ export type UserWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
roles?: Prisma.RoleListRelationFilter roles?: Prisma.RoleListRelationFilter
sessions?: Prisma.SessionListRelationFilter sessions?: Prisma.SessionListRelationFilter
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
} }
export type UserOrderByWithRelationInput = { export type UserOrderByWithRelationInput = {
@@ -257,6 +258,7 @@ export type UserOrderByWithRelationInput = {
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
roles?: Prisma.RoleOrderByRelationAggregateInput roles?: Prisma.RoleOrderByRelationAggregateInput
sessions?: Prisma.SessionOrderByRelationAggregateInput sessions?: Prisma.SessionOrderByRelationAggregateInput
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
} }
export type UserWhereUniqueInput = Prisma.AtLeast<{ export type UserWhereUniqueInput = Prisma.AtLeast<{
@@ -277,6 +279,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
roles?: Prisma.RoleListRelationFilter roles?: Prisma.RoleListRelationFilter
sessions?: Prisma.SessionListRelationFilter sessions?: Prisma.SessionListRelationFilter
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
}, "id" | "login" | "email" | "userId"> }, "id" | "login" | "email" | "userId">
export type UserOrderByWithAggregationInput = { export type UserOrderByWithAggregationInput = {
@@ -330,6 +333,7 @@ export type UserCreateInput = {
updatedAt?: Date | string updatedAt?: Date | string
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
} }
export type UserUncheckedCreateInput = { export type UserUncheckedCreateInput = {
@@ -347,6 +351,7 @@ export type UserUncheckedCreateInput = {
updatedAt?: Date | string updatedAt?: Date | string
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
} }
export type UserUpdateInput = { export type UserUpdateInput = {
@@ -364,6 +369,7 @@ export type UserUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
} }
export type UserUncheckedUpdateInput = { export type UserUncheckedUpdateInput = {
@@ -381,6 +387,7 @@ export type UserUncheckedUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
} }
export type UserCreateManyInput = { export type UserCreateManyInput = {
@@ -488,6 +495,11 @@ export type UserOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder _count?: Prisma.SortOrder
} }
export type UserNullableScalarRelationFilter = {
is?: Prisma.UserWhereInput | null
isNot?: Prisma.UserWhereInput | null
}
export type UserCreateNestedOneWithoutSessionsInput = { export type UserCreateNestedOneWithoutSessionsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutSessionsInput, Prisma.UserUncheckedCreateWithoutSessionsInput> create?: Prisma.XOR<Prisma.UserCreateWithoutSessionsInput, Prisma.UserUncheckedCreateWithoutSessionsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutSessionsInput connectOrCreate?: Prisma.UserCreateOrConnectWithoutSessionsInput
@@ -544,6 +556,22 @@ export type UserUncheckedUpdateManyWithoutRolesNestedInput = {
deleteMany?: Prisma.UserScalarWhereInput | Prisma.UserScalarWhereInput[] 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 = { export type UserCreateWithoutSessionsInput = {
id?: string id?: string
permissions?: string | null permissions?: string | null
@@ -558,6 +586,7 @@ export type UserCreateWithoutSessionsInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
} }
export type UserUncheckedCreateWithoutSessionsInput = { export type UserUncheckedCreateWithoutSessionsInput = {
@@ -574,6 +603,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
} }
export type UserCreateOrConnectWithoutSessionsInput = { export type UserCreateOrConnectWithoutSessionsInput = {
@@ -606,6 +636,7 @@ export type UserUpdateWithoutSessionsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
} }
export type UserUncheckedUpdateWithoutSessionsInput = { export type UserUncheckedUpdateWithoutSessionsInput = {
@@ -622,6 +653,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
} }
export type UserCreateWithoutRolesInput = { export type UserCreateWithoutRolesInput = {
@@ -638,6 +670,7 @@ export type UserCreateWithoutRolesInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
} }
export type UserUncheckedCreateWithoutRolesInput = { export type UserUncheckedCreateWithoutRolesInput = {
@@ -654,6 +687,7 @@ export type UserUncheckedCreateWithoutRolesInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
} }
export type UserCreateOrConnectWithoutRolesInput = { export type UserCreateOrConnectWithoutRolesInput = {
@@ -695,6 +729,90 @@ export type UserScalarWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string 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 = { export type UserUpdateWithoutRolesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string id?: Prisma.StringFieldUpdateOperationsInput | string
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -709,6 +827,7 @@ export type UserUpdateWithoutRolesInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
} }
export type UserUncheckedUpdateWithoutRolesInput = { export type UserUncheckedUpdateWithoutRolesInput = {
@@ -725,6 +844,7 @@ export type UserUncheckedUpdateWithoutRolesInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
} }
export type UserUncheckedUpdateManyWithoutRolesInput = { export type UserUncheckedUpdateManyWithoutRolesInput = {
@@ -750,11 +870,13 @@ export type UserUncheckedUpdateManyWithoutRolesInput = {
export type UserCountOutputType = { export type UserCountOutputType = {
roles: number roles: number
sessions: number sessions: number
generatedQuotes: number
} }
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
roles?: boolean | UserCountOutputTypeCountRolesArgs roles?: boolean | UserCountOutputTypeCountRolesArgs
sessions?: boolean | UserCountOutputTypeCountSessionsArgs sessions?: boolean | UserCountOutputTypeCountSessionsArgs
generatedQuotes?: boolean | UserCountOutputTypeCountGeneratedQuotesArgs
} }
/** /**
@@ -781,6 +903,13 @@ export type UserCountOutputTypeCountSessionsArgs<ExtArgs extends runtime.Types.E
where?: Prisma.SessionWhereInput 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<{ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -797,6 +926,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
updatedAt?: boolean updatedAt?: boolean
roles?: boolean | Prisma.User$rolesArgs<ExtArgs> roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs> sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]> }, 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> = { export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
roles?: boolean | Prisma.User$rolesArgs<ExtArgs> roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs> sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
} }
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {} 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: { objects: {
roles: Prisma.$RolePayload<ExtArgs>[] roles: Prisma.$RolePayload<ExtArgs>[]
sessions: Prisma.$SessionPayload<ExtArgs>[] sessions: Prisma.$SessionPayload<ExtArgs>[]
generatedQuotes: Prisma.$GeneratedQuotesPayload<ExtArgs>[]
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
@@ -1269,6 +1401,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
readonly [Symbol.toStringTag]: "PrismaPromise" 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> 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> 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. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @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[] 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 * 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", "ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"keypair": "^1.0.4", "keypair": "^1.0.4",
"pdf-lib": "^1.17.1",
"pdfmake": "^0.3.5",
"prisma": "^7.3.0", "prisma": "^7.3.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"zod": "^4.3.6", "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;
+26
View File
@@ -43,6 +43,7 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
generatedQuotes GeneratedQuotes[]
} }
model Role { model Role {
@@ -130,6 +131,8 @@ model Opportunity {
name String name String
notes String? notes String?
generatedQuotes GeneratedQuotes[]
// Stage / status / priority / type / rating stored as JSON references // Stage / status / priority / type / rating stored as JSON references
// so we don't need separate lookup tables for CW enums // so we don't need separate lookup tables for CW enums
typeName String? typeName String?
@@ -165,6 +168,7 @@ model Opportunity {
// Financials // Financials
totalSalesTax Float @default(0) totalSalesTax Float @default(0)
probability Float @default(0)
// Location / department // Location / department
locationName String? locationName String?
@@ -244,3 +248,25 @@ model Credential {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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
}
+7 -1
View File
@@ -18,8 +18,9 @@ import {
fetchAndCacheProducts, fetchAndCacheProducts,
fetchAndCacheSite, fetchAndCacheSite,
} from "../../../modules/cache/opportunityCache"; } 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( export default createRoute(
"get", "get",
["/opportunities/:identifier"], ["/opportunities/:identifier"],
@@ -140,6 +141,11 @@ export default createRoute(
.fetchProducts() .fetchProducts()
.then((products) => products.map((p) => p.toJson())); .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 keys = Object.keys(subResourcePromises);
const results = await Promise.all(keys.map((k) => subResourcePromises[k])); const results = await Promise.all(keys.map((k) => subResourcePromises[k]));
+28 -18
View File
@@ -1,22 +1,27 @@
import { default as fetchAll } from "./fetchAll"; import { default as fetchAll } from "./opportunities/fetchAll";
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes"; import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
import { default as count } from "./count"; import { default as count } from "./opportunities/count";
import { default as fetch } from "./[id]/fetch"; import { default as fetch } from "./opportunities/[id]/fetch";
import { default as refresh } from "./[id]/refresh"; import { default as refresh } from "./opportunities/[id]/refresh";
import { default as products } from "./[id]/products"; import { default as products } from "./opportunities/[id]/products/fetchAll";
import { default as addProduct } from "./[id]/addProduct"; import { default as addProduct } from "./opportunities/[id]/products/add";
import { default as addSpecialOrderProduct } from "./[id]/addSpecialOrderProduct"; import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
import { default as addLabor } from "./[id]/addLabor"; import { default as addLabor } from "./opportunities/[id]/products/addLabor";
import { default as laborOptions } from "./[id]/laborOptions"; import { default as laborOptions } from "./opportunities/[id]/products/laborOptions";
import { default as resequenceProducts } from "./[id]/resequenceProducts"; import { default as resequenceProducts } from "./opportunities/[id]/products/resequence";
import { default as updateProduct } from "./[id]/updateProduct"; import { default as updateProduct } from "./opportunities/[id]/products/update";
import { default as cancelProduct } from "./[id]/cancelProduct"; import { default as cancelProduct } from "./opportunities/[id]/products/cancel";
import { default as notes } from "./[id]/notes"; import { default as notes } from "./opportunities/[id]/notes/fetchAll";
import { default as fetchNote } from "./[id]/fetchNote"; import { default as fetchNote } from "./opportunities/[id]/notes/fetch";
import { default as createNote } from "./[id]/createNote"; import { default as createNote } from "./opportunities/[id]/notes/create";
import { default as updateNote } from "./[id]/updateNote"; import { default as updateNote } from "./opportunities/[id]/notes/update";
import { default as deleteNote } from "./[id]/deleteNote"; import { default as deleteNote } from "./opportunities/[id]/notes/delete";
import { default as contacts } from "./[id]/contacts"; 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 { export {
addProduct, addProduct,
@@ -37,5 +42,10 @@ export {
updateNote, updateNote,
deleteNote, deleteNote,
contacts, contacts,
commitQuote,
fetchQuotes,
previewQuote,
downloadQuote,
fetchDownloads,
refresh, refresh,
}; };
@@ -1,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/contacts */ /* GET /v1/sales/opportunities/:identifier/contacts */
export default createRoute( 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 { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import { resolveMember } from "../../../modules/cw-utils/members/memberCache"; import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
import { z } from "zod"; import { z } from "zod";
/* POST /v1/sales/opportunities/:identifier/notes */ /* POST /v1/sales/opportunities/:identifier/notes */
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../Errors/GenericError"; import GenericError from "../../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */ /* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
export default createRoute( export default createRoute(
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../Errors/GenericError"; import GenericError from "../../../../../Errors/GenericError";
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */ /* GET /v1/sales/opportunities/:identifier/notes/:noteId */
export default createRoute( export default createRoute(
@@ -1,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/notes */ /* GET /v1/sales/opportunities/:identifier/notes */
export default createRoute( export default createRoute(
@@ -1,10 +1,10 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../Errors/GenericError"; import GenericError from "../../../../../Errors/GenericError";
import { resolveMember } from "../../../modules/cw-utils/members/memberCache"; import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
import { z } from "zod"; import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */ /* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions"; import { processObjectValuePerms } from "../../../../../modules/permission-utils/processObjectPermissions";
import { z } from "zod"; import { z } from "zod";
const productItemSchema = z const productItemSchema = z
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { procurement } from "../../../managers/procurement"; import { procurement } from "../../../../../managers/procurement";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod"; import { z } from "zod";
const LABOR_DEFAULT_RATE = { const LABOR_DEFAULT_RATE = {
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { procurement } from "../../../managers/procurement"; import { procurement } from "../../../../../managers/procurement";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod"; import { z } from "zod";
const specialOrderItemSchema = z const specialOrderItemSchema = z
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../Errors/GenericError"; import GenericError from "../../../../../Errors/GenericError";
import { z } from "zod"; import { z } from "zod";
const cancelProductSchema = z const cancelProductSchema = z
@@ -59,7 +59,9 @@ export default createRoute(
}); });
const refreshedProducts = await opportunity.fetchProducts({ fresh: true }); const refreshedProducts = await opportunity.fetchProducts({ fresh: true });
const updated = refreshedProducts.find((item) => item.cwForecastId === productId); const updated = refreshedProducts.find(
(item) => item.cwForecastId === productId,
);
if (!updated) { if (!updated) {
throw new GenericError({ throw new GenericError({
@@ -1,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/products */ /* GET /v1/sales/opportunities/:identifier/products */
export default createRoute( export default createRoute(
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { procurement } from "../../../managers/procurement"; import { procurement } from "../../../../../managers/procurement";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/products/labor/options */ /* GET /v1/sales/opportunities/:identifier/products/labor/options */
export default createRoute( export default createRoute(
@@ -1,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod"; import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */ /* PATCH /v1/sales/opportunities/:identifier/products/sequence */
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../Errors/GenericError"; import GenericError from "../../../../../Errors/GenericError";
import { z } from "zod"; import { z } from "zod";
const PRODUCT_NARRATIVE_FIELD_ID = 46; const PRODUCT_NARRATIVE_FIELD_ID = 46;
@@ -22,9 +22,7 @@ const updateProductSchema = z
.strict() .strict()
.refine( .refine(
(value) => (value) =>
Object.values(value).some( Object.values(value).some((item) => item !== undefined && item !== null),
(item) => item !== undefined && item !== null,
),
"At least one editable field is required", "At least one editable field is required",
); );
@@ -100,7 +98,10 @@ export default createRoute(
if (input.quantity !== undefined) { if (input.quantity !== undefined) {
forecastPatch.quantity = input.quantity; forecastPatch.quantity = input.quantity;
} }
if (input.customerDescription !== undefined && input.customerDescription !== null) { if (
input.customerDescription !== undefined &&
input.customerDescription !== null
) {
forecastPatch.customerDescription = input.customerDescription; forecastPatch.customerDescription = input.customerDescription;
} }
if (input.unitPrice !== undefined) { if (input.unitPrice !== undefined) {
@@ -109,7 +110,9 @@ export default createRoute(
); );
} }
if (input.unitCost !== undefined) { if (input.unitCost !== undefined) {
forecastPatch.cost = Number((input.unitCost * effectiveQuantity).toFixed(2)); forecastPatch.cost = Number(
(input.unitCost * effectiveQuantity).toFixed(2),
);
} }
const existingProcurement = const existingProcurement =
@@ -155,7 +158,10 @@ export default createRoute(
: []; : [];
let updatedFields = existingFields as Array<Record<string, unknown>>; let updatedFields = existingFields as Array<Record<string, unknown>>;
if (input.procurementNotes !== undefined && input.procurementNotes !== null) { if (
input.procurementNotes !== undefined &&
input.procurementNotes !== null
) {
updatedFields = upsertCustomTextField( updatedFields = upsertCustomTextField(
updatedFields, updatedFields,
PROCUREMENT_NOTES_FIELD_ID, PROCUREMENT_NOTES_FIELD_ID,
@@ -163,7 +169,10 @@ export default createRoute(
input.procurementNotes, input.procurementNotes,
); );
} }
if (input.productNarrative !== undefined && input.productNarrative !== null) { if (
input.productNarrative !== undefined &&
input.productNarrative !== null
) {
updatedFields = upsertCustomTextField( updatedFields = upsertCustomTextField(
updatedFields, updatedFields,
PRODUCT_NARRATIVE_FIELD_ID, PRODUCT_NARRATIVE_FIELD_ID,
@@ -199,19 +208,20 @@ export default createRoute(
? updatedProcurement.customFields ? updatedProcurement.customFields
: []; : [];
const procurementNotes = const procurementNotes =
updatedFields.find((field: any) => field?.id === PROCUREMENT_NOTES_FIELD_ID) updatedFields.find(
?.value ?? null; (field: any) => field?.id === PROCUREMENT_NOTES_FIELD_ID,
)?.value ?? null;
const productNarrative = const productNarrative =
updatedFields.find((field: any) => field?.id === PRODUCT_NARRATIVE_FIELD_ID) updatedFields.find(
?.value ?? null; (field: any) => field?.id === PRODUCT_NARRATIVE_FIELD_ID,
)?.value ?? null;
const quantity = updatedProcurement?.quantity ?? updatedForecast.quantity ?? null; const quantity =
updatedProcurement?.quantity ?? updatedForecast.quantity ?? null;
const unitPrice = updatedProcurement?.price ?? null; const unitPrice = updatedProcurement?.price ?? null;
const unitCost = updatedProcurement?.cost ?? null; const unitCost = updatedProcurement?.cost ?? null;
const response = apiResponse.successful( const response = apiResponse.successful("Product updated successfully!", {
"Product updated successfully!",
{
...updatedForecast, ...updatedForecast,
productDescription: productDescription:
updatedProcurement?.description ?? updatedForecast.productDescription, updatedProcurement?.description ?? updatedForecast.productDescription,
@@ -224,8 +234,7 @@ export default createRoute(
unitCost, unitCost,
procurementNotes, procurementNotes,
productNarrative, productNarrative,
}, });
);
return c.json(response, response.status as ContentfulStatusCode); return c.json(response, response.status as ContentfulStatusCode);
}, },
@@ -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 { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../middleware/authorization";
/* POST /v1/sales/opportunities/:identifier/refresh */ /* POST /v1/sales/opportunities/:identifier/refresh */
export default createRoute( export default createRoute(
@@ -1,8 +1,8 @@
import { createRoute } from "../../modules/api-utils/createRoute"; import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../managers/opportunities"; import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization"; import { authMiddleware } from "../../middleware/authorization";
/* GET /v1/sales/opportunities/count */ /* GET /v1/sales/opportunities/count */
export default createRoute( export default createRoute(
@@ -1,9 +1,9 @@
import { createRoute } from "../../modules/api-utils/createRoute"; import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../managers/opportunities"; import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization"; import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions"; import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* GET /v1/sales/opportunities */ /* GET /v1/sales/opportunities */
export default createRoute( 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;
};
@@ -23,6 +23,8 @@ export class ForecastProductController {
public catalogItemIdentifier: string | null; public catalogItemIdentifier: string | null;
public productDescription: string; public productDescription: string;
public customerDescription: string | null;
public productNarrative: string | null;
public productClass: string; public productClass: string;
public forecastType: string; public forecastType: string;
@@ -74,6 +76,9 @@ export class ForecastProductController {
this.catalogItemIdentifier = data.catalogItem?.identifier ?? null; this.catalogItemIdentifier = data.catalogItem?.identifier ?? null;
this.productDescription = data.productDescription; 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.productClass = data.productClass;
this.forecastType = data.forecastType; this.forecastType = data.forecastType;
@@ -118,6 +123,24 @@ export class ForecastProductController {
* Enriches this forecast product with cancellation data from the * Enriches this forecast product with cancellation data from the
* procurement products endpoint. * 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: { public applyCancellationData(data: {
cancelledFlag?: boolean; cancelledFlag?: boolean;
quantityCancelled?: number; quantityCancelled?: number;
@@ -154,6 +177,38 @@ export class ForecastProductController {
return this.revenue - this.cost; 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 * Cancelled
* *
@@ -201,12 +256,17 @@ export class ForecastProductController {
? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier } ? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier }
: null, : null,
productDescription: this.productDescription, productDescription: this.productDescription,
customerDescription: this.customerDescription,
productNarrative: this.productNarrative,
productClass: this.productClass, productClass: this.productClass,
forecastType: this.forecastType, forecastType: this.forecastType,
revenue: this.revenue, revenue: this.revenue,
cost: this.cost, cost: this.cost,
margin: this.margin, margin: this.margin,
profit: this.profit, profit: this.profit,
effectiveQuantity: this.effectiveQuantity,
effectiveRevenue: this.effectiveRevenue,
effectiveCost: this.effectiveCost,
percentage: this.percentage, percentage: this.percentage,
includeFlag: this.includeFlag, includeFlag: this.includeFlag,
linkFlag: this.linkFlag, 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,
};
}
}
+413
View File
@@ -20,11 +20,13 @@ import {
import { import {
resolveMember, resolveMember,
resolveMembers, resolveMembers,
getMemberCache,
} from "../modules/cw-utils/members/memberCache"; } from "../modules/cw-utils/members/memberCache";
import { ForecastProductController } from "./ForecastProductController"; import { ForecastProductController } from "./ForecastProductController";
import GenericError from "../Errors/GenericError"; import GenericError from "../Errors/GenericError";
import { computeSubResourceCacheTTL } from "../modules/algorithms/computeSubResourceCacheTTL"; import { computeSubResourceCacheTTL } from "../modules/algorithms/computeSubResourceCacheTTL";
import { computeProductsCacheTTL } from "../modules/algorithms/computeProductsCacheTTL"; import { computeProductsCacheTTL } from "../modules/algorithms/computeProductsCacheTTL";
import UserController from "./UserController";
import { import {
getCachedNotes, getCachedNotes,
getCachedContacts, getCachedContacts,
@@ -37,6 +39,11 @@ import {
invalidateNotesCache, invalidateNotesCache,
invalidateProductsCache, invalidateProductsCache,
} from "../modules/cache/opportunityCache"; } from "../modules/cache/opportunityCache";
import {
generateQuote as generateQuotePdf,
type QuoteMetadata,
} from "../modules/pdf-utils";
import { generatedQuotes } from "../managers/generatedQuotes";
/** /**
* Opportunity Controller * Opportunity Controller
@@ -81,6 +88,7 @@ export class OpportunityController {
public customerPO: string | null; public customerPO: string | null;
public totalSalesTax: number; public totalSalesTax: number;
public probability: number;
public locationName: string | null; public locationName: string | null;
public locationCwId: number | null; public locationCwId: number | null;
@@ -131,6 +139,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( constructor(
data: Opportunity & { company?: Company | null }, data: Opportunity & { company?: Company | null },
opts?: { opts?: {
@@ -174,6 +205,7 @@ export class OpportunityController {
this.customerPO = data.customerPO; this.customerPO = data.customerPO;
this.totalSalesTax = data.totalSalesTax; this.totalSalesTax = data.totalSalesTax;
this.probability = data.probability;
this.locationName = data.locationName; this.locationName = data.locationName;
this.locationCwId = data.locationCwId; this.locationCwId = data.locationCwId;
@@ -203,6 +235,18 @@ export class OpportunityController {
this._activities = opts?.activities ?? null; 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 * Fetch Company
* *
@@ -297,6 +341,7 @@ export class OpportunityController {
customerPO: item.customerPO ?? null, customerPO: item.customerPO ?? null,
totalSalesTax: item.totalSalesTax ?? 0, totalSalesTax: item.totalSalesTax ?? 0,
probability: Number(item.probability?.name) || 0,
locationName: item.location?.name ?? null, locationName: item.location?.name ?? null,
locationCwId: item.location?.id ?? null, locationCwId: item.location?.id ?? null,
@@ -536,6 +581,372 @@ export class OpportunityController {
return this._buildProductControllers(forecast, procProducts); 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. * Build ForecastProductController[] from raw CW data.
* *
@@ -593,6 +1004,7 @@ export class OpportunityController {
const procData = cancellationMap.get(item.id); const procData = cancellationMap.get(item.id);
if (procData) { if (procData) {
ctrl.applyCancellationData(procData as any); ctrl.applyCancellationData(procData as any);
ctrl.applyProcurementCustomFields(procData as any);
} }
return ctrl; return ctrl;
}, },
@@ -1115,6 +1527,7 @@ export class OpportunityController {
: null, : null,
customerPO: this.customerPO, customerPO: this.customerPO,
totalSalesTax: this.totalSalesTax, totalSalesTax: this.totalSalesTax,
probability: this.probability,
location: this.locationCwId location: this.locationCwId
? { id: this.locationCwId, name: this.locationName } ? { id: this.locationCwId, name: this.locationName }
: null, : null,
+4
View File
@@ -1,5 +1,6 @@
import { refresh } from "./api/auth"; import { refresh } from "./api/auth";
import app from "./api/server"; import app from "./api/server";
import { setupSockets } from "./api/sockets";
import { import {
engine, engine,
PORT, PORT,
@@ -68,6 +69,9 @@ Bun.serve({
console.log(`[startup] Server listening on port ${PORT}`); console.log(`[startup] Server listening on port ${PORT}`);
setupSockets();
console.log("[startup] Socket namespaces initialized");
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Background initialisation — none of this blocks the server. // Background initialisation — none of this blocks the server.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+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);
},
};
+1 -1
View File
@@ -377,7 +377,7 @@ export const opportunities = {
include: { company: true }, include: { company: true },
skip, skip,
take: rpp, take: rpp,
orderBy: { expectedCloseDate: "asc" }, orderBy: { createdAt: "desc" },
}); });
return Promise.all( return Promise.all(
@@ -354,7 +354,7 @@ export const opportunityCw = {
opportunityId: number, opportunityId: number,
): Promise<Record<string, unknown>[]> => { ): Promise<Record<string, unknown>[]> => {
const response = await connectWiseApi.get( 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; return response.data;
}, },
@@ -68,6 +68,7 @@ export interface CWOpportunity {
closedDate: string; closedDate: string;
closedBy: CWMemberReference; closedBy: CWMemberReference;
totalSalesTax: number; totalSalesTax: number;
probability: CWReference;
shipToCompany: CWCompanyReference; shipToCompany: CWCompanyReference;
shipToContact: CWContactReference; shipToContact: CWContactReference;
shipToSite: CWSiteReference; shipToSite: CWSiteReference;
@@ -14,6 +14,7 @@ export const processOpportunityResponse = (opportunity: CWOpportunity) => ({
expectedCloseDate: opportunity.expectedCloseDate, expectedCloseDate: opportunity.expectedCloseDate,
closedDate: opportunity.closedDate, closedDate: opportunity.closedDate,
closedFlag: opportunity.closedFlag, closedFlag: opportunity.closedFlag,
probability: Number(opportunity.probability?.name) || 0,
type: opportunity.type type: opportunity.type
? { id: opportunity.type.id, name: opportunity.type.name } ? { id: opportunity.type.id, name: opportunity.type.name }
: null, : null,
+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();
}
+56 -18
View File
@@ -399,11 +399,12 @@ export const PERMISSION_NODES = {
description: description:
"Fetch a single opportunity and its sub-resources (products, notes, contacts)", "Fetch a single opportunity and its sub-resources (products, notes, contacts)",
usedIn: [ usedIn: [
"src/api/sales/[id]/fetch.ts", "src/api/sales/opportunities/[id]/fetch.ts",
"src/api/sales/[id]/products.ts", "src/api/sales/opportunities/[id]/products/fetchAll.ts",
"src/api/sales/[id]/notes.ts", "src/api/sales/opportunities/[id]/notes/fetchAll.ts",
"src/api/sales/[id]/fetchNote.ts", "src/api/sales/opportunities/[id]/notes/fetch.ts",
"src/api/sales/[id]/contacts.ts", "src/api/sales/opportunities/[id]/contacts.ts",
"src/api/sockets/events/liveQuotePreview.ts",
], ],
}, },
{ {
@@ -411,33 +412,33 @@ export const PERMISSION_NODES = {
description: description:
"Fetch multiple opportunities, count, or opportunity types", "Fetch multiple opportunities, count, or opportunity types",
usedIn: [ usedIn: [
"src/api/sales/fetchAll.ts", "src/api/sales/opportunities/fetchAll.ts",
"src/api/sales/count.ts", "src/api/sales/opportunities/count.ts",
"src/api/sales/fetchOpportunityTypes.ts", "src/api/sales/fetchOpportunityTypes.ts",
], ],
}, },
{ {
node: "sales.opportunity.refresh", node: "sales.opportunity.refresh",
description: "Refresh a single opportunity from ConnectWise", 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"], dependencies: ["sales.opportunity.fetch"],
}, },
{ {
node: "sales.opportunity.note.create", node: "sales.opportunity.note.create",
description: "Create a new note on an opportunity", 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"], dependencies: ["sales.opportunity.fetch"],
}, },
{ {
node: "sales.opportunity.note.update", node: "sales.opportunity.note.update",
description: "Update an existing note on an opportunity", 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"], dependencies: ["sales.opportunity.fetch"],
}, },
{ {
node: "sales.opportunity.note.delete", node: "sales.opportunity.note.delete",
description: "Delete a note from an opportunity", 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"], dependencies: ["sales.opportunity.fetch"],
}, },
{ {
@@ -445,9 +446,9 @@ export const PERMISSION_NODES = {
description: description:
"Update products (forecast items) on an opportunity, including resequencing", "Update products (forecast items) on an opportunity, including resequencing",
usedIn: [ usedIn: [
"src/api/sales/[id]/resequenceProducts.ts", "src/api/sales/opportunities/[id]/products/resequence.ts",
"src/api/sales/[id]/updateProduct.ts", "src/api/sales/opportunities/[id]/products/update.ts",
"src/api/sales/[id]/cancelProduct.ts", "src/api/sales/opportunities/[id]/products/cancel.ts",
], ],
dependencies: ["sales.opportunity.fetch"], dependencies: ["sales.opportunity.fetch"],
}, },
@@ -455,7 +456,7 @@ export const PERMISSION_NODES = {
node: "sales.opportunity.product.add", node: "sales.opportunity.product.add",
description: description:
"Add a new product (forecast item) to an opportunity. Individual fields are gated by sales.opportunity.product.field.<field> permissions.", "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"], dependencies: ["sales.opportunity.fetch"],
fieldLevelPermissions: [ fieldLevelPermissions: [
"sales.opportunity.product.field.catalogItem", "sales.opportunity.product.field.catalogItem",
@@ -481,7 +482,9 @@ export const PERMISSION_NODES = {
node: "sales.opportunity.product.add.specialOrder", node: "sales.opportunity.product.add.specialOrder",
description: description:
'Add one or more "SPECIAL ORDER" products to an opportunity via the dedicated special-order route.', 'Add one or more "SPECIAL ORDER" products to an opportunity via the dedicated special-order route.',
usedIn: ["src/api/sales/[id]/addSpecialOrderProduct.ts"], usedIn: [
"src/api/sales/opportunities/[id]/products/addSpecialOrder.ts",
],
dependencies: ["sales.opportunity.fetch"], dependencies: ["sales.opportunity.fetch"],
}, },
{ {
@@ -489,11 +492,45 @@ export const PERMISSION_NODES = {
description: description:
"Add labor products to an opportunity using the dedicated labor route with Field/Tech catalog selection and pricing inputs.", "Add labor products to an opportunity using the dedicated labor route with Field/Tech catalog selection and pricing inputs.",
usedIn: [ usedIn: [
"src/api/sales/[id]/addLabor.ts", "src/api/sales/opportunities/[id]/products/addLabor.ts",
"src/api/sales/[id]/laborOptions.ts", "src/api/sales/opportunities/[id]/products/laborOptions.ts",
], ],
dependencies: ["sales.opportunity.fetch"], 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"],
},
], ],
}, },
@@ -954,6 +991,7 @@ export const PERMISSION_NODES = {
"obj.opportunity.site", "obj.opportunity.site",
"obj.opportunity.customerPO", "obj.opportunity.customerPO",
"obj.opportunity.totalSalesTax", "obj.opportunity.totalSalesTax",
"obj.opportunity.probability",
"obj.opportunity.location", "obj.opportunity.location",
"obj.opportunity.department", "obj.opportunity.department",
"obj.opportunity.expectedCloseDate", "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. */ /** Build a minimal Prisma-shaped CatalogItem row. */
export function buildMockCatalogItem(overrides: Record<string, any> = {}) { export function buildMockCatalogItem(overrides: Record<string, any> = {}) {
return { 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 // applyInventoryData
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -203,6 +237,67 @@ describe("ForecastProductController", () => {
}); });
expect(ctrl.cancellationType).toBe("partial"); 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.subNumber).toBe(0);
expect(json.cwLastUpdated).toBeInstanceOf(Date); 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("global");
expect(PERMISSION_NODES).toHaveProperty("company"); expect(PERMISSION_NODES).toHaveProperty("company");
expect(PERMISSION_NODES).toHaveProperty("credential"); expect(PERMISSION_NODES).toHaveProperty("credential");
expect(PERMISSION_NODES).toHaveProperty("credentialType");
expect(PERMISSION_NODES).toHaveProperty("sales"); expect(PERMISSION_NODES).toHaveProperty("sales");
expect(PERMISSION_NODES).toHaveProperty("procurement"); expect(PERMISSION_NODES).toHaveProperty("procurement");
expect(PERMISSION_NODES).toHaveProperty("objectTypes"); 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", () => { 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.fetchDistinctValues).toBe("function");
expect(typeof procurement.linkItems).toBe("function"); expect(typeof procurement.linkItems).toBe("function");
expect(typeof procurement.unlinkItems).toBe("function"); expect(typeof procurement.unlinkItems).toBe("function");
expect(typeof procurement.fetchLaborCatalogItems).toBe("function");
}); });
test("fetchPages calls through without errors (mock absorbs)", async () => { 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);
});
});