Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1907bb433b | |||
| 4efca6cc53 |
+673
-1
@@ -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",
|
||||||
@@ -3382,12 +3492,136 @@ When a `productSequence` is set, `GET .../products` returns items in that order.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Edit Opportunity Product
|
||||||
|
|
||||||
|
**PATCH** `/sales/opportunities/:identifier/products/:productId/edit`
|
||||||
|
|
||||||
|
Edit a product line item on an opportunity. This route supports forecast-backed fields and procurement-backed fields (including custom fields for narrative/notes).
|
||||||
|
|
||||||
|
**Authentication Required:** Yes
|
||||||
|
|
||||||
|
**Required Permissions:** `sales.opportunity.product.update`
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||||
|
- `productId` — Forecast item ID (numeric)
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
At least one field is required.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"productDescription": "Labor & Installation - Field (Updated)",
|
||||||
|
"quantity": 2,
|
||||||
|
"unitPrice": 125,
|
||||||
|
"unitCost": 62.5,
|
||||||
|
"customerDescription": "Onsite labor for rack install",
|
||||||
|
"productNarrative": "Install, cable, and validate cutover",
|
||||||
|
"procurementNotes": "Coordinate site contact before arrival"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --------------------- | ------ | ---------------------------------------------------------- |
|
||||||
|
| `productDescription` | string | Product description |
|
||||||
|
| `quantity` | number | Quantity |
|
||||||
|
| `unitPrice` | number | Unit price (maps to procurement `price`, forecast revenue) |
|
||||||
|
| `unitCost` | number | Unit cost (maps to procurement `cost`, forecast cost) |
|
||||||
|
| `customerDescription` | string | Customer-facing description |
|
||||||
|
| `productNarrative` | string | Custom field `Product Narrative` (`id: 46`) |
|
||||||
|
| `procurementNotes` | string | Custom field `Procurement Notes` (`id: 29`) |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 200,
|
||||||
|
"message": "Product updated successfully!",
|
||||||
|
"data": {
|
||||||
|
"id": 32281,
|
||||||
|
"productDescription": "Labor & Installation - Field (Updated)",
|
||||||
|
"quantity": 2,
|
||||||
|
"unitPrice": 125,
|
||||||
|
"unitCost": 62.5,
|
||||||
|
"customerDescription": "Onsite labor for rack install",
|
||||||
|
"productNarrative": "Install, cable, and validate cutover",
|
||||||
|
"procurementNotes": "Coordinate site contact before arrival"
|
||||||
|
},
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Cancel / Uncancel Opportunity Product
|
||||||
|
|
||||||
|
**PATCH** `/sales/opportunities/:identifier/products/:productId/cancel`
|
||||||
|
|
||||||
|
Set cancellation state for a product line item using procurement cancellation fields.
|
||||||
|
|
||||||
|
- `quantityCancelled = 0` → item is treated as **uncancelled**
|
||||||
|
- `quantityCancelled > 0 && < quantity` → **partial** cancellation
|
||||||
|
- `quantityCancelled >= quantity` → **full** cancellation
|
||||||
|
|
||||||
|
**Authentication Required:** Yes
|
||||||
|
|
||||||
|
**Required Permissions:** `sales.opportunity.product.update`
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||||
|
- `productId` — Forecast item ID (numeric)
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"quantityCancelled": 1,
|
||||||
|
"cancellationReason": "Out of stock"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| -------------------- | ---------------- | -------- | ------------------------------------------------------------- |
|
||||||
|
| `quantityCancelled` | number (integer) | Yes | Number of units to cancel. Use `0` to uncancel the line item. |
|
||||||
|
| `cancellationReason` | string \| null | No | Optional reason that is passed through to ConnectWise. |
|
||||||
|
|
||||||
|
**Validation Rules:**
|
||||||
|
|
||||||
|
- `quantityCancelled` must be >= `0`
|
||||||
|
- `quantityCancelled` cannot exceed the line item's quantity
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 200,
|
||||||
|
"message": "Product cancellation updated successfully!",
|
||||||
|
"data": {
|
||||||
|
"id": 32281,
|
||||||
|
"quantity": 2,
|
||||||
|
"cancelled": true,
|
||||||
|
"cancellationType": "partial",
|
||||||
|
"quantityCancelled": 1,
|
||||||
|
"cancelledReason": "Out of stock",
|
||||||
|
"cancelledDate": "2026-03-04T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Add Product to Opportunity
|
### Add Product to Opportunity
|
||||||
|
|
||||||
**POST** `/sales/opportunities/:identifier/products`
|
**POST** `/sales/opportunities/:identifier/products`
|
||||||
|
|
||||||
Add a new product (forecast item) to an opportunity in ConnectWise. The request body is validated with Zod, then each submitted field is gated against `sales.opportunity.product.field.<field>` permissions — only fields the user has permission for are forwarded to ConnectWise.
|
Add a new product (forecast item) to an opportunity in ConnectWise. The request body is validated with Zod, then each submitted field is gated against `sales.opportunity.product.field.<field>` permissions — only fields the user has permission for are forwarded to ConnectWise.
|
||||||
|
|
||||||
|
After creation, the new forecast item ID is appended to the opportunity's local `productSequence` array in the database (if not already present) so display ordering remains stable.
|
||||||
|
|
||||||
**Authentication Required:** Yes
|
**Authentication Required:** Yes
|
||||||
|
|
||||||
**Required Permissions:** `sales.opportunity.product.add`
|
**Required Permissions:** `sales.opportunity.product.add`
|
||||||
@@ -3546,6 +3780,7 @@ Accepts either a single object or an array of objects.
|
|||||||
- `customFields` are auto-built when notes are provided:
|
- `customFields` are auto-built when notes are provided:
|
||||||
- `procurementNotes` → `Procurement Notes` (`id: 29`)
|
- `procurementNotes` → `Procurement Notes` (`id: 29`)
|
||||||
- `productNarrative` → `Product Narrative` (`id: 46`)
|
- `productNarrative` → `Product Narrative` (`id: 46`)
|
||||||
|
- When CW returns `forecastDetailId`, it is appended to the opportunity's local `productSequence` array in the database (if not already present)
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
@@ -3569,6 +3804,443 @@ Accepts either a single object or an array of objects.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Get Labor Product Options
|
||||||
|
|
||||||
|
**GET** `/sales/opportunities/:identifier/products/labor/options`
|
||||||
|
|
||||||
|
Fetch the resolved **Field** and **Tech** labor catalog products plus default labor pricing metadata so the UI can hydrate the labor-entry form without hardcoding catalog IDs.
|
||||||
|
|
||||||
|
**Authentication Required:** Yes
|
||||||
|
|
||||||
|
**Required Permissions:** `sales.opportunity.product.add.labor`
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 200,
|
||||||
|
"message": "Labor product options fetched successfully!",
|
||||||
|
"data": {
|
||||||
|
"defaults": {
|
||||||
|
"customerType": "corporate",
|
||||||
|
"rates": {
|
||||||
|
"corporate": 100,
|
||||||
|
"residential": 85
|
||||||
|
},
|
||||||
|
"cpuMultiplier": 0.5,
|
||||||
|
"quantity": 1
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"field": {
|
||||||
|
"cwCatalogId": 3756,
|
||||||
|
"identifier": "Labor & Installation - Field",
|
||||||
|
"name": "Labor & Installation - Field",
|
||||||
|
"taxableFlag": true
|
||||||
|
},
|
||||||
|
"tech": {
|
||||||
|
"cwCatalogId": 3757,
|
||||||
|
"identifier": "Labor & Installation - Tech",
|
||||||
|
"name": "Labor & Installation - Tech",
|
||||||
|
"taxableFlag": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Add Labor Product
|
||||||
|
|
||||||
|
**POST** `/sales/opportunities/:identifier/products/labor`
|
||||||
|
|
||||||
|
Add a labor line item to an opportunity using one of the two canonical labor catalog products (**Field** or **Tech**). The route resolves both labor products from the local catalog, then picks the selected style and creates a ConnectWise procurement product.
|
||||||
|
|
||||||
|
**Authentication Required:** Yes
|
||||||
|
|
||||||
|
**Required Permissions:** `sales.opportunity.product.add.labor`
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"laborStyle": "field",
|
||||||
|
"customerType": "corporate",
|
||||||
|
"hours": 1,
|
||||||
|
"taxable": true,
|
||||||
|
"rate": 100,
|
||||||
|
"ppu": 100,
|
||||||
|
"cpu": 50,
|
||||||
|
"procurementNotes": "Schedule with PM before install",
|
||||||
|
"productNarrative": "Install and validate onsite",
|
||||||
|
"customerDescription": "Onsite installation labor"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| --------------------- | ------------------------------ | -------- | -------------------------------------------------------------------------- |
|
||||||
|
| `laborStyle` | `"field" \| "tech"` | Yes | Chooses which labor catalog product to use |
|
||||||
|
| `customerType` | `"corporate" \| "residential"` | No | Selects default rate (`corporate=100`, `residential=85`) when rate omitted |
|
||||||
|
| `hours` | number | No | Quantity/hours (defaults to `1`) |
|
||||||
|
| `rate` | number | No | Hourly labor rate used when `ppu` is not provided |
|
||||||
|
| `ppu` | number | No | Price per unit/hour (overrides `rate`) |
|
||||||
|
| `cpu` | number | No | Cost per unit/hour (defaults to `50%` of selected price) |
|
||||||
|
| `taxable` | boolean | No | Taxable flag override |
|
||||||
|
| `taxableFlag` | boolean | No | Alternate taxable flag input (same behavior as `taxable`) |
|
||||||
|
| `description` | string | No | Internal line description override |
|
||||||
|
| `customerDescription` | string | No | Customer-facing description |
|
||||||
|
| `procurementNotes` | string | No | Maps to custom field `Procurement Notes` (`id: 29`) |
|
||||||
|
| `productNarrative` | string | No | Maps to custom field `Product Narrative` (`id: 46`) |
|
||||||
|
|
||||||
|
**Route-Enforced Defaults / Behavior:**
|
||||||
|
|
||||||
|
- Resolves both labor products from local catalog and uses `laborStyle` to select one
|
||||||
|
- Uses `customerType` defaults when `rate/ppu/cpu` are not supplied
|
||||||
|
- Sets `quantity` from `hours` (default `1`)
|
||||||
|
- Sets `price` from selected `ppu`, `cost` from selected `cpu`
|
||||||
|
- Sets `dropshipFlag` to `false` and `billableOption` to `Billable`
|
||||||
|
- Sets taxable flag from `taxable`/`taxableFlag`, falling back to the selected catalog item's tax setting
|
||||||
|
- When CW returns `forecastDetailId`, it is appended to the opportunity's local `productSequence` array in the database (if not already present)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 201,
|
||||||
|
"message": "Labor added to opportunity successfully!",
|
||||||
|
"data": {
|
||||||
|
"id": 88341,
|
||||||
|
"forecastDetailId": 32281,
|
||||||
|
"laborStyle": "field",
|
||||||
|
"customerType": "corporate",
|
||||||
|
"catalogItem": {
|
||||||
|
"id": 3756,
|
||||||
|
"identifier": "Labor & Installation - Field",
|
||||||
|
"name": "Labor & Installation - Field"
|
||||||
|
},
|
||||||
|
"description": "Labor & Installation - Field",
|
||||||
|
"customerDescription": "Onsite installation labor",
|
||||||
|
"quantity": 1,
|
||||||
|
"rate": 100,
|
||||||
|
"ppu": 100,
|
||||||
|
"cpu": 50,
|
||||||
|
"revenue": 100,
|
||||||
|
"cost": 50,
|
||||||
|
"taxableFlag": true,
|
||||||
|
"procurementNotes": "Schedule with PM before install",
|
||||||
|
"productNarrative": "Install and validate onsite"
|
||||||
|
},
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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`
|
||||||
|
|||||||
+20
-11
@@ -136,17 +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.
|
||||||
|
|
||||||
| Permission Node | Description | Used In | Dependencies |
|
**WebSocket note:** The `/secure` socket event chain `opp:live_quote_preview` and `opp:live_quote_preview:<id>:data` is gated by `sales.opportunity.fetch`.
|
||||||
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
|
|
||||||
| `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) | |
|
| Permission Node | Description | Used In | Dependencies |
|
||||||
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.ts) | |
|
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
|
||||||
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` |
|
| `sales.opportunity.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.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.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.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.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.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.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.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts) | `sales.opportunity.fetch` |
|
| `sales.opportunity.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.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.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.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.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/opportunities/[id]/products/resequence.ts](src/api/sales/opportunities/[id]/products/resequence.ts), [src/api/sales/opportunities/[id]/products/update.ts](src/api/sales/opportunities/[id]/products/update.ts), [src/api/sales/opportunities/[id]/products/cancel.ts](src/api/sales/opportunities/[id]/products/cancel.ts) | `sales.opportunity.fetch` |
|
||||||
|
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<field>` permissions. | [src/api/sales/opportunities/[id]/products/add.ts](src/api/sales/opportunities/[id]/products/add.ts) | `sales.opportunity.fetch` |
|
||||||
|
| `sales.opportunity.product.add.specialOrder` | Add one or more "SPECIAL ORDER" products via the dedicated special-order route. | [src/api/sales/opportunities/[id]/products/addSpecialOrder.ts](src/api/sales/opportunities/[id]/products/addSpecialOrder.ts) | `sales.opportunity.fetch` |
|
||||||
|
| `sales.opportunity.product.add.labor` | Add labor products via the dedicated labor route with Field/Tech catalog selection and labor pricing inputs. | [src/api/sales/opportunities/[id]/products/addLabor.ts](src/api/sales/opportunities/[id]/products/addLabor.ts), [src/api/sales/opportunities/[id]/products/laborOptions.ts](src/api/sales/opportunities/[id]/products/laborOptions.ts) | `sales.opportunity.fetch` |
|
||||||
|
| `sales.opportunity.quote.fetch` | Fetch all committed quotes for an opportunity. | [src/api/sales/opportunities/[id]/quotes/fetchAll.ts](src/api/sales/opportunities/[id]/quotes/fetchAll.ts) | `sales.opportunity.fetch` |
|
||||||
|
| `sales.opportunity.quote.commit` | Generate and store a finalized quote PDF for an opportunity with regeneration metadata and creator attribution. | [src/api/sales/opportunities/[id]/quotes/commit.ts](src/api/sales/opportunities/[id]/quotes/commit.ts) | `sales.opportunity.fetch` |
|
||||||
|
| `sales.opportunity.quote.preview` | Generate a preview-stamped quote PDF for an opportunity without storing it. | [src/api/sales/opportunities/[id]/quotes/preview.ts](src/api/sales/opportunities/[id]/quotes/preview.ts) | `sales.opportunity.fetch` |
|
||||||
|
| `sales.opportunity.quote.download` | Download a committed quote PDF. Each download is recorded with timestamp and user info. | [src/api/sales/opportunities/[id]/quotes/download.ts](src/api/sales/opportunities/[id]/quotes/download.ts) | `sales.opportunity.fetch` |
|
||||||
|
| `sales.opportunity.quote.fetch_downloads` | Fetch download/print history for all quotes on an opportunity. Admin-level permission. | [src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts](src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts) | `sales.opportunity.fetch` |
|
||||||
|
|
||||||
<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>
|
||||||
@@ -348,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 |
|
||||||
|
|||||||
@@ -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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' '
|
||||||
|
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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
+28
-2
@@ -41,8 +41,9 @@ model User {
|
|||||||
|
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
+32
-14
@@ -1,21 +1,32 @@
|
|||||||
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 resequenceProducts } from "./[id]/resequenceProducts";
|
import { default as addLabor } from "./opportunities/[id]/products/addLabor";
|
||||||
import { default as notes } from "./[id]/notes";
|
import { default as laborOptions } from "./opportunities/[id]/products/laborOptions";
|
||||||
import { default as fetchNote } from "./[id]/fetchNote";
|
import { default as resequenceProducts } from "./opportunities/[id]/products/resequence";
|
||||||
import { default as createNote } from "./[id]/createNote";
|
import { default as updateProduct } from "./opportunities/[id]/products/update";
|
||||||
import { default as updateNote } from "./[id]/updateNote";
|
import { default as cancelProduct } from "./opportunities/[id]/products/cancel";
|
||||||
import { default as deleteNote } from "./[id]/deleteNote";
|
import { default as notes } from "./opportunities/[id]/notes/fetchAll";
|
||||||
import { default as contacts } from "./[id]/contacts";
|
import { default as fetchNote } from "./opportunities/[id]/notes/fetch";
|
||||||
|
import { default as createNote } from "./opportunities/[id]/notes/create";
|
||||||
|
import { default as updateNote } from "./opportunities/[id]/notes/update";
|
||||||
|
import { default as deleteNote } from "./opportunities/[id]/notes/delete";
|
||||||
|
import { default as contacts } from "./opportunities/[id]/contacts";
|
||||||
|
import { default as commitQuote } from "./opportunities/[id]/quotes/commit";
|
||||||
|
import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll";
|
||||||
|
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
|
||||||
|
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
|
||||||
|
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
addProduct,
|
addProduct,
|
||||||
|
addLabor,
|
||||||
|
laborOptions,
|
||||||
addSpecialOrderProduct,
|
addSpecialOrderProduct,
|
||||||
count,
|
count,
|
||||||
fetch,
|
fetch,
|
||||||
@@ -23,11 +34,18 @@ export {
|
|||||||
fetchOpportunityTypes,
|
fetchOpportunityTypes,
|
||||||
products,
|
products,
|
||||||
resequenceProducts,
|
resequenceProducts,
|
||||||
|
updateProduct,
|
||||||
|
cancelProduct,
|
||||||
notes,
|
notes,
|
||||||
fetchNote,
|
fetchNote,
|
||||||
createNote,
|
createNote,
|
||||||
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(
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../../../managers/opportunities";
|
||||||
|
import { procurement } from "../../../../../managers/procurement";
|
||||||
|
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const LABOR_DEFAULT_RATE = {
|
||||||
|
corporate: 100,
|
||||||
|
residential: 85,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const roundMoney = (value: number) => Math.round(value * 100) / 100;
|
||||||
|
|
||||||
|
const addLaborSchema = z
|
||||||
|
.object({
|
||||||
|
laborStyle: z.enum(["field", "tech"]),
|
||||||
|
customerType: z.enum(["corporate", "residential"]).optional(),
|
||||||
|
hours: z.number().positive().optional(),
|
||||||
|
rate: z.number().min(0).optional(),
|
||||||
|
ppu: z.number().min(0).optional(),
|
||||||
|
cpu: z.number().min(0).optional(),
|
||||||
|
taxable: z.boolean().optional(),
|
||||||
|
taxableFlag: z.boolean().optional(),
|
||||||
|
description: z.string().min(1).optional(),
|
||||||
|
customerDescription: z.string().min(1).optional(),
|
||||||
|
procurementNotes: z.string().optional(),
|
||||||
|
productNarrative: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
/* POST /v1/sales/opportunities/:identifier/products/labor */
|
||||||
|
export default createRoute(
|
||||||
|
"post",
|
||||||
|
["/opportunities/:identifier/products/labor"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const body = await c.req.json();
|
||||||
|
const input = addLaborSchema.parse(body);
|
||||||
|
|
||||||
|
const laborCatalog = await procurement.fetchLaborCatalogItems();
|
||||||
|
const selectedCatalog =
|
||||||
|
input.laborStyle === "tech" ? laborCatalog.tech : laborCatalog.field;
|
||||||
|
|
||||||
|
const customerType = input.customerType ?? "corporate";
|
||||||
|
const defaultRate = LABOR_DEFAULT_RATE[customerType];
|
||||||
|
const quantity = input.hours ?? 1;
|
||||||
|
const ppu = input.ppu ?? input.rate ?? defaultRate;
|
||||||
|
const cpu = input.cpu ?? roundMoney(ppu * 0.5);
|
||||||
|
const taxableFlag =
|
||||||
|
input.taxable ?? input.taxableFlag ?? selectedCatalog.salesTaxable;
|
||||||
|
|
||||||
|
const makeCustomField = (
|
||||||
|
caption: string,
|
||||||
|
value: string,
|
||||||
|
fieldId: number,
|
||||||
|
) => ({
|
||||||
|
id: fieldId,
|
||||||
|
caption,
|
||||||
|
type: "Text",
|
||||||
|
entryMethod: "EntryField",
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...(input.procurementNotes || input.productNarrative
|
||||||
|
? {
|
||||||
|
customFields: [
|
||||||
|
...(input.procurementNotes
|
||||||
|
? [
|
||||||
|
makeCustomField(
|
||||||
|
"Procurement Notes",
|
||||||
|
input.procurementNotes,
|
||||||
|
29,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(input.productNarrative
|
||||||
|
? [
|
||||||
|
makeCustomField(
|
||||||
|
"Product Narrative",
|
||||||
|
input.productNarrative,
|
||||||
|
46,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
catalogItem: { id: selectedCatalog.cwCatalogId },
|
||||||
|
description:
|
||||||
|
input.description ??
|
||||||
|
selectedCatalog.name ??
|
||||||
|
selectedCatalog.identifier ??
|
||||||
|
`${input.laborStyle.toUpperCase()} Labor`,
|
||||||
|
customerDescription: input.customerDescription,
|
||||||
|
quantity,
|
||||||
|
price: ppu,
|
||||||
|
cost: cpu,
|
||||||
|
taxableFlag,
|
||||||
|
dropshipFlag: false,
|
||||||
|
billableOption: "Billable",
|
||||||
|
};
|
||||||
|
|
||||||
|
const opportunity = await opportunities.fetchRecord(identifier);
|
||||||
|
const [created] = await opportunity.addProcurementProducts(payload);
|
||||||
|
|
||||||
|
const fields = Array.isArray(created?.customFields)
|
||||||
|
? created.customFields
|
||||||
|
: [];
|
||||||
|
const procurementNotes =
|
||||||
|
fields.find((f: any) => f?.id === 29)?.value ?? null;
|
||||||
|
const productNarrative =
|
||||||
|
fields.find((f: any) => f?.id === 46)?.value ?? null;
|
||||||
|
|
||||||
|
const response = apiResponse.created(
|
||||||
|
"Labor added to opportunity successfully!",
|
||||||
|
{
|
||||||
|
id: created?.id ?? null,
|
||||||
|
forecastDetailId: created?.forecastDetailId ?? null,
|
||||||
|
laborStyle: input.laborStyle,
|
||||||
|
customerType,
|
||||||
|
catalogItem: {
|
||||||
|
id: selectedCatalog.cwCatalogId,
|
||||||
|
identifier: selectedCatalog.identifier,
|
||||||
|
name: selectedCatalog.name,
|
||||||
|
},
|
||||||
|
description: created?.description ?? payload.description,
|
||||||
|
customerDescription:
|
||||||
|
created?.customerDescription ?? input.customerDescription ?? null,
|
||||||
|
quantity: created?.quantity ?? quantity,
|
||||||
|
rate: ppu,
|
||||||
|
ppu,
|
||||||
|
cpu,
|
||||||
|
revenue: roundMoney((created?.quantity ?? quantity) * ppu),
|
||||||
|
cost: roundMoney((created?.quantity ?? quantity) * cpu),
|
||||||
|
taxableFlag: created?.taxableFlag ?? taxableFlag,
|
||||||
|
procurementNotes,
|
||||||
|
productNarrative,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
|
||||||
|
);
|
||||||
+5
-5
@@ -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
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
import GenericError from "../../../../../Errors/GenericError";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const cancelProductSchema = z
|
||||||
|
.object({
|
||||||
|
quantityCancelled: z.number().int().min(0),
|
||||||
|
cancellationReason: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/cancel */
|
||||||
|
export default createRoute(
|
||||||
|
"patch",
|
||||||
|
["/opportunities/:identifier/products/:productId/cancel"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const productId = Number(c.req.param("productId"));
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
if (!Number.isInteger(productId) || productId <= 0) {
|
||||||
|
throw new GenericError({
|
||||||
|
status: 400,
|
||||||
|
name: "InvalidProductId",
|
||||||
|
message: "productId must be a positive integer",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = cancelProductSchema.parse(body);
|
||||||
|
const opportunity = await opportunities.fetchRecord(identifier);
|
||||||
|
|
||||||
|
const products = await opportunity.fetchProducts();
|
||||||
|
const product = products.find((item) => item.cwForecastId === productId);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new GenericError({
|
||||||
|
status: 404,
|
||||||
|
name: "ForecastItemNotFound",
|
||||||
|
message: `Forecast item ${productId} not found on opportunity`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = product.quantity ?? 0;
|
||||||
|
if (input.quantityCancelled > quantity) {
|
||||||
|
throw new GenericError({
|
||||||
|
status: 400,
|
||||||
|
name: "InvalidCancelledQuantity",
|
||||||
|
message: `quantityCancelled cannot exceed product quantity (${quantity})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await opportunity.setProductCancellation(productId, {
|
||||||
|
quantityCancelled: input.quantityCancelled,
|
||||||
|
cancellationReason: input.cancellationReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshedProducts = await opportunity.fetchProducts({ fresh: true });
|
||||||
|
const updated = refreshedProducts.find(
|
||||||
|
(item) => item.cwForecastId === productId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new GenericError({
|
||||||
|
status: 404,
|
||||||
|
name: "ForecastItemNotFound",
|
||||||
|
message: `Forecast item ${productId} not found on opportunity`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
input.quantityCancelled === 0
|
||||||
|
? "Product uncancelled successfully!"
|
||||||
|
: "Product cancellation updated successfully!",
|
||||||
|
updated.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
|
||||||
|
);
|
||||||
+4
-4
@@ -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(
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../../../managers/opportunities";
|
||||||
|
import { procurement } from "../../../../../managers/procurement";
|
||||||
|
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/:identifier/products/labor/options */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/:identifier/products/labor/options"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
|
||||||
|
await opportunities.fetchRecord(identifier);
|
||||||
|
|
||||||
|
const laborCatalog = await procurement.fetchLaborCatalogItems();
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Labor product options fetched successfully!",
|
||||||
|
{
|
||||||
|
defaults: {
|
||||||
|
customerType: "corporate",
|
||||||
|
rates: {
|
||||||
|
corporate: 100,
|
||||||
|
residential: 85,
|
||||||
|
},
|
||||||
|
cpuMultiplier: 0.5,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
field: {
|
||||||
|
cwCatalogId: laborCatalog.field.cwCatalogId,
|
||||||
|
identifier: laborCatalog.field.identifier,
|
||||||
|
name: laborCatalog.field.name,
|
||||||
|
taxableFlag: laborCatalog.field.salesTaxable,
|
||||||
|
},
|
||||||
|
tech: {
|
||||||
|
cwCatalogId: laborCatalog.tech.cwCatalogId,
|
||||||
|
identifier: laborCatalog.tech.identifier,
|
||||||
|
name: laborCatalog.tech.name,
|
||||||
|
taxableFlag: laborCatalog.tech.salesTaxable,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
|
||||||
|
);
|
||||||
+4
-4
@@ -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 */
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
import GenericError from "../../../../../Errors/GenericError";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const PRODUCT_NARRATIVE_FIELD_ID = 46;
|
||||||
|
const PROCUREMENT_NOTES_FIELD_ID = 29;
|
||||||
|
|
||||||
|
const updateProductSchema = z
|
||||||
|
.object({
|
||||||
|
productDescription: z.string().min(1).optional(),
|
||||||
|
quantity: z.number().positive().optional(),
|
||||||
|
unitPrice: z.number().min(0).optional(),
|
||||||
|
unitCost: z.number().min(0).optional(),
|
||||||
|
customerDescription: z.string().nullable().optional(),
|
||||||
|
productNarrative: z.string().nullable().optional(),
|
||||||
|
procurementNotes: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
Object.values(value).some((item) => item !== undefined && item !== null),
|
||||||
|
"At least one editable field is required",
|
||||||
|
);
|
||||||
|
|
||||||
|
const upsertCustomTextField = (
|
||||||
|
fields: Array<Record<string, unknown>>,
|
||||||
|
fieldId: number,
|
||||||
|
caption: string,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const next = [...fields];
|
||||||
|
const idx = next.findIndex((f) => Number(f.id) === fieldId);
|
||||||
|
|
||||||
|
const field = {
|
||||||
|
id: fieldId,
|
||||||
|
caption,
|
||||||
|
type: "Text",
|
||||||
|
entryMethod: "EntryField",
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (idx === -1) {
|
||||||
|
next.push(field);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
next[idx] = {
|
||||||
|
...next[idx],
|
||||||
|
...field,
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/edit */
|
||||||
|
export default createRoute(
|
||||||
|
"patch",
|
||||||
|
["/opportunities/:identifier/products/:productId/edit"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const productId = Number(c.req.param("productId"));
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
if (!Number.isInteger(productId) || productId <= 0) {
|
||||||
|
throw new GenericError({
|
||||||
|
status: 400,
|
||||||
|
name: "InvalidProductId",
|
||||||
|
message: "productId must be a positive integer",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = updateProductSchema.parse(body);
|
||||||
|
const opportunity = await opportunities.fetchRecord(identifier);
|
||||||
|
|
||||||
|
const forecastItems = await opportunity.fetchProducts();
|
||||||
|
const forecastItem = forecastItems.find(
|
||||||
|
(item) => item.cwForecastId === productId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!forecastItem) {
|
||||||
|
throw new GenericError({
|
||||||
|
status: 404,
|
||||||
|
name: "ForecastItemNotFound",
|
||||||
|
message: `Forecast item ${productId} not found on opportunity`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const forecastJson = forecastItem.toJson();
|
||||||
|
const effectiveQuantity = input.quantity ?? forecastJson.quantity ?? 1;
|
||||||
|
|
||||||
|
const forecastPatch: Record<string, unknown> = {};
|
||||||
|
if (input.productDescription !== undefined) {
|
||||||
|
forecastPatch.productDescription = input.productDescription;
|
||||||
|
}
|
||||||
|
if (input.quantity !== undefined) {
|
||||||
|
forecastPatch.quantity = input.quantity;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.customerDescription !== undefined &&
|
||||||
|
input.customerDescription !== null
|
||||||
|
) {
|
||||||
|
forecastPatch.customerDescription = input.customerDescription;
|
||||||
|
}
|
||||||
|
if (input.unitPrice !== undefined) {
|
||||||
|
forecastPatch.revenue = Number(
|
||||||
|
(input.unitPrice * effectiveQuantity).toFixed(2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.unitCost !== undefined) {
|
||||||
|
forecastPatch.cost = Number(
|
||||||
|
(input.unitCost * effectiveQuantity).toFixed(2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingProcurement =
|
||||||
|
await opportunity.fetchProcurementProductByForecastItem(productId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(input.productNarrative !== undefined ||
|
||||||
|
input.procurementNotes !== undefined) &&
|
||||||
|
!existingProcurement
|
||||||
|
) {
|
||||||
|
throw new GenericError({
|
||||||
|
status: 400,
|
||||||
|
name: "ProcurementLinkRequired",
|
||||||
|
message:
|
||||||
|
"Product Narrative and Procurement Notes can only be updated on products linked to a procurement record",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedProcurement = existingProcurement;
|
||||||
|
if (existingProcurement) {
|
||||||
|
const procurementPatch: Record<string, unknown> = {};
|
||||||
|
if (input.productDescription !== undefined) {
|
||||||
|
procurementPatch.description = input.productDescription;
|
||||||
|
}
|
||||||
|
if (input.quantity !== undefined) {
|
||||||
|
procurementPatch.quantity = input.quantity;
|
||||||
|
}
|
||||||
|
if (input.unitPrice !== undefined) {
|
||||||
|
procurementPatch.price = input.unitPrice;
|
||||||
|
}
|
||||||
|
if (input.unitCost !== undefined) {
|
||||||
|
procurementPatch.cost = input.unitCost;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.customerDescription !== undefined &&
|
||||||
|
input.customerDescription !== null
|
||||||
|
) {
|
||||||
|
procurementPatch.customerDescription = input.customerDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingFields = Array.isArray(existingProcurement.customFields)
|
||||||
|
? existingProcurement.customFields.map((field) => ({ ...field }))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let updatedFields = existingFields as Array<Record<string, unknown>>;
|
||||||
|
if (
|
||||||
|
input.procurementNotes !== undefined &&
|
||||||
|
input.procurementNotes !== null
|
||||||
|
) {
|
||||||
|
updatedFields = upsertCustomTextField(
|
||||||
|
updatedFields,
|
||||||
|
PROCUREMENT_NOTES_FIELD_ID,
|
||||||
|
"Procurement Notes",
|
||||||
|
input.procurementNotes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.productNarrative !== undefined &&
|
||||||
|
input.productNarrative !== null
|
||||||
|
) {
|
||||||
|
updatedFields = upsertCustomTextField(
|
||||||
|
updatedFields,
|
||||||
|
PRODUCT_NARRATIVE_FIELD_ID,
|
||||||
|
"Product Narrative",
|
||||||
|
input.productNarrative,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(input.procurementNotes !== undefined &&
|
||||||
|
input.procurementNotes !== null) ||
|
||||||
|
(input.productNarrative !== undefined &&
|
||||||
|
input.productNarrative !== null)
|
||||||
|
) {
|
||||||
|
procurementPatch.customFields = updatedFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(procurementPatch).length > 0) {
|
||||||
|
updatedProcurement =
|
||||||
|
await opportunity.updateProcurementProductByForecastItem(
|
||||||
|
productId,
|
||||||
|
procurementPatch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedForecast = forecastJson;
|
||||||
|
if (Object.keys(forecastPatch).length > 0) {
|
||||||
|
const patched = await opportunity.updateProduct(productId, forecastPatch);
|
||||||
|
updatedForecast = patched.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedFields = Array.isArray(updatedProcurement?.customFields)
|
||||||
|
? updatedProcurement.customFields
|
||||||
|
: [];
|
||||||
|
const procurementNotes =
|
||||||
|
updatedFields.find(
|
||||||
|
(field: any) => field?.id === PROCUREMENT_NOTES_FIELD_ID,
|
||||||
|
)?.value ?? null;
|
||||||
|
const productNarrative =
|
||||||
|
updatedFields.find(
|
||||||
|
(field: any) => field?.id === PRODUCT_NARRATIVE_FIELD_ID,
|
||||||
|
)?.value ?? null;
|
||||||
|
|
||||||
|
const quantity =
|
||||||
|
updatedProcurement?.quantity ?? updatedForecast.quantity ?? null;
|
||||||
|
const unitPrice = updatedProcurement?.price ?? null;
|
||||||
|
const unitCost = updatedProcurement?.cost ?? null;
|
||||||
|
|
||||||
|
const response = apiResponse.successful("Product updated successfully!", {
|
||||||
|
...updatedForecast,
|
||||||
|
productDescription:
|
||||||
|
updatedProcurement?.description ?? updatedForecast.productDescription,
|
||||||
|
customerDescription:
|
||||||
|
updatedProcurement?.customerDescription ??
|
||||||
|
updatedForecast.customerDescription ??
|
||||||
|
null,
|
||||||
|
quantity,
|
||||||
|
unitPrice,
|
||||||
|
unitCost,
|
||||||
|
procurementNotes,
|
||||||
|
productNarrative,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const commitQuoteSchema = z
|
||||||
|
.object({
|
||||||
|
lineItemPricing: z.boolean().optional(),
|
||||||
|
includeQuoteNarrative: z.boolean().optional(),
|
||||||
|
includeItemNarratives: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
/* POST /v1/sales/opportunities/:identifier/quote/commit */
|
||||||
|
export default createRoute(
|
||||||
|
"post",
|
||||||
|
["/opportunities/:identifier/quote/commit"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const body = await c.req.json().catch(() => undefined);
|
||||||
|
|
||||||
|
const opts = commitQuoteSchema.parse(body);
|
||||||
|
|
||||||
|
const item = await opportunities.fetchRecord(identifier);
|
||||||
|
const user = c.get("user");
|
||||||
|
|
||||||
|
const quote = await item.commitQuote(opts ?? {}, user);
|
||||||
|
|
||||||
|
const response = apiResponse.created(
|
||||||
|
"Quote committed successfully!",
|
||||||
|
quote.toJson({ includeRegenData: true, includeRegenParams: true }),
|
||||||
|
);
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.quote.commit"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||||
|
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||||
|
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||||
|
import { injectPdfMetadata } from "../../../../../modules/pdf-utils";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
import GenericError from "../../../../../Errors/GenericError";
|
||||||
|
|
||||||
|
const VALID_FETCH_ACTIONS = ["download", "print"] as const;
|
||||||
|
type FetchAction = (typeof VALID_FETCH_ACTIONS)[number];
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/download?fetchAction=download|print */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/:identifier/quote/:quoteId/download"],
|
||||||
|
async (c) => {
|
||||||
|
const quoteId = c.req.param("quoteId");
|
||||||
|
const user = c.get("user");
|
||||||
|
const fetchAction = c.req.query("fetchAction") as FetchAction | undefined;
|
||||||
|
|
||||||
|
if (!fetchAction || !VALID_FETCH_ACTIONS.includes(fetchAction)) {
|
||||||
|
throw new GenericError({
|
||||||
|
status: 400,
|
||||||
|
name: "InvalidFetchAction",
|
||||||
|
message: `Query parameter 'fetchAction' is required and must be one of: ${VALID_FETCH_ACTIONS.join(", ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const quote = await generatedQuotes.recordDownload(quoteId, {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
fetchAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inject download-time metadata into the PDF's document properties
|
||||||
|
const pdfWithMetadata = await injectPdfMetadata(quote.quoteFile, {
|
||||||
|
downloadedAt,
|
||||||
|
downloadedById: user.id,
|
||||||
|
downloadedByName: user.name ?? undefined,
|
||||||
|
downloadedByEmail: user.email ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = apiResponse.successful("Quote downloaded successfully!", {
|
||||||
|
id: quote.id,
|
||||||
|
quoteFileName: quote.quoteFileName,
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
contentBase64: Buffer.from(pdfWithMetadata).toString("base64"),
|
||||||
|
});
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.quote.download"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../../../managers/opportunities";
|
||||||
|
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||||
|
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/:identifier/quotes */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/:identifier/quotes"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const includeRegenData = c.req.query("includeRegenData") === "true";
|
||||||
|
const includeRegenParams = c.req.query("includeRegenParams") === "true";
|
||||||
|
|
||||||
|
const item = await opportunities.fetchRecord(identifier);
|
||||||
|
const quotes = await generatedQuotes.fetchByOpportunity(item.id);
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Committed quotes fetched successfully!",
|
||||||
|
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams })),
|
||||||
|
);
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.quote.fetch"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||||
|
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||||
|
import { opportunities } from "../../../../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/:identifier/quotes/downloads */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/:identifier/quotes/downloads"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
|
||||||
|
const opportunity = await opportunities.fetchRecord(identifier);
|
||||||
|
const quotes = await generatedQuotes.fetchByOpportunity(opportunity.id);
|
||||||
|
|
||||||
|
const data = quotes.map((quote) => ({
|
||||||
|
quoteId: quote.id,
|
||||||
|
quoteFileName: quote.quoteFileName,
|
||||||
|
createdById: quote.createdById,
|
||||||
|
createdAt: quote.createdAt,
|
||||||
|
downloads: quote.downloads,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Quote download history fetched successfully!",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.quote.fetch_downloads"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../../../managers/opportunities";
|
||||||
|
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||||
|
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/preview */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/:identifier/quote/:quoteId/preview"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const quoteId = c.req.param("quoteId");
|
||||||
|
|
||||||
|
const item = await opportunities.fetchRecord(identifier);
|
||||||
|
const quote = await generatedQuotes.fetch(quoteId);
|
||||||
|
|
||||||
|
const regenData =
|
||||||
|
quote.quoteRegenData && typeof quote.quoteRegenData === "object"
|
||||||
|
? (quote.quoteRegenData as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const options =
|
||||||
|
regenData.options && typeof regenData.options === "object"
|
||||||
|
? (regenData.options as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const creator = await quote.fetchCreatedBy();
|
||||||
|
|
||||||
|
const previewBuffer = await item.generateQuote({
|
||||||
|
lineItemPricing: options.lineItemPricing as boolean | undefined,
|
||||||
|
includeQuoteNarrative: options.includeQuoteNarrative as
|
||||||
|
| boolean
|
||||||
|
| undefined,
|
||||||
|
includeItemNarratives: options.includeItemNarratives as
|
||||||
|
| boolean
|
||||||
|
| undefined,
|
||||||
|
showPreview: true,
|
||||||
|
metadata: {
|
||||||
|
quoteId: quote.id,
|
||||||
|
createdById: quote.createdById ?? undefined,
|
||||||
|
createdByName: creator?.name ?? undefined,
|
||||||
|
createdByEmail: creator?.email ?? undefined,
|
||||||
|
createdAt: quote.createdAt?.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewBase64 = Buffer.from(previewBuffer).toString("base64");
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Quote preview generated successfully!",
|
||||||
|
{
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
contentBase64: previewBase64,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.quote.preview"] }),
|
||||||
|
);
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
import { 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(
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
},
|
},
|
||||||
@@ -782,6 +1194,41 @@ export class OpportunityController {
|
|||||||
return this.fetchProducts();
|
return this.fetchProducts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append Product Sequence IDs
|
||||||
|
*
|
||||||
|
* Adds newly created forecast item IDs to the end of the local
|
||||||
|
* productSequence array, preserving existing order and avoiding duplicates.
|
||||||
|
*/
|
||||||
|
private async appendProductSequenceIds(ids: number[]): Promise<void> {
|
||||||
|
const normalizedIds = ids.filter(
|
||||||
|
(id): id is number => Number.isInteger(id) && id > 0,
|
||||||
|
);
|
||||||
|
if (normalizedIds.length === 0) return;
|
||||||
|
|
||||||
|
const current = await prisma.opportunity.findUnique({
|
||||||
|
where: { id: this.id },
|
||||||
|
select: { productSequence: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = current?.productSequence ?? [];
|
||||||
|
const existingSet = new Set(existing);
|
||||||
|
const idsToAppend = normalizedIds.filter((id) => !existingSet.has(id));
|
||||||
|
if (idsToAppend.length === 0) {
|
||||||
|
this.productSequence = existing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSequence = [...existing, ...idsToAppend];
|
||||||
|
|
||||||
|
await prisma.opportunity.update({
|
||||||
|
where: { id: this.id },
|
||||||
|
data: { productSequence: updatedSequence },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.productSequence = updatedSequence;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add Products
|
* Add Products
|
||||||
*
|
*
|
||||||
@@ -800,6 +1247,7 @@ export class OpportunityController {
|
|||||||
this.cwOpportunityId,
|
this.cwOpportunityId,
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
|
await this.appendProductSequenceIds(created.map((item) => item.id));
|
||||||
await invalidateProductsCache(this.cwOpportunityId);
|
await invalidateProductsCache(this.cwOpportunityId);
|
||||||
return created.map((item) => new ForecastProductController(item));
|
return created.map((item) => new ForecastProductController(item));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -845,6 +1293,11 @@ export class OpportunityController {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const created = await opportunityCw.createProcurementProducts(normalized);
|
const created = await opportunityCw.createProcurementProducts(normalized);
|
||||||
|
await this.appendProductSequenceIds(
|
||||||
|
created
|
||||||
|
.map((item) => item.forecastDetailId)
|
||||||
|
.filter((id): id is number => typeof id === "number"),
|
||||||
|
);
|
||||||
await invalidateProductsCache(this.cwOpportunityId);
|
await invalidateProductsCache(this.cwOpportunityId);
|
||||||
return created;
|
return created;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -873,6 +1326,91 @@ export class OpportunityController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Procurement Product By Forecast Item
|
||||||
|
*
|
||||||
|
* Returns the linked procurement product for a forecast item ID,
|
||||||
|
* or null when no procurement record exists.
|
||||||
|
*/
|
||||||
|
public async fetchProcurementProductByForecastItem(
|
||||||
|
forecastItemId: number,
|
||||||
|
): Promise<CWProcurementProduct | null> {
|
||||||
|
return opportunityCw.fetchProcurementProductByForecastDetail(
|
||||||
|
this.cwOpportunityId,
|
||||||
|
forecastItemId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Procurement Product By Forecast Item
|
||||||
|
*
|
||||||
|
* Finds the linked procurement product for a forecast item and updates it.
|
||||||
|
* Returns null when no linked procurement product exists.
|
||||||
|
*/
|
||||||
|
public async updateProcurementProductByForecastItem(
|
||||||
|
forecastItemId: number,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<CWProcurementProduct | null> {
|
||||||
|
const linked =
|
||||||
|
await this.fetchProcurementProductByForecastItem(forecastItemId);
|
||||||
|
if (!linked?.id) return null;
|
||||||
|
|
||||||
|
const updated = await opportunityCw.updateProcurementProduct(
|
||||||
|
linked.id,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
await invalidateProductsCache(this.cwOpportunityId);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set Product Cancellation
|
||||||
|
*
|
||||||
|
* Updates cancellation fields on the procurement product linked to a
|
||||||
|
* forecast item. A quantity of 0 is treated as uncancelled.
|
||||||
|
*/
|
||||||
|
public async setProductCancellation(
|
||||||
|
forecastItemId: number,
|
||||||
|
opts: { quantityCancelled: number; cancellationReason?: string | null },
|
||||||
|
): Promise<CWProcurementProduct> {
|
||||||
|
const linked =
|
||||||
|
await this.fetchProcurementProductByForecastItem(forecastItemId);
|
||||||
|
|
||||||
|
if (!linked?.id) {
|
||||||
|
throw new GenericError({
|
||||||
|
status: 404,
|
||||||
|
name: "ProcurementProductNotFound",
|
||||||
|
message:
|
||||||
|
"No linked procurement product found for the specified forecast item",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantityCancelled = Math.max(0, Math.trunc(opts.quantityCancelled));
|
||||||
|
const cancelledFlag = quantityCancelled > 0;
|
||||||
|
|
||||||
|
const updated = await this.updateProcurementProductByForecastItem(
|
||||||
|
forecastItemId,
|
||||||
|
{
|
||||||
|
quantityCancelled,
|
||||||
|
cancelledFlag,
|
||||||
|
cancelledReason: cancelledFlag
|
||||||
|
? (opts.cancellationReason ?? null)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new GenericError({
|
||||||
|
status: 404,
|
||||||
|
name: "ProcurementProductNotFound",
|
||||||
|
message:
|
||||||
|
"No linked procurement product found for the specified forecast item",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add Note
|
* Add Note
|
||||||
*
|
*
|
||||||
@@ -938,7 +1476,7 @@ export class OpportunityController {
|
|||||||
id: this.id,
|
id: this.id,
|
||||||
cwOpportunityId: this.cwOpportunityId,
|
cwOpportunityId: this.cwOpportunityId,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
notes: this.notes,
|
description: this.notes,
|
||||||
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
|
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
|
||||||
stage: this.stageCwId
|
stage: this.stageCwId
|
||||||
? { id: this.stageCwId, name: this.stageName }
|
? { id: this.stageCwId, name: this.stageName }
|
||||||
@@ -989,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,
|
||||||
|
|||||||
@@ -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.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -17,6 +17,61 @@ const catalogItemInclude = {
|
|||||||
linkedItems: true,
|
linkedItems: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const LABOR_STYLE_CANDIDATES = {
|
||||||
|
field: ["LABOR & INSTALLATION - FIELD", "LABOR - FIELD", "LABOR FIELD"],
|
||||||
|
tech: ["LABOR & INSTALLATION - TECH", "LABOR - TECH", "LABOR TECH"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
async function findCatalogByExactCandidates(
|
||||||
|
candidates: readonly string[],
|
||||||
|
): Promise<CatalogItemController | null> {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const item = await prisma.catalogItem.findFirst({
|
||||||
|
where: {
|
||||||
|
inactive: false,
|
||||||
|
OR: [
|
||||||
|
{ identifier: { equals: candidate, mode: "insensitive" } },
|
||||||
|
{ name: { equals: candidate, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: catalogItemInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (item) return new CatalogItemController(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCatalogByLaborStyle(
|
||||||
|
style: "field" | "tech",
|
||||||
|
): Promise<CatalogItemController | null> {
|
||||||
|
const fallback = await prisma.catalogItem.findFirst({
|
||||||
|
where: {
|
||||||
|
inactive: false,
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ identifier: { contains: "labor", mode: "insensitive" } },
|
||||||
|
{ name: { contains: "labor", mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ identifier: { contains: style, mode: "insensitive" } },
|
||||||
|
{ name: { contains: style, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: catalogItemInclude,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fallback) return null;
|
||||||
|
return new CatalogItemController(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter options for catalog item queries.
|
* Filter options for catalog item queries.
|
||||||
*/
|
*/
|
||||||
@@ -204,6 +259,36 @@ export const procurement = {
|
|||||||
return new CatalogItemController(item);
|
return new CatalogItemController(item);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Labor Catalog Items
|
||||||
|
*
|
||||||
|
* Resolves canonical Field and Tech labor products from the local catalog.
|
||||||
|
* Prefers exact identifier/name matches, then falls back to keyword matching.
|
||||||
|
*/
|
||||||
|
async fetchLaborCatalogItems(): Promise<{
|
||||||
|
field: CatalogItemController;
|
||||||
|
tech: CatalogItemController;
|
||||||
|
}> {
|
||||||
|
const fieldItem =
|
||||||
|
(await findCatalogByExactCandidates(LABOR_STYLE_CANDIDATES.field)) ??
|
||||||
|
(await findCatalogByLaborStyle("field"));
|
||||||
|
const techItem =
|
||||||
|
(await findCatalogByExactCandidates(LABOR_STYLE_CANDIDATES.tech)) ??
|
||||||
|
(await findCatalogByLaborStyle("tech"));
|
||||||
|
|
||||||
|
if (!fieldItem || !techItem) {
|
||||||
|
throw new GenericError({
|
||||||
|
message: "Labor catalog products are not configured",
|
||||||
|
name: "LaborCatalogProductsNotFound",
|
||||||
|
cause:
|
||||||
|
"Expected active FIELD and TECH labor catalog items in the local catalog",
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { field: fieldItem, tech: techItem };
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch All Catalog Items (Paginated)
|
* Fetch All Catalog Items (Paginated)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
@@ -381,4 +381,45 @@ export const opportunityCw = {
|
|||||||
|
|
||||||
return created;
|
return created;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Procurement Product by Forecast Detail
|
||||||
|
*
|
||||||
|
* Finds the procurement product linked to a given forecast item ID
|
||||||
|
* on an opportunity.
|
||||||
|
*/
|
||||||
|
fetchProcurementProductByForecastDetail: async (
|
||||||
|
opportunityId: number,
|
||||||
|
forecastDetailId: number,
|
||||||
|
): Promise<CWProcurementProduct | null> => {
|
||||||
|
const conditions = `opportunity/id=${opportunityId} and forecastDetailId=${forecastDetailId}`;
|
||||||
|
const response = await connectWiseApi.get(
|
||||||
|
`/procurement/products?conditions=${encodeURIComponent(conditions)}&fields=id,forecastDetailId,description,customerDescription,quantity,price,cost,taxableFlag,specialOrderFlag,customFields`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = (response.data ?? []) as CWProcurementProduct[];
|
||||||
|
return items[0] ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Procurement Product
|
||||||
|
*
|
||||||
|
* Applies a JSON Patch update to a procurement product record.
|
||||||
|
*/
|
||||||
|
updateProcurementProduct: async (
|
||||||
|
procurementProductId: number,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<CWProcurementProduct> => {
|
||||||
|
const operations = Object.entries(data).map(([key, value]) => ({
|
||||||
|
op: "replace" as const,
|
||||||
|
path: key,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await connectWiseApi.patch(
|
||||||
|
`/procurement/products/${procurementProductId}`,
|
||||||
|
operations,
|
||||||
|
);
|
||||||
|
return response.data as CWProcurementProduct;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export interface CWOpportunity {
|
|||||||
closedDate: string;
|
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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,47 +412,51 @@ 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"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
node: "sales.opportunity.product.update",
|
node: "sales.opportunity.product.update",
|
||||||
description:
|
description:
|
||||||
"Update products (forecast items) on an opportunity, including resequencing",
|
"Update products (forecast items) on an opportunity, including resequencing",
|
||||||
usedIn: ["src/api/sales/[id]/resequenceProducts.ts"],
|
usedIn: [
|
||||||
|
"src/api/sales/opportunities/[id]/products/resequence.ts",
|
||||||
|
"src/api/sales/opportunities/[id]/products/update.ts",
|
||||||
|
"src/api/sales/opportunities/[id]/products/cancel.ts",
|
||||||
|
],
|
||||||
dependencies: ["sales.opportunity.fetch"],
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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",
|
||||||
@@ -477,7 +482,53 @@ 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"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.product.add.labor",
|
||||||
|
description:
|
||||||
|
"Add labor products to an opportunity using the dedicated labor route with Field/Tech catalog selection and pricing inputs.",
|
||||||
|
usedIn: [
|
||||||
|
"src/api/sales/opportunities/[id]/products/addLabor.ts",
|
||||||
|
"src/api/sales/opportunities/[id]/products/laborOptions.ts",
|
||||||
|
],
|
||||||
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.quote.fetch",
|
||||||
|
description: "Fetch all committed quotes for an opportunity.",
|
||||||
|
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchAll.ts"],
|
||||||
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.quote.commit",
|
||||||
|
description:
|
||||||
|
"Generate and store a finalized quote PDF for an opportunity with regeneration metadata and creator attribution.",
|
||||||
|
usedIn: ["src/api/sales/opportunities/[id]/quotes/commit.ts"],
|
||||||
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.quote.preview",
|
||||||
|
description:
|
||||||
|
"Generate a preview-stamped quote PDF for an opportunity without storing it.",
|
||||||
|
usedIn: ["src/api/sales/opportunities/[id]/quotes/preview.ts"],
|
||||||
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.quote.download",
|
||||||
|
description:
|
||||||
|
"Download a committed quote PDF. Each download is recorded with timestamp and user info.",
|
||||||
|
usedIn: ["src/api/sales/opportunities/[id]/quotes/download.ts"],
|
||||||
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.quote.fetch_downloads",
|
||||||
|
description:
|
||||||
|
"Fetch download/print history for all quotes on an opportunity. Admin-level permission.",
|
||||||
|
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts"],
|
||||||
dependencies: ["sales.opportunity.fetch"],
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -940,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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user