From 1907bb433b79394e2d560662574842f038e5a724 Mon Sep 17 00:00:00 2001 From: Jackson Roberts Date: Fri, 6 Mar 2026 23:25:37 -0600 Subject: [PATCH] feat: restructure sales, add PDF quote generation and WebSocket support --- API_ROUTES.md | 408 +++- PERMISSIONS.md | 32 +- bun.lock | 58 + debug-scripts/generate_log_report.py | 900 +++++++++ generated/prisma/browser.ts | 5 + generated/prisma/client.ts | 5 + generated/prisma/commonInputTypes.ts | 34 + generated/prisma/internal/class.ts | 14 +- generated/prisma/internal/prismaNamespace.ts | 112 +- .../prisma/internal/prismaNamespaceBrowser.ts | 21 +- generated/prisma/models.ts | 1 + generated/prisma/models/GeneratedQuotes.ts | 1711 +++++++++++++++++ generated/prisma/models/Opportunity.ts | 352 +++- generated/prisma/models/User.ts | 157 ++ logo.png | Bin 0 -> 52102 bytes package.json | 2 + .../migration.sql | 9 + .../migration.sql | 19 + prisma/schema.prisma | 30 +- src/api/sales/[id]/fetch.ts | 8 +- src/api/sales/index.ts | 46 +- .../{ => opportunities}/[id]/contacts.ts | 8 +- src/api/sales/opportunities/[id]/fetch.ts | 187 ++ .../[id]/notes/create.ts} | 10 +- .../[id]/notes/delete.ts} | 10 +- .../[id]/notes/fetch.ts} | 10 +- .../[id]/notes/fetchAll.ts} | 8 +- .../[id]/notes/update.ts} | 12 +- .../[id]/products/add.ts} | 10 +- .../[id]/products}/addLabor.ts | 10 +- .../[id]/products/addSpecialOrder.ts} | 10 +- .../[id]/products/cancel.ts} | 14 +- .../[id]/products/fetchAll.ts} | 8 +- .../[id]/products}/laborOptions.ts | 10 +- .../[id]/products/resequence.ts} | 8 +- .../[id]/products/update.ts} | 77 +- .../sales/opportunities/[id]/quotes/commit.ts | 39 + .../opportunities/[id]/quotes/download.ts | 55 + .../opportunities/[id]/quotes/fetchAll.ts | 27 + .../[id]/quotes/fetchDownloads.ts | 33 + .../opportunities/[id]/quotes/preview.ts | 61 + .../sales/{ => opportunities}/[id]/refresh.ts | 8 +- src/api/sales/{ => opportunities}/count.ts | 8 +- src/api/sales/{ => opportunities}/fetchAll.ts | 10 +- src/api/sockets/events/liveQuotePreview.ts | 102 + src/api/sockets/index.ts | 5 + src/api/sockets/middleware/authorization.ts | 71 + src/api/sockets/secure.ts | 128 ++ src/controllers/ForecastProductController.ts | 60 + src/controllers/GeneratedQuoteController.ts | 131 ++ src/controllers/OpportunityController.ts | 413 ++++ src/index.ts | 4 + src/managers/generatedQuotes.ts | 188 ++ src/managers/opportunities.ts | 2 +- .../cw-utils/opportunities/opportunities.ts | 2 +- .../opportunities/opportunity.types.ts | 1 + .../processOpportunityResponse.ts | 1 + src/modules/pdf-utils/generateQuote.ts | 782 ++++++++ src/modules/pdf-utils/index.ts | 2 + src/modules/pdf-utils/injectPdfMetadata.ts | 48 + src/types/PermissionNodes.ts | 74 +- tests/setup.ts | 17 + .../controllers/CatalogItemController.test.ts | 223 +++ .../ForecastProductController.test.ts | 114 ++ .../GeneratedQuoteController.test.ts | 171 ++ tests/unit/cwCallback.test.ts | 241 +++ tests/unit/cwConcurrencyLimiter.test.ts | 184 ++ tests/unit/generatedQuotesManager.test.ts | 261 +++ tests/unit/permissionNodes.test.ts | 8 + tests/unit/procurement.test.ts | 1 + tests/unit/secureValues.test.ts | 160 ++ tests/unit/signPermissions.test.ts | 89 + tests/unit/withCwRetry.test.ts | 245 +++ 73 files changed, 8115 insertions(+), 170 deletions(-) create mode 100644 debug-scripts/generate_log_report.py create mode 100644 generated/prisma/models/GeneratedQuotes.ts create mode 100644 logo.png create mode 100644 prisma/migrations/20260305012604_store_generated_pdf_bytes/migration.sql create mode 100644 prisma/migrations/20260305013723_expand_generated_quotes_model/migration.sql rename src/api/sales/{ => opportunities}/[id]/contacts.ts (69%) create mode 100644 src/api/sales/opportunities/[id]/fetch.ts rename src/api/sales/{[id]/createNote.ts => opportunities/[id]/notes/create.ts} (76%) rename src/api/sales/{[id]/deleteNote.ts => opportunities/[id]/notes/delete.ts} (70%) rename src/api/sales/{[id]/fetchNote.ts => opportunities/[id]/notes/fetch.ts} (70%) rename src/api/sales/{[id]/notes.ts => opportunities/[id]/notes/fetchAll.ts} (67%) rename src/api/sales/{[id]/updateNote.ts => opportunities/[id]/notes/update.ts} (77%) rename src/api/sales/{[id]/addProduct.ts => opportunities/[id]/products/add.ts} (85%) rename src/api/sales/{[id] => opportunities/[id]/products}/addLabor.ts (92%) rename src/api/sales/{[id]/addSpecialOrderProduct.ts => opportunities/[id]/products/addSpecialOrder.ts} (91%) rename src/api/sales/{[id]/cancelProduct.ts => opportunities/[id]/products/cancel.ts} (83%) rename src/api/sales/{[id]/products.ts => opportunities/[id]/products/fetchAll.ts} (69%) rename src/api/sales/{[id] => opportunities/[id]/products}/laborOptions.ts (79%) rename src/api/sales/{[id]/resequenceProducts.ts => opportunities/[id]/products/resequence.ts} (77%) rename src/api/sales/{[id]/updateProduct.ts => opportunities/[id]/products/update.ts} (78%) create mode 100644 src/api/sales/opportunities/[id]/quotes/commit.ts create mode 100644 src/api/sales/opportunities/[id]/quotes/download.ts create mode 100644 src/api/sales/opportunities/[id]/quotes/fetchAll.ts create mode 100644 src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts create mode 100644 src/api/sales/opportunities/[id]/quotes/preview.ts rename src/api/sales/{ => opportunities}/[id]/refresh.ts (70%) rename src/api/sales/{ => opportunities}/count.ts (67%) rename src/api/sales/{ => opportunities}/fetchAll.ts (80%) create mode 100644 src/api/sockets/events/liveQuotePreview.ts create mode 100644 src/api/sockets/index.ts create mode 100644 src/api/sockets/middleware/authorization.ts create mode 100644 src/api/sockets/secure.ts create mode 100644 src/controllers/GeneratedQuoteController.ts create mode 100644 src/managers/generatedQuotes.ts create mode 100644 src/modules/pdf-utils/generateQuote.ts create mode 100644 src/modules/pdf-utils/index.ts create mode 100644 src/modules/pdf-utils/injectPdfMetadata.ts create mode 100644 tests/unit/controllers/CatalogItemController.test.ts create mode 100644 tests/unit/controllers/GeneratedQuoteController.test.ts create mode 100644 tests/unit/cwCallback.test.ts create mode 100644 tests/unit/cwConcurrencyLimiter.test.ts create mode 100644 tests/unit/generatedQuotesManager.test.ts create mode 100644 tests/unit/secureValues.test.ts create mode 100644 tests/unit/signPermissions.test.ts create mode 100644 tests/unit/withCwRetry.test.ts diff --git a/API_ROUTES.md b/API_ROUTES.md index a1a488b..64512c8 100644 --- a/API_ROUTES.md +++ b/API_ROUTES.md @@ -8,6 +8,111 @@ This document provides a comprehensive overview of all API routes available in t 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 ` or `Key ` +- `handshake.auth.authorization` as `Bearer ` or `Key ` +- `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": "" +} +``` + +**Ack Response (success):** + +```json +{ + "ok": true, + "event": "opp:live_quote_preview::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::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::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::preview` + +```json +{ + "id": "", + "mimeType": "application/pdf", + "contentBase64": "JVBERi0xLjQKJ..." +} +``` + +**Server → Client Event (error):** `opp:live_quote_preview:error` + +```json +{ + "id": "", + "message": "Failed to generate live quote preview" +} +``` + --- ## 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" }, "customerPO": null, "totalSalesTax": 0, + "probability": 50, "location": { "id": 1, "name": "Murray" }, "department": { "id": 5, "name": "Sales" }, "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:** -- `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:** @@ -3062,6 +3170,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The }, "customerPO": null, "totalSalesTax": 0, + "probability": 50, "location": { "id": 1, "name": "Murray" }, "department": { "id": 5, "name": "Sales" }, "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" }, "customerPO": null, "totalSalesTax": 0, + "probability": 50, "location": { "id": 1, "name": "Murray" }, "department": { "id": 5, "name": "Sales" }, "expectedCloseDate": "2026-04-15T00:00:00.000Z", @@ -3835,6 +3945,302 @@ Add a labor line item to an opportunity using one of the two canonical labor cat --- +### Fetch Committed Quotes + +**GET** `/sales/opportunities/:identifier/quotes` + +Fetch all committed (finalized) quotes for an opportunity, ordered by most recent first. + +**Authentication Required:** Yes + +**Required Permissions:** `sales.opportunity.quote.fetch` + +**Path Parameters:** + +- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric) + +**Query Parameters:** + +- `includeRegenData` _(optional)_ — When `"true"`, includes `quoteRegenData` on each quote object. Default: `false`. +- `includeRegenParams` _(optional)_ — When `"true"`, includes `quoteRegenParams` on each quote object. Default: `false`. + +**Response:** + +```json +{ + "status": 200, + "message": "Committed quotes fetched successfully!", + "data": [ + { + "id": "clx9abc123...", + "quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf", + "quoteRegenHash": "a1b2c3d4e5f6...", + "opportunityId": "clx9xyz789...", + "createdById": "clx9user456...", + "quoteRegenData": "(included when ?includeRegenData=true)", + "quoteRegenParams": "(included when ?includeRegenParams=true)", + "createdAt": "2026-03-06T12:00:00.000Z", + "updatedAt": "2026-03-06T12:00:00.000Z" + } + ], + "successful": true +} +``` + +--- + +### Commit Quote + +**POST** `/sales/opportunities/:identifier/quote/commit` + +Generate a finalized (non-preview) quote PDF for an opportunity and store it in the database with regeneration metadata and creator attribution. + +**Authentication Required:** Yes + +**Required Permissions:** `sales.opportunity.quote.commit` + +**Path Parameters:** + +- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric) + +**Request Body (optional):** + +```json +{ + "lineItemPricing": true, + "includeQuoteNarrative": true, + "includeItemNarratives": true +} +``` + +| Field | Type | Required | Default | Description | +| ----------------------- | ------- | -------- | ------- | ---------------------------------------------- | +| `lineItemPricing` | boolean | No | `true` | Include per-line-item pricing in the quote PDF | +| `includeQuoteNarrative` | boolean | No | `true` | Include the quote-level narrative section | +| `includeItemNarratives` | boolean | No | `true` | Include per-item narrative sections | + +**Response:** + +```json +{ + "status": 201, + "message": "Quote committed successfully!", + "data": { + "id": "clx9abc123...", + "quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf", + "quoteRegenHash": "a1b2c3d4e5f6...", + "opportunityId": "clx9xyz789...", + "createdById": "clx9user456...", + "quoteRegenData": { + "options": { + "lineItemPricing": true, + "includeQuoteNarrative": true, + "includeItemNarratives": true + }, + "opportunity": { + "id": "clx9xyz789...", + "cwOpportunityId": 12345, + "name": "Network Refresh – Acme Corp", + "totalSalesTax": 245.5, + "contactName": "Jane Smith", + "companyName": "Acme Corp" + }, + "customer": { + "preparedFor": "Jane Smith", + "companyName": "Acme Corp", + "primaryContact": { + "firstName": "Jane", + "lastName": "Smith", + "email": "jane@acme.com", + "phone": "801-555-1234" + }, + "siteAddress": ["123 Main St", "Salt Lake City UT 84101"], + "companyAddress": ["456 Corporate Blvd", "Murray UT 84107"] + }, + "salesRep": { + "name": "John Roberts", + "email": "jroberts@example.com" + }, + "quoteNarrative": "Full network infrastructure refresh including...", + "products": [ + { + "cwForecastId": 101, + "forecastDescription": "UniFi U6-Pro AP", + "productDescription": "UniFi U6-Pro Access Point", + "customerDescription": "Wireless access point", + "productNarrative": "Install and validate onsite", + "productClass": "Product", + "forecastType": "Products", + "catalogItem": { "id": 500, "identifier": "U6-PRO" }, + "quantity": 4, + "effectiveQuantity": 4, + "revenue": 800.0, + "cost": 520.0, + "margin": 280.0, + "percentage": 35.0, + "includeFlag": true, + "taxableFlag": true, + "recurringFlag": false, + "recurringRevenue": 0, + "recurringCost": 0, + "sequenceNumber": 1, + "cancelledFlag": false, + "cancellationType": null, + "quantityCancelled": 0, + "cancelledReason": null, + "cancelledDate": null + } + ], + "snapshotTimestamp": "2026-03-06T12:00:00.000Z" + }, + "quoteRegenParams": { + "opportunityId": "clx9xyz789...", + "cwOpportunityId": 12345 + }, + "createdAt": "2026-03-06T12:00:00.000Z", + "updatedAt": "2026-03-06T12:00:00.000Z" + }, + "successful": true +} +``` + +--- + +### Preview Quote + +**GET** `/sales/opportunities/:identifier/quote/:quoteId/preview` + +Regenerate a preview-stamped version of an existing committed quote PDF using its stored generation parameters. The PDF is not persisted — it is returned as a base64-encoded string. + +**Authentication Required:** Yes + +**Required Permissions:** `sales.opportunity.quote.preview` + +**Path Parameters:** + +- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric) +- `quoteId` — The generated quote's internal ID (uuid) + +**Response:** + +```json +{ + "status": 200, + "message": "Quote preview generated successfully!", + "data": { + "mimeType": "application/pdf", + "contentBase64": "JVBERi0xLjQK... (base64-encoded PDF)" + }, + "successful": true +} +``` + +--- + +### Download Quote + +**GET** `/sales/opportunities/:identifier/quote/:quoteId/download` + +Download a committed quote PDF by its ID. Returns the PDF file as a base64-encoded string. Each call automatically records a download entry with the timestamp, user info, and fetch action in the quote's `downloads` array. Download-time metadata (downloadedAt, downloadedBy) is also injected into the PDF's document properties. + +**Authentication Required:** Yes + +**Required Permissions:** `sales.opportunity.quote.download` + +**Path Parameters:** + +- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric) +- `quoteId` — The generated quote's internal ID (uuid) + +**Query Parameters:** + +- `fetchAction` **(required)** — The action being performed. Must be one of: `download`, `print`. Tracked in the download record for audit purposes. + +**Response:** + +```json +{ + "status": 200, + "message": "Quote downloaded successfully!", + "data": { + "id": "a1b2c3d4-e5f6-...", + "quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf", + "mimeType": "application/pdf", + "contentBase64": "JVBERi0xLjQK... (base64-encoded PDF)" + }, + "successful": true +} +``` + +**Download Record Shape (stored in `downloads` JSON array):** + +```json +{ + "downloadedAt": "2026-03-06T15:30:00.000Z", + "fetchAction": "download", + "userId": "clx9user456...", + "userName": "John Roberts", + "userEmail": "jroberts@example.com" +} +``` + +**Error Responses:** + +- `400` — Missing or invalid `fetchAction` query parameter. +- `404` — Generated quote not found. + +--- + +### Fetch Quote Download History + +**GET** `/sales/opportunities/:identifier/quotes/downloads` + +Fetch download/print history for all committed quotes on an opportunity. Returns an array of quote summaries, each containing the full `downloads` array with every download/print record. This is an admin-level route intended for audit and tracking purposes. + +**Authentication Required:** Yes + +**Required Permissions:** `sales.opportunity.quote.fetch_downloads` + +**Path Parameters:** + +- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric) + +**Response:** + +```json +{ + "status": 200, + "message": "Download logs fetched successfully!", + "data": [ + { + "quoteId": "a1b2c3d4-e5f6-...", + "quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf", + "createdById": "clx9user123...", + "createdAt": "2026-03-06T12:00:00.000Z", + "downloads": [ + { + "downloadedAt": "2026-03-06T15:30:00.000Z", + "fetchAction": "download", + "userId": "clx9user456...", + "userName": "John Roberts", + "userEmail": "jroberts@example.com" + }, + { + "downloadedAt": "2026-03-06T16:00:00.000Z", + "fetchAction": "print", + "userId": "clx9user789...", + "userName": "Jane Smith", + "userEmail": "jsmith@example.com" + } + ] + } + ], + "successful": true +} +``` + +--- + ### Get Opportunity Notes **GET** `/sales/opportunities/:identifier/notes` diff --git a/PERMISSIONS.md b/PERMISSIONS.md index aa1468c..1a67fde 100644 --- a/PERMISSIONS.md +++ b/PERMISSIONS.md @@ -136,18 +136,25 @@ Admin-specific UI permissions that control visibility and data loading for admin Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (products, notes, contacts) are fetched live from CW. -| Permission Node | Description | Used In | Dependencies | -| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | -| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/products.ts](src/api/sales/[id]/products.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/fetchNote.ts](src/api/sales/[id]/fetchNote.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | | -| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.ts) | | -| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/[id]/createNote.ts](src/api/sales/[id]/createNote.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/[id]/updateNote.ts](src/api/sales/[id]/updateNote.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts), [src/api/sales/[id]/updateProduct.ts](src/api/sales/[id]/updateProduct.ts), [src/api/sales/[id]/cancelProduct.ts](src/api/sales/[id]/cancelProduct.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.` permissions. | [src/api/sales/[id]/addProduct.ts](src/api/sales/[id]/addProduct.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.product.add.specialOrder` | Add one or more "SPECIAL ORDER" products via the dedicated special-order route. | [src/api/sales/[id]/addSpecialOrderProduct.ts](src/api/sales/[id]/addSpecialOrderProduct.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.product.add.labor` | Add labor products via the dedicated labor route with Field/Tech catalog selection and labor pricing inputs. | [src/api/sales/[id]/addLabor.ts](src/api/sales/[id]/addLabor.ts), [src/api/sales/[id]/laborOptions.ts](src/api/sales/[id]/laborOptions.ts) | `sales.opportunity.fetch` | +**WebSocket note:** The `/secure` socket event chain `opp:live_quote_preview` and `opp:live_quote_preview::data` is gated by `sales.opportunity.fetch`. + +| Permission Node | Description | Used In | Dependencies | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | +| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/opportunities/[id]/fetch.ts](src/api/sales/opportunities/[id]/fetch.ts), [src/api/sales/opportunities/[id]/products/fetchAll.ts](src/api/sales/opportunities/[id]/products/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetchAll.ts](src/api/sales/opportunities/[id]/notes/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetch.ts](src/api/sales/opportunities/[id]/notes/fetch.ts), [src/api/sales/opportunities/[id]/contacts/fetchAll.ts](src/api/sales/opportunities/[id]/contacts/fetchAll.ts), [src/api/sockets/events/liveQuotePreview.ts](src/api/sockets/events/liveQuotePreview.ts) | | +| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/opportunities/fetchAll.ts](src/api/sales/opportunities/fetchAll.ts), [src/api/sales/opportunities/count.ts](src/api/sales/opportunities/count.ts), [src/api/sales/opportunities/fetchTypes.ts](src/api/sales/opportunities/fetchTypes.ts) | | +| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/opportunities/[id]/refresh.ts](src/api/sales/opportunities/[id]/refresh.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/opportunities/[id]/notes/create.ts](src/api/sales/opportunities/[id]/notes/create.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/opportunities/[id]/notes/update.ts](src/api/sales/opportunities/[id]/notes/update.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/opportunities/[id]/notes/delete.ts](src/api/sales/opportunities/[id]/notes/delete.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/opportunities/[id]/products/resequence.ts](src/api/sales/opportunities/[id]/products/resequence.ts), [src/api/sales/opportunities/[id]/products/update.ts](src/api/sales/opportunities/[id]/products/update.ts), [src/api/sales/opportunities/[id]/products/cancel.ts](src/api/sales/opportunities/[id]/products/cancel.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.` 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` |
Field-level permissions for sales.opportunity.product.add @@ -349,6 +356,7 @@ All fetch and fetchAll routes gate response object keys using `processObjectValu | `obj.opportunity.site` | View site | | `obj.opportunity.customerPO` | View customer PO | | `obj.opportunity.totalSalesTax` | View total sales tax | +| `obj.opportunity.probability` | View probability percentage | | `obj.opportunity.location` | View location | | `obj.opportunity.department` | View department | | `obj.opportunity.expectedCloseDate` | View expected close date | diff --git a/bun.lock b/bun.lock index 6a73852..335ca5a 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,8 @@ "ioredis": "^5.10.0", "jsonwebtoken": "^9.0.3", "keypair": "^1.0.4", + "pdf-lib": "^1.17.1", + "pdfmake": "^0.3.5", "prisma": "^7.3.0", "socket.io": "^4.8.3", "zod": "^4.3.6", @@ -62,6 +64,10 @@ "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="], + "@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="], + + "@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="], + "@prisma/adapter-pg": ["@prisma/adapter-pg@7.3.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.3.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-iuYQMbIPO6i9O45Fv8TB7vWu00BXhCaNAShenqF7gLExGDbnGp5BfFB4yz1K59zQ59jF6tQ9YHrg0P6/J3OoLg=="], "@prisma/client": ["@prisma/client@7.3.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.3.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-FXBIxirqQfdC6b6HnNgxGmU7ydCPEPk7maHMOduJJfnTP+MuOGa15X4omjR/zpPUUpm8ef/mEFQjJudOGkXFcQ=="], @@ -96,6 +102,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="], + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], @@ -116,10 +124,14 @@ "axios": ["axios@1.13.3", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g=="], + "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], + "base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], "blakets": ["blakets@0.1.12", "", { "dependencies": { "@prokopschield/argv": "^0.1.0-2" }, "bin": { "blake": "lib/demo.js", "blake2b": "lib/cli.js", "blake2s": "lib/cli.js", "blakejs": "lib/demo.js", "blakets": "lib/demo.js" } }, "sha512-ReOnLTDRlbExlTXbJZoA2xkvhzauJ7ldpvhKnb1cUNw8gdAHWHWOWG8XMjwpxQmmEZCDAR7VZiM5BYTUSOLVrw=="], + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], @@ -134,6 +146,8 @@ "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], @@ -148,6 +162,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "cuid": ["cuid@3.0.0", "", {}, "sha512-WZYYkHdIDnaxdeP8Misq3Lah5vFjJwGuItJuV+tvMafosMzw0nF297T7mrm8IOWiPJkV6gc7sa8pzx27+w25Zg=="], @@ -164,6 +180,8 @@ "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -190,8 +208,12 @@ "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -236,6 +258,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jpeg-exif": ["jpeg-exif@1.1.4", "", {}, "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], @@ -246,6 +270,8 @@ "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], @@ -292,10 +318,18 @@ "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="], + + "pdfkit": ["pdfkit@0.17.2", "", { "dependencies": { "crypto-js": "^4.2.0", "fontkit": "^2.0.4", "jpeg-exif": "^1.1.4", "linebreak": "^1.1.0", "png-js": "^1.0.0" } }, "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw=="], + + "pdfmake": ["pdfmake@0.3.5", "", { "dependencies": { "linebreak": "^1.1.0", "pdfkit": "^0.17.2", "xmldoc": "^2.0.3" } }, "sha512-DR7jRrK4lk7UiRT6pi+NeWhW1ToTsL2Y8CH+bFKNYz3M7agIVgeCtwARveEORhCAqoG3AUDrN318xU/lkOr1Bg=="], + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], "pg": ["pg@8.17.2", "", { "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw=="], @@ -316,6 +350,8 @@ "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + "png-js": ["png-js@1.0.0", "", {}, "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="], + "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], @@ -350,12 +386,16 @@ "remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="], + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -382,12 +422,20 @@ "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], @@ -398,6 +446,8 @@ "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "xmldoc": ["xmldoc@2.0.3", "", { "dependencies": { "sax": "^1.4.3" } }, "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="], @@ -414,10 +464,18 @@ "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + "@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="], "pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], } } diff --git a/debug-scripts/generate_log_report.py b/debug-scripts/generate_log_report.py new file mode 100644 index 0000000..50ec223 --- /dev/null +++ b/debug-scripts/generate_log_report.py @@ -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("No data", 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 actor is resolved as: entityUpdatedBy → query.memberId → summary.memberId. ' + '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("No actor data available.", 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'{aid}' + f'   ' + f'{ad["count"]:,} events', + 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('First Event', ss["BodyText2"]), + Paragraph(f'{first_str}', ss["BodyText2"]), + Paragraph('Last Event', ss["BodyText2"]), + Paragraph(f'{last_str}', ss["BodyText2"]), + ], + [ + Paragraph('Active Duration', ss["BodyText2"]), + Paragraph(f'{dur}', ss["BodyText2"]), + Paragraph('Total Est. Value', ss["BodyText2"]), + Paragraph(f'${est:,.2f}', ss["BodyText2"]), + ], + [ + Paragraph('Callback Members', ss["BodyText2"]), + Paragraph(f'{truncate(mid_str, 40)}', ss["BodyText2"]), + Paragraph('Sales Reps', 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'{id_str}', 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('First Event', ss["BodyText2"]), + Paragraph(f'{stats["first_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}', ss["BodyText2"]), + Paragraph('Last Event', ss["BodyText2"]), + Paragraph(f'{stats["last_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}', 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() diff --git a/generated/prisma/browser.ts b/generated/prisma/browser.ts index ad940dd..55953ea 100644 --- a/generated/prisma/browser.ts +++ b/generated/prisma/browser.ts @@ -67,3 +67,8 @@ export type SecureValue = Prisma.SecureValueModel * */ export type Credential = Prisma.CredentialModel +/** + * Model GeneratedQuotes + * + */ +export type GeneratedQuotes = Prisma.GeneratedQuotesModel diff --git a/generated/prisma/client.ts b/generated/prisma/client.ts index fd4f968..e4e816e 100644 --- a/generated/prisma/client.ts +++ b/generated/prisma/client.ts @@ -89,3 +89,8 @@ export type SecureValue = Prisma.SecureValueModel * */ export type Credential = Prisma.CredentialModel +/** + * Model GeneratedQuotes + * + */ +export type GeneratedQuotes = Prisma.GeneratedQuotesModel diff --git a/generated/prisma/commonInputTypes.ts b/generated/prisma/commonInputTypes.ts index c70536d..2427f6b 100644 --- a/generated/prisma/commonInputTypes.ts +++ b/generated/prisma/commonInputTypes.ts @@ -280,6 +280,23 @@ export type JsonWithAggregatesFilterBase<$PrismaModel = never> = { _max?: Prisma.NestedJsonFilter<$PrismaModel> } +export type BytesFilter<$PrismaModel = never> = { + equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel> + in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel> + notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel> + not?: Prisma.NestedBytesFilter<$PrismaModel> | runtime.Bytes +} + +export type BytesWithAggregatesFilter<$PrismaModel = never> = { + equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel> + in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel> + notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel> + not?: Prisma.NestedBytesWithAggregatesFilter<$PrismaModel> | runtime.Bytes + _count?: Prisma.NestedIntFilter<$PrismaModel> + _min?: Prisma.NestedBytesFilter<$PrismaModel> + _max?: Prisma.NestedBytesFilter<$PrismaModel> +} + export type NestedStringFilter<$PrismaModel = never> = { equals?: string | Prisma.StringFieldRefInput<$PrismaModel> in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> @@ -521,4 +538,21 @@ export type NestedJsonFilterBase<$PrismaModel = never> = { not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter } +export type NestedBytesFilter<$PrismaModel = never> = { + equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel> + in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel> + notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel> + not?: Prisma.NestedBytesFilter<$PrismaModel> | runtime.Bytes +} + +export type NestedBytesWithAggregatesFilter<$PrismaModel = never> = { + equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel> + in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel> + notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel> + not?: Prisma.NestedBytesWithAggregatesFilter<$PrismaModel> | runtime.Bytes + _count?: Prisma.NestedIntFilter<$PrismaModel> + _min?: Prisma.NestedBytesFilter<$PrismaModel> + _max?: Prisma.NestedBytesFilter<$PrismaModel> +} + diff --git a/generated/prisma/internal/class.ts b/generated/prisma/internal/class.ts index eb47bd9..955c952 100644 --- a/generated/prisma/internal/class.ts +++ b/generated/prisma/internal/class.ts @@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = { "clientVersion": "7.3.0", "engineVersion": "9d6ad21cbbceab97458517b147a6a09ff43aa735", "activeProvider": "postgresql", - "inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\n// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?\n// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel Session {\n id String @id @default(uuid())\n sessionKey String @unique @default(cuid())\n userId String\n expires DateTime\n refreshTokenGenerated Boolean @default(false)\n refreshedAt DateTime?\n invalidatedAt DateTime?\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\nmodel User {\n id String @id @default(cuid())\n roles Role[]\n permissions String?\n login String @unique\n name String?\n email String @unique\n emailVerified DateTime?\n image String?\n\n cwIdentifier String?\n\n userId String @unique\n token String?\n\n sessions Session[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Role {\n id String @id @default(uuid())\n title String\n moniker String @unique // e.g. admin, super_admin, moderator\n\n permissions String\n users User[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel UnifiSite {\n id String @id @default(cuid())\n name String\n\n siteId String @unique\n\n companyId String?\n company Company? @relation(fields: [companyId], references: [id])\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Company {\n id String @id @default(cuid())\n name String\n\n cw_CompanyId Int @unique\n cw_Identifier String @unique\n\n credentials Credential[]\n unifiSites UnifiSite[]\n opportunities Opportunity[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel CatalogItem {\n id String @id @default(cuid())\n cwCatalogId Int @unique\n identifier String? @unique\n name String\n description String?\n customerDescription String?\n internalNotes String?\n\n linkedItems CatalogItem[] @relation(\"LinkedItems\")\n linkedTo CatalogItem[] @relation(\"LinkedItems\")\n\n category String?\n categoryCwId Int?\n subcategory String?\n subcategoryCwId Int?\n\n manufacturer String?\n manufactureCwId Int?\n\n partNumber String?\n\n vendorName String?\n vendorSku String?\n vendorCwId Int?\n\n price Float\n cost Float\n\n inactive Boolean @default(false)\n salesTaxable Boolean @default(true)\n\n onHand Int @default(0)\n cwLastUpdated DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Opportunity {\n id String @id @default(cuid())\n cwOpportunityId Int @unique\n name String\n notes String?\n\n // Stage / status / priority / type / rating stored as JSON references\n // so we don't need separate lookup tables for CW enums\n typeName String?\n typeCwId Int?\n stageName String?\n stageCwId Int?\n statusName String?\n statusCwId Int?\n priorityName String?\n priorityCwId Int?\n ratingName String?\n ratingCwId Int?\n source String?\n campaignName String?\n campaignCwId Int?\n\n // Sales rep references\n primarySalesRepName String?\n primarySalesRepIdentifier String?\n primarySalesRepCwId Int?\n secondarySalesRepName String?\n secondarySalesRepIdentifier String?\n secondarySalesRepCwId Int?\n\n // Company / contact / site\n companyCwId Int?\n companyName String?\n contactCwId Int?\n contactName String?\n siteCwId Int?\n siteName String?\n customerPO String?\n\n // Financials\n totalSalesTax Float @default(0)\n\n // Location / department\n locationName String?\n locationCwId Int?\n departmentName String?\n departmentCwId Int?\n\n // Dates\n expectedCloseDate DateTime?\n pipelineChangeDate DateTime?\n dateBecameLead DateTime?\n closedDate DateTime?\n closedFlag Boolean @default(false)\n closedByName String?\n closedByCwId Int?\n\n // Internal relation to Company (optional, linked by cwCompanyId)\n companyId String?\n company Company? @relation(fields: [companyId], references: [id])\n\n // Local product sequence — array of CW forecast item IDs in display order.\n // When present, fetchProducts() uses this order instead of CW sequenceNumber.\n productSequence Int[] @default([])\n\n cwLastUpdated DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel CredentialType {\n id String @id @default(cuid())\n name String @unique\n\n permissionScope String\n icon String?\n fields Json\n\n credentials Credential[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel SecureValue {\n id String @id @default(cuid())\n name String\n\n content String // Encrypted content\n hash String // Hash of the original content for integrity verification and Search\n\n credentialId String\n credential Credential @relation(fields: [credentialId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Credential {\n id String @id @default(cuid())\n name String\n notes String?\n subCredentialOfId String?\n subCredentialOf Credential? @relation(\"SubCredentials\", fields: [subCredentialOfId], references: [id], onDelete: Cascade)\n subCredentials Credential[] @relation(\"SubCredentials\")\n\n typeId String\n type CredentialType @relation(fields: [typeId], references: [id], onDelete: Cascade)\n\n fields Json\n\n companyId String\n company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)\n\n securevalues SecureValue[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n", + "inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\n// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?\n// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel Session {\n id String @id @default(uuid())\n sessionKey String @unique @default(cuid())\n userId String\n expires DateTime\n refreshTokenGenerated Boolean @default(false)\n refreshedAt DateTime?\n invalidatedAt DateTime?\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\nmodel User {\n id String @id @default(cuid())\n roles Role[]\n permissions String?\n login String @unique\n name String?\n email String @unique\n emailVerified DateTime?\n image String?\n\n cwIdentifier String?\n\n userId String @unique\n token String?\n\n sessions Session[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n generatedQuotes GeneratedQuotes[]\n}\n\nmodel Role {\n id String @id @default(uuid())\n title String\n moniker String @unique // e.g. admin, super_admin, moderator\n\n permissions String\n users User[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel UnifiSite {\n id String @id @default(cuid())\n name String\n\n siteId String @unique\n\n companyId String?\n company Company? @relation(fields: [companyId], references: [id])\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Company {\n id String @id @default(cuid())\n name String\n\n cw_CompanyId Int @unique\n cw_Identifier String @unique\n\n credentials Credential[]\n unifiSites UnifiSite[]\n opportunities Opportunity[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel CatalogItem {\n id String @id @default(cuid())\n cwCatalogId Int @unique\n identifier String? @unique\n name String\n description String?\n customerDescription String?\n internalNotes String?\n\n linkedItems CatalogItem[] @relation(\"LinkedItems\")\n linkedTo CatalogItem[] @relation(\"LinkedItems\")\n\n category String?\n categoryCwId Int?\n subcategory String?\n subcategoryCwId Int?\n\n manufacturer String?\n manufactureCwId Int?\n\n partNumber String?\n\n vendorName String?\n vendorSku String?\n vendorCwId Int?\n\n price Float\n cost Float\n\n inactive Boolean @default(false)\n salesTaxable Boolean @default(true)\n\n onHand Int @default(0)\n cwLastUpdated DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Opportunity {\n id String @id @default(cuid())\n cwOpportunityId Int @unique\n name String\n notes String?\n\n generatedQuotes GeneratedQuotes[]\n\n // Stage / status / priority / type / rating stored as JSON references\n // so we don't need separate lookup tables for CW enums\n typeName String?\n typeCwId Int?\n stageName String?\n stageCwId Int?\n statusName String?\n statusCwId Int?\n priorityName String?\n priorityCwId Int?\n ratingName String?\n ratingCwId Int?\n source String?\n campaignName String?\n campaignCwId Int?\n\n // Sales rep references\n primarySalesRepName String?\n primarySalesRepIdentifier String?\n primarySalesRepCwId Int?\n secondarySalesRepName String?\n secondarySalesRepIdentifier String?\n secondarySalesRepCwId Int?\n\n // Company / contact / site\n companyCwId Int?\n companyName String?\n contactCwId Int?\n contactName String?\n siteCwId Int?\n siteName String?\n customerPO String?\n\n // Financials\n totalSalesTax Float @default(0)\n probability Float @default(0)\n\n // Location / department\n locationName String?\n locationCwId Int?\n departmentName String?\n departmentCwId Int?\n\n // Dates\n expectedCloseDate DateTime?\n pipelineChangeDate DateTime?\n dateBecameLead DateTime?\n closedDate DateTime?\n closedFlag Boolean @default(false)\n closedByName String?\n closedByCwId Int?\n\n // Internal relation to Company (optional, linked by cwCompanyId)\n companyId String?\n company Company? @relation(fields: [companyId], references: [id])\n\n // Local product sequence — array of CW forecast item IDs in display order.\n // When present, fetchProducts() uses this order instead of CW sequenceNumber.\n productSequence Int[] @default([])\n\n cwLastUpdated DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel CredentialType {\n id String @id @default(cuid())\n name String @unique\n\n permissionScope String\n icon String?\n fields Json\n\n credentials Credential[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel SecureValue {\n id String @id @default(cuid())\n name String\n\n content String // Encrypted content\n hash String // Hash of the original content for integrity verification and Search\n\n credentialId String\n credential Credential @relation(fields: [credentialId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Credential {\n id String @id @default(cuid())\n name String\n notes String?\n subCredentialOfId String?\n subCredentialOf Credential? @relation(\"SubCredentials\", fields: [subCredentialOfId], references: [id], onDelete: Cascade)\n subCredentials Credential[] @relation(\"SubCredentials\")\n\n typeId String\n type CredentialType @relation(fields: [typeId], references: [id], onDelete: Cascade)\n\n fields Json\n\n companyId String\n company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)\n\n securevalues SecureValue[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel GeneratedQuotes {\n id String @id @default(uuid())\n\n quoteRegenData Json @default(\"{}\") // Store any additional data needed for quote regeneration, such as product details, pricing, etc.\n quoteRegenParams Json @default(\"{}\") // Store parameters used for quote regeneration, such as template ID, formatting options, etc.\n quoteRegenHash String @unique @default(\"\")\n\n downloads Json @default(\"[]\") // Array of download records with timestamp and user info\n\n quoteFile Bytes\n quoteFileName String\n\n opportunityId String\n opportunity Opportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade)\n\n createdById String?\n createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n", "runtimeDataModel": { "models": {}, "enums": {}, @@ -28,7 +28,7 @@ const config: runtime.GetPrismaClientConfig = { } } -config.runtimeDataModel = JSON.parse("{\"models\":{\"Session\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessionKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expires\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"refreshTokenGenerated\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"refreshedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"invalidatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"SessionToUser\"}],\"dbName\":null},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"roles\",\"kind\":\"object\",\"type\":\"Role\",\"relationName\":\"RoleToUser\"},{\"name\":\"permissions\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"login\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"emailVerified\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"image\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwIdentifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"token\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessions\",\"kind\":\"object\",\"type\":\"Session\",\"relationName\":\"SessionToUser\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Role\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"moniker\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"permissions\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"users\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"RoleToUser\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"UnifiSite\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"siteId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"companyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"company\",\"kind\":\"object\",\"type\":\"Company\",\"relationName\":\"CompanyToUnifiSite\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Company\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cw_CompanyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"cw_Identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credentials\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"CompanyToCredential\"},{\"name\":\"unifiSites\",\"kind\":\"object\",\"type\":\"UnifiSite\",\"relationName\":\"CompanyToUnifiSite\"},{\"name\":\"opportunities\",\"kind\":\"object\",\"type\":\"Opportunity\",\"relationName\":\"CompanyToOpportunity\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"CatalogItem\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwCatalogId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"customerDescription\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"internalNotes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"linkedItems\",\"kind\":\"object\",\"type\":\"CatalogItem\",\"relationName\":\"LinkedItems\"},{\"name\":\"linkedTo\",\"kind\":\"object\",\"type\":\"CatalogItem\",\"relationName\":\"LinkedItems\"},{\"name\":\"category\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"categoryCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"subcategory\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subcategoryCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"manufacturer\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"manufactureCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"partNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"vendorName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"vendorSku\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"vendorCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"price\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"cost\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"inactive\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"salesTaxable\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"onHand\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"cwLastUpdated\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Opportunity\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwOpportunityId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"notes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"typeName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"typeCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"stageName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"stageCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"statusName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"statusCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"priorityName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"priorityCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"ratingName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ratingCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"source\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"campaignName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"campaignCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"primarySalesRepName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"primarySalesRepIdentifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"primarySalesRepCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"secondarySalesRepName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"secondarySalesRepIdentifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"secondarySalesRepCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"companyCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"companyName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"contactCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"contactName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"siteCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"siteName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"customerPO\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"totalSalesTax\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"locationName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"locationCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"departmentName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"departmentCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"expectedCloseDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"pipelineChangeDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"dateBecameLead\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"closedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"closedFlag\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"closedByName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"closedByCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"companyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"company\",\"kind\":\"object\",\"type\":\"Company\",\"relationName\":\"CompanyToOpportunity\"},{\"name\":\"productSequence\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"cwLastUpdated\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"CredentialType\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"permissionScope\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"icon\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"fields\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"credentials\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"CredentialToCredentialType\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"SecureValue\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"hash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credentialId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credential\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"CredentialToSecureValue\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Credential\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"notes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subCredentialOfId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subCredentialOf\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"SubCredentials\"},{\"name\":\"subCredentials\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"SubCredentials\"},{\"name\":\"typeId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"type\",\"kind\":\"object\",\"type\":\"CredentialType\",\"relationName\":\"CredentialToCredentialType\"},{\"name\":\"fields\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"companyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"company\",\"kind\":\"object\",\"type\":\"Company\",\"relationName\":\"CompanyToCredential\"},{\"name\":\"securevalues\",\"kind\":\"object\",\"type\":\"SecureValue\",\"relationName\":\"CredentialToSecureValue\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") +config.runtimeDataModel = JSON.parse("{\"models\":{\"Session\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessionKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expires\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"refreshTokenGenerated\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"refreshedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"invalidatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"SessionToUser\"}],\"dbName\":null},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"roles\",\"kind\":\"object\",\"type\":\"Role\",\"relationName\":\"RoleToUser\"},{\"name\":\"permissions\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"login\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"emailVerified\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"image\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwIdentifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"token\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessions\",\"kind\":\"object\",\"type\":\"Session\",\"relationName\":\"SessionToUser\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"generatedQuotes\",\"kind\":\"object\",\"type\":\"GeneratedQuotes\",\"relationName\":\"GeneratedQuotesToUser\"}],\"dbName\":null},\"Role\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"moniker\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"permissions\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"users\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"RoleToUser\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"UnifiSite\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"siteId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"companyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"company\",\"kind\":\"object\",\"type\":\"Company\",\"relationName\":\"CompanyToUnifiSite\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Company\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cw_CompanyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"cw_Identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credentials\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"CompanyToCredential\"},{\"name\":\"unifiSites\",\"kind\":\"object\",\"type\":\"UnifiSite\",\"relationName\":\"CompanyToUnifiSite\"},{\"name\":\"opportunities\",\"kind\":\"object\",\"type\":\"Opportunity\",\"relationName\":\"CompanyToOpportunity\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"CatalogItem\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwCatalogId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"customerDescription\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"internalNotes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"linkedItems\",\"kind\":\"object\",\"type\":\"CatalogItem\",\"relationName\":\"LinkedItems\"},{\"name\":\"linkedTo\",\"kind\":\"object\",\"type\":\"CatalogItem\",\"relationName\":\"LinkedItems\"},{\"name\":\"category\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"categoryCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"subcategory\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subcategoryCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"manufacturer\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"manufactureCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"partNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"vendorName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"vendorSku\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"vendorCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"price\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"cost\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"inactive\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"salesTaxable\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"onHand\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"cwLastUpdated\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Opportunity\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwOpportunityId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"notes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"generatedQuotes\",\"kind\":\"object\",\"type\":\"GeneratedQuotes\",\"relationName\":\"GeneratedQuotesToOpportunity\"},{\"name\":\"typeName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"typeCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"stageName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"stageCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"statusName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"statusCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"priorityName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"priorityCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"ratingName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ratingCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"source\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"campaignName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"campaignCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"primarySalesRepName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"primarySalesRepIdentifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"primarySalesRepCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"secondarySalesRepName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"secondarySalesRepIdentifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"secondarySalesRepCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"companyCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"companyName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"contactCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"contactName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"siteCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"siteName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"customerPO\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"totalSalesTax\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"probability\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"locationName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"locationCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"departmentName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"departmentCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"expectedCloseDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"pipelineChangeDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"dateBecameLead\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"closedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"closedFlag\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"closedByName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"closedByCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"companyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"company\",\"kind\":\"object\",\"type\":\"Company\",\"relationName\":\"CompanyToOpportunity\"},{\"name\":\"productSequence\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"cwLastUpdated\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"CredentialType\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"permissionScope\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"icon\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"fields\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"credentials\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"CredentialToCredentialType\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"SecureValue\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"hash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credentialId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credential\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"CredentialToSecureValue\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Credential\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"notes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subCredentialOfId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subCredentialOf\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"SubCredentials\"},{\"name\":\"subCredentials\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"SubCredentials\"},{\"name\":\"typeId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"type\",\"kind\":\"object\",\"type\":\"CredentialType\",\"relationName\":\"CredentialToCredentialType\"},{\"name\":\"fields\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"companyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"company\",\"kind\":\"object\",\"type\":\"Company\",\"relationName\":\"CompanyToCredential\"},{\"name\":\"securevalues\",\"kind\":\"object\",\"type\":\"SecureValue\",\"relationName\":\"CredentialToSecureValue\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"GeneratedQuotes\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"quoteRegenData\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"quoteRegenParams\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"quoteRegenHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"downloads\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"quoteFile\",\"kind\":\"scalar\",\"type\":\"Bytes\"},{\"name\":\"quoteFileName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"opportunityId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"opportunity\",\"kind\":\"object\",\"type\":\"Opportunity\",\"relationName\":\"GeneratedQuotesToOpportunity\"},{\"name\":\"createdById\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdBy\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"GeneratedQuotesToUser\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") async function decodeBase64AsWasm(wasmBase64: string): Promise { const { Buffer } = await import('node:buffer') @@ -275,6 +275,16 @@ export interface PrismaClient< * ``` */ get credential(): Prisma.CredentialDelegate; + + /** + * `prisma.generatedQuotes`: Exposes CRUD operations for the **GeneratedQuotes** model. + * Example usage: + * ```ts + * // Fetch zero or more GeneratedQuotes + * const generatedQuotes = await prisma.generatedQuotes.findMany() + * ``` + */ + get generatedQuotes(): Prisma.GeneratedQuotesDelegate; } export function getPrismaClientClass(): PrismaClientConstructor { diff --git a/generated/prisma/internal/prismaNamespace.ts b/generated/prisma/internal/prismaNamespace.ts index 04dfbf8..01066fe 100644 --- a/generated/prisma/internal/prismaNamespace.ts +++ b/generated/prisma/internal/prismaNamespace.ts @@ -393,7 +393,8 @@ export const ModelName = { Opportunity: 'Opportunity', CredentialType: 'CredentialType', SecureValue: 'SecureValue', - Credential: 'Credential' + Credential: 'Credential', + GeneratedQuotes: 'GeneratedQuotes' } as const export type ModelName = (typeof ModelName)[keyof typeof ModelName] @@ -409,7 +410,7 @@ export type TypeMap + fields: Prisma.GeneratedQuotesFieldRefs + operations: { + findUnique: { + args: Prisma.GeneratedQuotesFindUniqueArgs + result: runtime.Types.Utils.PayloadToResult | null + } + findUniqueOrThrow: { + args: Prisma.GeneratedQuotesFindUniqueOrThrowArgs + result: runtime.Types.Utils.PayloadToResult + } + findFirst: { + args: Prisma.GeneratedQuotesFindFirstArgs + result: runtime.Types.Utils.PayloadToResult | null + } + findFirstOrThrow: { + args: Prisma.GeneratedQuotesFindFirstOrThrowArgs + result: runtime.Types.Utils.PayloadToResult + } + findMany: { + args: Prisma.GeneratedQuotesFindManyArgs + result: runtime.Types.Utils.PayloadToResult[] + } + create: { + args: Prisma.GeneratedQuotesCreateArgs + result: runtime.Types.Utils.PayloadToResult + } + createMany: { + args: Prisma.GeneratedQuotesCreateManyArgs + result: BatchPayload + } + createManyAndReturn: { + args: Prisma.GeneratedQuotesCreateManyAndReturnArgs + result: runtime.Types.Utils.PayloadToResult[] + } + delete: { + args: Prisma.GeneratedQuotesDeleteArgs + result: runtime.Types.Utils.PayloadToResult + } + update: { + args: Prisma.GeneratedQuotesUpdateArgs + result: runtime.Types.Utils.PayloadToResult + } + deleteMany: { + args: Prisma.GeneratedQuotesDeleteManyArgs + result: BatchPayload + } + updateMany: { + args: Prisma.GeneratedQuotesUpdateManyArgs + result: BatchPayload + } + updateManyAndReturn: { + args: Prisma.GeneratedQuotesUpdateManyAndReturnArgs + result: runtime.Types.Utils.PayloadToResult[] + } + upsert: { + args: Prisma.GeneratedQuotesUpsertArgs + result: runtime.Types.Utils.PayloadToResult + } + aggregate: { + args: Prisma.GeneratedQuotesAggregateArgs + result: runtime.Types.Utils.Optional + } + groupBy: { + args: Prisma.GeneratedQuotesGroupByArgs + result: runtime.Types.Utils.Optional[] + } + count: { + args: Prisma.GeneratedQuotesCountArgs + result: runtime.Types.Utils.Optional | number + } + } + } } } & { other: { @@ -1322,6 +1397,7 @@ export const OpportunityScalarFieldEnum = { siteName: 'siteName', customerPO: 'customerPO', totalSalesTax: 'totalSalesTax', + probability: 'probability', locationName: 'locationName', locationCwId: 'locationCwId', departmentName: 'departmentName', @@ -1384,6 +1460,23 @@ export const CredentialScalarFieldEnum = { export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof CredentialScalarFieldEnum] +export const GeneratedQuotesScalarFieldEnum = { + id: 'id', + quoteRegenData: 'quoteRegenData', + quoteRegenParams: 'quoteRegenParams', + quoteRegenHash: 'quoteRegenHash', + downloads: 'downloads', + quoteFile: 'quoteFile', + quoteFileName: 'quoteFileName', + opportunityId: 'opportunityId', + createdById: 'createdById', + createdAt: 'createdAt', + updatedAt: 'updatedAt' +} as const + +export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum] + + export const SortOrder = { asc: 'asc', desc: 'desc' @@ -1506,6 +1599,20 @@ export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'J export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'> + +/** + * Reference to a field of type 'Bytes' + */ +export type BytesFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Bytes'> + + + +/** + * Reference to a field of type 'Bytes[]' + */ +export type ListBytesFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Bytes[]'> + + /** * Batch Payload for updateMany & deleteMany & createMany */ @@ -1611,6 +1718,7 @@ export type GlobalOmitConfig = { credentialType?: Prisma.CredentialTypeOmit secureValue?: Prisma.SecureValueOmit credential?: Prisma.CredentialOmit + generatedQuotes?: Prisma.GeneratedQuotesOmit } /* Types for Logging */ diff --git a/generated/prisma/internal/prismaNamespaceBrowser.ts b/generated/prisma/internal/prismaNamespaceBrowser.ts index 54ca505..39bb161 100644 --- a/generated/prisma/internal/prismaNamespaceBrowser.ts +++ b/generated/prisma/internal/prismaNamespaceBrowser.ts @@ -60,7 +60,8 @@ export const ModelName = { Opportunity: 'Opportunity', CredentialType: 'CredentialType', SecureValue: 'SecureValue', - Credential: 'Credential' + Credential: 'Credential', + GeneratedQuotes: 'GeneratedQuotes' } as const export type ModelName = (typeof ModelName)[keyof typeof ModelName] @@ -209,6 +210,7 @@ export const OpportunityScalarFieldEnum = { siteName: 'siteName', customerPO: 'customerPO', totalSalesTax: 'totalSalesTax', + probability: 'probability', locationName: 'locationName', locationCwId: 'locationCwId', departmentName: 'departmentName', @@ -271,6 +273,23 @@ export const CredentialScalarFieldEnum = { export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof CredentialScalarFieldEnum] +export const GeneratedQuotesScalarFieldEnum = { + id: 'id', + quoteRegenData: 'quoteRegenData', + quoteRegenParams: 'quoteRegenParams', + quoteRegenHash: 'quoteRegenHash', + downloads: 'downloads', + quoteFile: 'quoteFile', + quoteFileName: 'quoteFileName', + opportunityId: 'opportunityId', + createdById: 'createdById', + createdAt: 'createdAt', + updatedAt: 'updatedAt' +} as const + +export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum] + + export const SortOrder = { asc: 'asc', desc: 'desc' diff --git a/generated/prisma/models.ts b/generated/prisma/models.ts index 5a67c0a..91d4be3 100644 --- a/generated/prisma/models.ts +++ b/generated/prisma/models.ts @@ -18,4 +18,5 @@ export type * from './models/Opportunity.ts' export type * from './models/CredentialType.ts' export type * from './models/SecureValue.ts' export type * from './models/Credential.ts' +export type * from './models/GeneratedQuotes.ts' export type * from './commonInputTypes.ts' \ No newline at end of file diff --git a/generated/prisma/models/GeneratedQuotes.ts b/generated/prisma/models/GeneratedQuotes.ts new file mode 100644 index 0000000..6fad6b9 --- /dev/null +++ b/generated/prisma/models/GeneratedQuotes.ts @@ -0,0 +1,1711 @@ + +/* !!! This is code generated by Prisma. Do not edit directly. !!! */ +/* eslint-disable */ +// biome-ignore-all lint: generated file +// @ts-nocheck +/* + * This file exports the `GeneratedQuotes` model and its related types. + * + * 🟢 You can import this file directly. + */ +import type * as runtime from "@prisma/client/runtime/client" +import type * as $Enums from "../enums.ts" +import type * as Prisma from "../internal/prismaNamespace.ts" + +/** + * Model GeneratedQuotes + * + */ +export type GeneratedQuotesModel = runtime.Types.Result.DefaultSelection + +export type AggregateGeneratedQuotes = { + _count: GeneratedQuotesCountAggregateOutputType | null + _min: GeneratedQuotesMinAggregateOutputType | null + _max: GeneratedQuotesMaxAggregateOutputType | null +} + +export type GeneratedQuotesMinAggregateOutputType = { + id: string | null + quoteRegenHash: string | null + quoteFile: runtime.Bytes | null + quoteFileName: string | null + opportunityId: string | null + createdById: string | null + createdAt: Date | null + updatedAt: Date | null +} + +export type GeneratedQuotesMaxAggregateOutputType = { + id: string | null + quoteRegenHash: string | null + quoteFile: runtime.Bytes | null + quoteFileName: string | null + opportunityId: string | null + createdById: string | null + createdAt: Date | null + updatedAt: Date | null +} + +export type GeneratedQuotesCountAggregateOutputType = { + id: number + quoteRegenData: number + quoteRegenParams: number + quoteRegenHash: number + downloads: number + quoteFile: number + quoteFileName: number + opportunityId: number + createdById: number + createdAt: number + updatedAt: number + _all: number +} + + +export type GeneratedQuotesMinAggregateInputType = { + id?: true + quoteRegenHash?: true + quoteFile?: true + quoteFileName?: true + opportunityId?: true + createdById?: true + createdAt?: true + updatedAt?: true +} + +export type GeneratedQuotesMaxAggregateInputType = { + id?: true + quoteRegenHash?: true + quoteFile?: true + quoteFileName?: true + opportunityId?: true + createdById?: true + createdAt?: true + updatedAt?: true +} + +export type GeneratedQuotesCountAggregateInputType = { + id?: true + quoteRegenData?: true + quoteRegenParams?: true + quoteRegenHash?: true + downloads?: true + quoteFile?: true + quoteFileName?: true + opportunityId?: true + createdById?: true + createdAt?: true + updatedAt?: true + _all?: true +} + +export type GeneratedQuotesAggregateArgs = { + /** + * Filter which GeneratedQuotes to aggregate. + */ + where?: Prisma.GeneratedQuotesWhereInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/sorting Sorting Docs} + * + * Determine the order of GeneratedQuotes to fetch. + */ + orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[] + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination#cursor-based-pagination Cursor Docs} + * + * Sets the start position + */ + cursor?: Prisma.GeneratedQuotesWhereUniqueInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Take `±n` GeneratedQuotes from the position of the cursor. + */ + take?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Skip the first `n` GeneratedQuotes. + */ + skip?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs} + * + * Count returned GeneratedQuotes + **/ + _count?: true | GeneratedQuotesCountAggregateInputType + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs} + * + * Select which fields to find the minimum value + **/ + _min?: GeneratedQuotesMinAggregateInputType + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs} + * + * Select which fields to find the maximum value + **/ + _max?: GeneratedQuotesMaxAggregateInputType +} + +export type GetGeneratedQuotesAggregateType = { + [P in keyof T & keyof AggregateGeneratedQuotes]: P extends '_count' | 'count' + ? T[P] extends true + ? number + : Prisma.GetScalarType + : Prisma.GetScalarType +} + + + + +export type GeneratedQuotesGroupByArgs = { + where?: Prisma.GeneratedQuotesWhereInput + orderBy?: Prisma.GeneratedQuotesOrderByWithAggregationInput | Prisma.GeneratedQuotesOrderByWithAggregationInput[] + by: Prisma.GeneratedQuotesScalarFieldEnum[] | Prisma.GeneratedQuotesScalarFieldEnum + having?: Prisma.GeneratedQuotesScalarWhereWithAggregatesInput + take?: number + skip?: number + _count?: GeneratedQuotesCountAggregateInputType | true + _min?: GeneratedQuotesMinAggregateInputType + _max?: GeneratedQuotesMaxAggregateInputType +} + +export type GeneratedQuotesGroupByOutputType = { + id: string + quoteRegenData: runtime.JsonValue + quoteRegenParams: runtime.JsonValue + quoteRegenHash: string + downloads: runtime.JsonValue + quoteFile: runtime.Bytes + quoteFileName: string + opportunityId: string + createdById: string | null + createdAt: Date + updatedAt: Date + _count: GeneratedQuotesCountAggregateOutputType | null + _min: GeneratedQuotesMinAggregateOutputType | null + _max: GeneratedQuotesMaxAggregateOutputType | null +} + +type GetGeneratedQuotesGroupByPayload = Prisma.PrismaPromise< + Array< + Prisma.PickEnumerable & + { + [P in ((keyof T) & (keyof GeneratedQuotesGroupByOutputType))]: P extends '_count' + ? T[P] extends boolean + ? number + : Prisma.GetScalarType + : Prisma.GetScalarType + } + > + > + + + +export type GeneratedQuotesWhereInput = { + AND?: Prisma.GeneratedQuotesWhereInput | Prisma.GeneratedQuotesWhereInput[] + OR?: Prisma.GeneratedQuotesWhereInput[] + NOT?: Prisma.GeneratedQuotesWhereInput | Prisma.GeneratedQuotesWhereInput[] + id?: Prisma.StringFilter<"GeneratedQuotes"> | string + quoteRegenData?: Prisma.JsonFilter<"GeneratedQuotes"> + quoteRegenParams?: Prisma.JsonFilter<"GeneratedQuotes"> + quoteRegenHash?: Prisma.StringFilter<"GeneratedQuotes"> | string + downloads?: Prisma.JsonFilter<"GeneratedQuotes"> + quoteFile?: Prisma.BytesFilter<"GeneratedQuotes"> | runtime.Bytes + quoteFileName?: Prisma.StringFilter<"GeneratedQuotes"> | string + opportunityId?: Prisma.StringFilter<"GeneratedQuotes"> | string + createdById?: Prisma.StringNullableFilter<"GeneratedQuotes"> | string | null + createdAt?: Prisma.DateTimeFilter<"GeneratedQuotes"> | Date | string + updatedAt?: Prisma.DateTimeFilter<"GeneratedQuotes"> | Date | string + opportunity?: Prisma.XOR + createdBy?: Prisma.XOR | null +} + +export type GeneratedQuotesOrderByWithRelationInput = { + id?: Prisma.SortOrder + quoteRegenData?: Prisma.SortOrder + quoteRegenParams?: Prisma.SortOrder + quoteRegenHash?: Prisma.SortOrder + downloads?: Prisma.SortOrder + quoteFile?: Prisma.SortOrder + quoteFileName?: Prisma.SortOrder + opportunityId?: Prisma.SortOrder + createdById?: Prisma.SortOrderInput | Prisma.SortOrder + createdAt?: Prisma.SortOrder + updatedAt?: Prisma.SortOrder + opportunity?: Prisma.OpportunityOrderByWithRelationInput + createdBy?: Prisma.UserOrderByWithRelationInput +} + +export type GeneratedQuotesWhereUniqueInput = Prisma.AtLeast<{ + id?: string + quoteRegenHash?: string + AND?: Prisma.GeneratedQuotesWhereInput | Prisma.GeneratedQuotesWhereInput[] + OR?: Prisma.GeneratedQuotesWhereInput[] + NOT?: Prisma.GeneratedQuotesWhereInput | Prisma.GeneratedQuotesWhereInput[] + quoteRegenData?: Prisma.JsonFilter<"GeneratedQuotes"> + quoteRegenParams?: Prisma.JsonFilter<"GeneratedQuotes"> + downloads?: Prisma.JsonFilter<"GeneratedQuotes"> + quoteFile?: Prisma.BytesFilter<"GeneratedQuotes"> | runtime.Bytes + quoteFileName?: Prisma.StringFilter<"GeneratedQuotes"> | string + opportunityId?: Prisma.StringFilter<"GeneratedQuotes"> | string + createdById?: Prisma.StringNullableFilter<"GeneratedQuotes"> | string | null + createdAt?: Prisma.DateTimeFilter<"GeneratedQuotes"> | Date | string + updatedAt?: Prisma.DateTimeFilter<"GeneratedQuotes"> | Date | string + opportunity?: Prisma.XOR + createdBy?: Prisma.XOR | null +}, "id" | "quoteRegenHash"> + +export type GeneratedQuotesOrderByWithAggregationInput = { + id?: Prisma.SortOrder + quoteRegenData?: Prisma.SortOrder + quoteRegenParams?: Prisma.SortOrder + quoteRegenHash?: Prisma.SortOrder + downloads?: Prisma.SortOrder + quoteFile?: Prisma.SortOrder + quoteFileName?: Prisma.SortOrder + opportunityId?: Prisma.SortOrder + createdById?: Prisma.SortOrderInput | Prisma.SortOrder + createdAt?: Prisma.SortOrder + updatedAt?: Prisma.SortOrder + _count?: Prisma.GeneratedQuotesCountOrderByAggregateInput + _max?: Prisma.GeneratedQuotesMaxOrderByAggregateInput + _min?: Prisma.GeneratedQuotesMinOrderByAggregateInput +} + +export type GeneratedQuotesScalarWhereWithAggregatesInput = { + AND?: Prisma.GeneratedQuotesScalarWhereWithAggregatesInput | Prisma.GeneratedQuotesScalarWhereWithAggregatesInput[] + OR?: Prisma.GeneratedQuotesScalarWhereWithAggregatesInput[] + NOT?: Prisma.GeneratedQuotesScalarWhereWithAggregatesInput | Prisma.GeneratedQuotesScalarWhereWithAggregatesInput[] + id?: Prisma.StringWithAggregatesFilter<"GeneratedQuotes"> | string + quoteRegenData?: Prisma.JsonWithAggregatesFilter<"GeneratedQuotes"> + quoteRegenParams?: Prisma.JsonWithAggregatesFilter<"GeneratedQuotes"> + quoteRegenHash?: Prisma.StringWithAggregatesFilter<"GeneratedQuotes"> | string + downloads?: Prisma.JsonWithAggregatesFilter<"GeneratedQuotes"> + quoteFile?: Prisma.BytesWithAggregatesFilter<"GeneratedQuotes"> | runtime.Bytes + quoteFileName?: Prisma.StringWithAggregatesFilter<"GeneratedQuotes"> | string + opportunityId?: Prisma.StringWithAggregatesFilter<"GeneratedQuotes"> | string + createdById?: Prisma.StringNullableWithAggregatesFilter<"GeneratedQuotes"> | string | null + createdAt?: Prisma.DateTimeWithAggregatesFilter<"GeneratedQuotes"> | Date | string + updatedAt?: Prisma.DateTimeWithAggregatesFilter<"GeneratedQuotes"> | Date | string +} + +export type GeneratedQuotesCreateInput = { + id?: string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile: runtime.Bytes + quoteFileName: string + createdAt?: Date | string + updatedAt?: Date | string + opportunity: Prisma.OpportunityCreateNestedOneWithoutGeneratedQuotesInput + createdBy?: Prisma.UserCreateNestedOneWithoutGeneratedQuotesInput +} + +export type GeneratedQuotesUncheckedCreateInput = { + id?: string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile: runtime.Bytes + quoteFileName: string + opportunityId: string + createdById?: string | null + createdAt?: Date | string + updatedAt?: Date | string +} + +export type GeneratedQuotesUpdateInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: Prisma.StringFieldUpdateOperationsInput | string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile?: Prisma.BytesFieldUpdateOperationsInput | runtime.Bytes + quoteFileName?: Prisma.StringFieldUpdateOperationsInput | string + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + opportunity?: Prisma.OpportunityUpdateOneRequiredWithoutGeneratedQuotesNestedInput + createdBy?: Prisma.UserUpdateOneWithoutGeneratedQuotesNestedInput +} + +export type GeneratedQuotesUncheckedUpdateInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: Prisma.StringFieldUpdateOperationsInput | string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile?: Prisma.BytesFieldUpdateOperationsInput | runtime.Bytes + quoteFileName?: Prisma.StringFieldUpdateOperationsInput | string + opportunityId?: Prisma.StringFieldUpdateOperationsInput | string + createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string +} + +export type GeneratedQuotesCreateManyInput = { + id?: string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile: runtime.Bytes + quoteFileName: string + opportunityId: string + createdById?: string | null + createdAt?: Date | string + updatedAt?: Date | string +} + +export type GeneratedQuotesUpdateManyMutationInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: Prisma.StringFieldUpdateOperationsInput | string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile?: Prisma.BytesFieldUpdateOperationsInput | runtime.Bytes + quoteFileName?: Prisma.StringFieldUpdateOperationsInput | string + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string +} + +export type GeneratedQuotesUncheckedUpdateManyInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: Prisma.StringFieldUpdateOperationsInput | string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile?: Prisma.BytesFieldUpdateOperationsInput | runtime.Bytes + quoteFileName?: Prisma.StringFieldUpdateOperationsInput | string + opportunityId?: Prisma.StringFieldUpdateOperationsInput | string + createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string +} + +export type GeneratedQuotesListRelationFilter = { + every?: Prisma.GeneratedQuotesWhereInput + some?: Prisma.GeneratedQuotesWhereInput + none?: Prisma.GeneratedQuotesWhereInput +} + +export type GeneratedQuotesOrderByRelationAggregateInput = { + _count?: Prisma.SortOrder +} + +export type GeneratedQuotesCountOrderByAggregateInput = { + id?: Prisma.SortOrder + quoteRegenData?: Prisma.SortOrder + quoteRegenParams?: Prisma.SortOrder + quoteRegenHash?: Prisma.SortOrder + downloads?: Prisma.SortOrder + quoteFile?: Prisma.SortOrder + quoteFileName?: Prisma.SortOrder + opportunityId?: Prisma.SortOrder + createdById?: Prisma.SortOrder + createdAt?: Prisma.SortOrder + updatedAt?: Prisma.SortOrder +} + +export type GeneratedQuotesMaxOrderByAggregateInput = { + id?: Prisma.SortOrder + quoteRegenHash?: Prisma.SortOrder + quoteFile?: Prisma.SortOrder + quoteFileName?: Prisma.SortOrder + opportunityId?: Prisma.SortOrder + createdById?: Prisma.SortOrder + createdAt?: Prisma.SortOrder + updatedAt?: Prisma.SortOrder +} + +export type GeneratedQuotesMinOrderByAggregateInput = { + id?: Prisma.SortOrder + quoteRegenHash?: Prisma.SortOrder + quoteFile?: Prisma.SortOrder + quoteFileName?: Prisma.SortOrder + opportunityId?: Prisma.SortOrder + createdById?: Prisma.SortOrder + createdAt?: Prisma.SortOrder + updatedAt?: Prisma.SortOrder +} + +export type GeneratedQuotesCreateNestedManyWithoutCreatedByInput = { + create?: Prisma.XOR | Prisma.GeneratedQuotesCreateWithoutCreatedByInput[] | Prisma.GeneratedQuotesUncheckedCreateWithoutCreatedByInput[] + connectOrCreate?: Prisma.GeneratedQuotesCreateOrConnectWithoutCreatedByInput | Prisma.GeneratedQuotesCreateOrConnectWithoutCreatedByInput[] + createMany?: Prisma.GeneratedQuotesCreateManyCreatedByInputEnvelope + connect?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] +} + +export type GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput = { + create?: Prisma.XOR | Prisma.GeneratedQuotesCreateWithoutCreatedByInput[] | Prisma.GeneratedQuotesUncheckedCreateWithoutCreatedByInput[] + connectOrCreate?: Prisma.GeneratedQuotesCreateOrConnectWithoutCreatedByInput | Prisma.GeneratedQuotesCreateOrConnectWithoutCreatedByInput[] + createMany?: Prisma.GeneratedQuotesCreateManyCreatedByInputEnvelope + connect?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] +} + +export type GeneratedQuotesUpdateManyWithoutCreatedByNestedInput = { + create?: Prisma.XOR | Prisma.GeneratedQuotesCreateWithoutCreatedByInput[] | Prisma.GeneratedQuotesUncheckedCreateWithoutCreatedByInput[] + connectOrCreate?: Prisma.GeneratedQuotesCreateOrConnectWithoutCreatedByInput | Prisma.GeneratedQuotesCreateOrConnectWithoutCreatedByInput[] + upsert?: Prisma.GeneratedQuotesUpsertWithWhereUniqueWithoutCreatedByInput | Prisma.GeneratedQuotesUpsertWithWhereUniqueWithoutCreatedByInput[] + createMany?: Prisma.GeneratedQuotesCreateManyCreatedByInputEnvelope + set?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + disconnect?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + delete?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + connect?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + update?: Prisma.GeneratedQuotesUpdateWithWhereUniqueWithoutCreatedByInput | Prisma.GeneratedQuotesUpdateWithWhereUniqueWithoutCreatedByInput[] + updateMany?: Prisma.GeneratedQuotesUpdateManyWithWhereWithoutCreatedByInput | Prisma.GeneratedQuotesUpdateManyWithWhereWithoutCreatedByInput[] + deleteMany?: Prisma.GeneratedQuotesScalarWhereInput | Prisma.GeneratedQuotesScalarWhereInput[] +} + +export type GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput = { + create?: Prisma.XOR | Prisma.GeneratedQuotesCreateWithoutCreatedByInput[] | Prisma.GeneratedQuotesUncheckedCreateWithoutCreatedByInput[] + connectOrCreate?: Prisma.GeneratedQuotesCreateOrConnectWithoutCreatedByInput | Prisma.GeneratedQuotesCreateOrConnectWithoutCreatedByInput[] + upsert?: Prisma.GeneratedQuotesUpsertWithWhereUniqueWithoutCreatedByInput | Prisma.GeneratedQuotesUpsertWithWhereUniqueWithoutCreatedByInput[] + createMany?: Prisma.GeneratedQuotesCreateManyCreatedByInputEnvelope + set?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + disconnect?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + delete?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + connect?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + update?: Prisma.GeneratedQuotesUpdateWithWhereUniqueWithoutCreatedByInput | Prisma.GeneratedQuotesUpdateWithWhereUniqueWithoutCreatedByInput[] + updateMany?: Prisma.GeneratedQuotesUpdateManyWithWhereWithoutCreatedByInput | Prisma.GeneratedQuotesUpdateManyWithWhereWithoutCreatedByInput[] + deleteMany?: Prisma.GeneratedQuotesScalarWhereInput | Prisma.GeneratedQuotesScalarWhereInput[] +} + +export type GeneratedQuotesCreateNestedManyWithoutOpportunityInput = { + create?: Prisma.XOR | Prisma.GeneratedQuotesCreateWithoutOpportunityInput[] | Prisma.GeneratedQuotesUncheckedCreateWithoutOpportunityInput[] + connectOrCreate?: Prisma.GeneratedQuotesCreateOrConnectWithoutOpportunityInput | Prisma.GeneratedQuotesCreateOrConnectWithoutOpportunityInput[] + createMany?: Prisma.GeneratedQuotesCreateManyOpportunityInputEnvelope + connect?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] +} + +export type GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput = { + create?: Prisma.XOR | Prisma.GeneratedQuotesCreateWithoutOpportunityInput[] | Prisma.GeneratedQuotesUncheckedCreateWithoutOpportunityInput[] + connectOrCreate?: Prisma.GeneratedQuotesCreateOrConnectWithoutOpportunityInput | Prisma.GeneratedQuotesCreateOrConnectWithoutOpportunityInput[] + createMany?: Prisma.GeneratedQuotesCreateManyOpportunityInputEnvelope + connect?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] +} + +export type GeneratedQuotesUpdateManyWithoutOpportunityNestedInput = { + create?: Prisma.XOR | Prisma.GeneratedQuotesCreateWithoutOpportunityInput[] | Prisma.GeneratedQuotesUncheckedCreateWithoutOpportunityInput[] + connectOrCreate?: Prisma.GeneratedQuotesCreateOrConnectWithoutOpportunityInput | Prisma.GeneratedQuotesCreateOrConnectWithoutOpportunityInput[] + upsert?: Prisma.GeneratedQuotesUpsertWithWhereUniqueWithoutOpportunityInput | Prisma.GeneratedQuotesUpsertWithWhereUniqueWithoutOpportunityInput[] + createMany?: Prisma.GeneratedQuotesCreateManyOpportunityInputEnvelope + set?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + disconnect?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + delete?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + connect?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + update?: Prisma.GeneratedQuotesUpdateWithWhereUniqueWithoutOpportunityInput | Prisma.GeneratedQuotesUpdateWithWhereUniqueWithoutOpportunityInput[] + updateMany?: Prisma.GeneratedQuotesUpdateManyWithWhereWithoutOpportunityInput | Prisma.GeneratedQuotesUpdateManyWithWhereWithoutOpportunityInput[] + deleteMany?: Prisma.GeneratedQuotesScalarWhereInput | Prisma.GeneratedQuotesScalarWhereInput[] +} + +export type GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput = { + create?: Prisma.XOR | Prisma.GeneratedQuotesCreateWithoutOpportunityInput[] | Prisma.GeneratedQuotesUncheckedCreateWithoutOpportunityInput[] + connectOrCreate?: Prisma.GeneratedQuotesCreateOrConnectWithoutOpportunityInput | Prisma.GeneratedQuotesCreateOrConnectWithoutOpportunityInput[] + upsert?: Prisma.GeneratedQuotesUpsertWithWhereUniqueWithoutOpportunityInput | Prisma.GeneratedQuotesUpsertWithWhereUniqueWithoutOpportunityInput[] + createMany?: Prisma.GeneratedQuotesCreateManyOpportunityInputEnvelope + set?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + disconnect?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + delete?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + connect?: Prisma.GeneratedQuotesWhereUniqueInput | Prisma.GeneratedQuotesWhereUniqueInput[] + update?: Prisma.GeneratedQuotesUpdateWithWhereUniqueWithoutOpportunityInput | Prisma.GeneratedQuotesUpdateWithWhereUniqueWithoutOpportunityInput[] + updateMany?: Prisma.GeneratedQuotesUpdateManyWithWhereWithoutOpportunityInput | Prisma.GeneratedQuotesUpdateManyWithWhereWithoutOpportunityInput[] + deleteMany?: Prisma.GeneratedQuotesScalarWhereInput | Prisma.GeneratedQuotesScalarWhereInput[] +} + +export type BytesFieldUpdateOperationsInput = { + set?: runtime.Bytes +} + +export type GeneratedQuotesCreateWithoutCreatedByInput = { + id?: string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile: runtime.Bytes + quoteFileName: string + createdAt?: Date | string + updatedAt?: Date | string + opportunity: Prisma.OpportunityCreateNestedOneWithoutGeneratedQuotesInput +} + +export type GeneratedQuotesUncheckedCreateWithoutCreatedByInput = { + id?: string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile: runtime.Bytes + quoteFileName: string + opportunityId: string + createdAt?: Date | string + updatedAt?: Date | string +} + +export type GeneratedQuotesCreateOrConnectWithoutCreatedByInput = { + where: Prisma.GeneratedQuotesWhereUniqueInput + create: Prisma.XOR +} + +export type GeneratedQuotesCreateManyCreatedByInputEnvelope = { + data: Prisma.GeneratedQuotesCreateManyCreatedByInput | Prisma.GeneratedQuotesCreateManyCreatedByInput[] + skipDuplicates?: boolean +} + +export type GeneratedQuotesUpsertWithWhereUniqueWithoutCreatedByInput = { + where: Prisma.GeneratedQuotesWhereUniqueInput + update: Prisma.XOR + create: Prisma.XOR +} + +export type GeneratedQuotesUpdateWithWhereUniqueWithoutCreatedByInput = { + where: Prisma.GeneratedQuotesWhereUniqueInput + data: Prisma.XOR +} + +export type GeneratedQuotesUpdateManyWithWhereWithoutCreatedByInput = { + where: Prisma.GeneratedQuotesScalarWhereInput + data: Prisma.XOR +} + +export type GeneratedQuotesScalarWhereInput = { + AND?: Prisma.GeneratedQuotesScalarWhereInput | Prisma.GeneratedQuotesScalarWhereInput[] + OR?: Prisma.GeneratedQuotesScalarWhereInput[] + NOT?: Prisma.GeneratedQuotesScalarWhereInput | Prisma.GeneratedQuotesScalarWhereInput[] + id?: Prisma.StringFilter<"GeneratedQuotes"> | string + quoteRegenData?: Prisma.JsonFilter<"GeneratedQuotes"> + quoteRegenParams?: Prisma.JsonFilter<"GeneratedQuotes"> + quoteRegenHash?: Prisma.StringFilter<"GeneratedQuotes"> | string + downloads?: Prisma.JsonFilter<"GeneratedQuotes"> + quoteFile?: Prisma.BytesFilter<"GeneratedQuotes"> | runtime.Bytes + quoteFileName?: Prisma.StringFilter<"GeneratedQuotes"> | string + opportunityId?: Prisma.StringFilter<"GeneratedQuotes"> | string + createdById?: Prisma.StringNullableFilter<"GeneratedQuotes"> | string | null + createdAt?: Prisma.DateTimeFilter<"GeneratedQuotes"> | Date | string + updatedAt?: Prisma.DateTimeFilter<"GeneratedQuotes"> | Date | string +} + +export type GeneratedQuotesCreateWithoutOpportunityInput = { + id?: string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile: runtime.Bytes + quoteFileName: string + createdAt?: Date | string + updatedAt?: Date | string + createdBy?: Prisma.UserCreateNestedOneWithoutGeneratedQuotesInput +} + +export type GeneratedQuotesUncheckedCreateWithoutOpportunityInput = { + id?: string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile: runtime.Bytes + quoteFileName: string + createdById?: string | null + createdAt?: Date | string + updatedAt?: Date | string +} + +export type GeneratedQuotesCreateOrConnectWithoutOpportunityInput = { + where: Prisma.GeneratedQuotesWhereUniqueInput + create: Prisma.XOR +} + +export type GeneratedQuotesCreateManyOpportunityInputEnvelope = { + data: Prisma.GeneratedQuotesCreateManyOpportunityInput | Prisma.GeneratedQuotesCreateManyOpportunityInput[] + skipDuplicates?: boolean +} + +export type GeneratedQuotesUpsertWithWhereUniqueWithoutOpportunityInput = { + where: Prisma.GeneratedQuotesWhereUniqueInput + update: Prisma.XOR + create: Prisma.XOR +} + +export type GeneratedQuotesUpdateWithWhereUniqueWithoutOpportunityInput = { + where: Prisma.GeneratedQuotesWhereUniqueInput + data: Prisma.XOR +} + +export type GeneratedQuotesUpdateManyWithWhereWithoutOpportunityInput = { + where: Prisma.GeneratedQuotesScalarWhereInput + data: Prisma.XOR +} + +export type GeneratedQuotesCreateManyCreatedByInput = { + id?: string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile: runtime.Bytes + quoteFileName: string + opportunityId: string + createdAt?: Date | string + updatedAt?: Date | string +} + +export type GeneratedQuotesUpdateWithoutCreatedByInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: Prisma.StringFieldUpdateOperationsInput | string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile?: Prisma.BytesFieldUpdateOperationsInput | runtime.Bytes + quoteFileName?: Prisma.StringFieldUpdateOperationsInput | string + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + opportunity?: Prisma.OpportunityUpdateOneRequiredWithoutGeneratedQuotesNestedInput +} + +export type GeneratedQuotesUncheckedUpdateWithoutCreatedByInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: Prisma.StringFieldUpdateOperationsInput | string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile?: Prisma.BytesFieldUpdateOperationsInput | runtime.Bytes + quoteFileName?: Prisma.StringFieldUpdateOperationsInput | string + opportunityId?: Prisma.StringFieldUpdateOperationsInput | string + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string +} + +export type GeneratedQuotesUncheckedUpdateManyWithoutCreatedByInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: Prisma.StringFieldUpdateOperationsInput | string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile?: Prisma.BytesFieldUpdateOperationsInput | runtime.Bytes + quoteFileName?: Prisma.StringFieldUpdateOperationsInput | string + opportunityId?: Prisma.StringFieldUpdateOperationsInput | string + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string +} + +export type GeneratedQuotesCreateManyOpportunityInput = { + id?: string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile: runtime.Bytes + quoteFileName: string + createdById?: string | null + createdAt?: Date | string + updatedAt?: Date | string +} + +export type GeneratedQuotesUpdateWithoutOpportunityInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: Prisma.StringFieldUpdateOperationsInput | string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile?: Prisma.BytesFieldUpdateOperationsInput | runtime.Bytes + quoteFileName?: Prisma.StringFieldUpdateOperationsInput | string + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + createdBy?: Prisma.UserUpdateOneWithoutGeneratedQuotesNestedInput +} + +export type GeneratedQuotesUncheckedUpdateWithoutOpportunityInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: Prisma.StringFieldUpdateOperationsInput | string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile?: Prisma.BytesFieldUpdateOperationsInput | runtime.Bytes + quoteFileName?: Prisma.StringFieldUpdateOperationsInput | string + createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string +} + +export type GeneratedQuotesUncheckedUpdateManyWithoutOpportunityInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + quoteRegenData?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenParams?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteRegenHash?: Prisma.StringFieldUpdateOperationsInput | string + downloads?: Prisma.JsonNullValueInput | runtime.InputJsonValue + quoteFile?: Prisma.BytesFieldUpdateOperationsInput | runtime.Bytes + quoteFileName?: Prisma.StringFieldUpdateOperationsInput | string + createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string +} + + + +export type GeneratedQuotesSelect = runtime.Types.Extensions.GetSelect<{ + id?: boolean + quoteRegenData?: boolean + quoteRegenParams?: boolean + quoteRegenHash?: boolean + downloads?: boolean + quoteFile?: boolean + quoteFileName?: boolean + opportunityId?: boolean + createdById?: boolean + createdAt?: boolean + updatedAt?: boolean + opportunity?: boolean | Prisma.OpportunityDefaultArgs + createdBy?: boolean | Prisma.GeneratedQuotes$createdByArgs +}, ExtArgs["result"]["generatedQuotes"]> + +export type GeneratedQuotesSelectCreateManyAndReturn = runtime.Types.Extensions.GetSelect<{ + id?: boolean + quoteRegenData?: boolean + quoteRegenParams?: boolean + quoteRegenHash?: boolean + downloads?: boolean + quoteFile?: boolean + quoteFileName?: boolean + opportunityId?: boolean + createdById?: boolean + createdAt?: boolean + updatedAt?: boolean + opportunity?: boolean | Prisma.OpportunityDefaultArgs + createdBy?: boolean | Prisma.GeneratedQuotes$createdByArgs +}, ExtArgs["result"]["generatedQuotes"]> + +export type GeneratedQuotesSelectUpdateManyAndReturn = runtime.Types.Extensions.GetSelect<{ + id?: boolean + quoteRegenData?: boolean + quoteRegenParams?: boolean + quoteRegenHash?: boolean + downloads?: boolean + quoteFile?: boolean + quoteFileName?: boolean + opportunityId?: boolean + createdById?: boolean + createdAt?: boolean + updatedAt?: boolean + opportunity?: boolean | Prisma.OpportunityDefaultArgs + createdBy?: boolean | Prisma.GeneratedQuotes$createdByArgs +}, ExtArgs["result"]["generatedQuotes"]> + +export type GeneratedQuotesSelectScalar = { + id?: boolean + quoteRegenData?: boolean + quoteRegenParams?: boolean + quoteRegenHash?: boolean + downloads?: boolean + quoteFile?: boolean + quoteFileName?: boolean + opportunityId?: boolean + createdById?: boolean + createdAt?: boolean + updatedAt?: boolean +} + +export type GeneratedQuotesOmit = runtime.Types.Extensions.GetOmit<"id" | "quoteRegenData" | "quoteRegenParams" | "quoteRegenHash" | "downloads" | "quoteFile" | "quoteFileName" | "opportunityId" | "createdById" | "createdAt" | "updatedAt", ExtArgs["result"]["generatedQuotes"]> +export type GeneratedQuotesInclude = { + opportunity?: boolean | Prisma.OpportunityDefaultArgs + createdBy?: boolean | Prisma.GeneratedQuotes$createdByArgs +} +export type GeneratedQuotesIncludeCreateManyAndReturn = { + opportunity?: boolean | Prisma.OpportunityDefaultArgs + createdBy?: boolean | Prisma.GeneratedQuotes$createdByArgs +} +export type GeneratedQuotesIncludeUpdateManyAndReturn = { + opportunity?: boolean | Prisma.OpportunityDefaultArgs + createdBy?: boolean | Prisma.GeneratedQuotes$createdByArgs +} + +export type $GeneratedQuotesPayload = { + name: "GeneratedQuotes" + objects: { + opportunity: Prisma.$OpportunityPayload + createdBy: Prisma.$UserPayload | null + } + scalars: runtime.Types.Extensions.GetPayloadResult<{ + id: string + quoteRegenData: runtime.JsonValue + quoteRegenParams: runtime.JsonValue + quoteRegenHash: string + downloads: runtime.JsonValue + quoteFile: runtime.Bytes + quoteFileName: string + opportunityId: string + createdById: string | null + createdAt: Date + updatedAt: Date + }, ExtArgs["result"]["generatedQuotes"]> + composites: {} +} + +export type GeneratedQuotesGetPayload = runtime.Types.Result.GetResult + +export type GeneratedQuotesCountArgs = + Omit & { + select?: GeneratedQuotesCountAggregateInputType | true + } + +export interface GeneratedQuotesDelegate { + [K: symbol]: { types: Prisma.TypeMap['model']['GeneratedQuotes'], meta: { name: 'GeneratedQuotes' } } + /** + * Find zero or one GeneratedQuotes that matches the filter. + * @param {GeneratedQuotesFindUniqueArgs} args - Arguments to find a GeneratedQuotes + * @example + * // Get one GeneratedQuotes + * const generatedQuotes = await prisma.generatedQuotes.findUnique({ + * where: { + * // ... provide filter here + * } + * }) + */ + findUnique(args: Prisma.SelectSubset>): Prisma.Prisma__GeneratedQuotesClient, T, "findUnique", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions> + + /** + * Find one GeneratedQuotes that matches the filter or throw an error with `error.code='P2025'` + * if no matches were found. + * @param {GeneratedQuotesFindUniqueOrThrowArgs} args - Arguments to find a GeneratedQuotes + * @example + * // Get one GeneratedQuotes + * const generatedQuotes = await prisma.generatedQuotes.findUniqueOrThrow({ + * where: { + * // ... provide filter here + * } + * }) + */ + findUniqueOrThrow(args: Prisma.SelectSubset>): Prisma.Prisma__GeneratedQuotesClient, T, "findUniqueOrThrow", GlobalOmitOptions>, never, ExtArgs, GlobalOmitOptions> + + /** + * Find the first GeneratedQuotes that matches the filter. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {GeneratedQuotesFindFirstArgs} args - Arguments to find a GeneratedQuotes + * @example + * // Get one GeneratedQuotes + * const generatedQuotes = await prisma.generatedQuotes.findFirst({ + * where: { + * // ... provide filter here + * } + * }) + */ + findFirst(args?: Prisma.SelectSubset>): Prisma.Prisma__GeneratedQuotesClient, T, "findFirst", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions> + + /** + * Find the first GeneratedQuotes that matches the filter or + * throw `PrismaKnownClientError` with `P2025` code if no matches were found. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {GeneratedQuotesFindFirstOrThrowArgs} args - Arguments to find a GeneratedQuotes + * @example + * // Get one GeneratedQuotes + * const generatedQuotes = await prisma.generatedQuotes.findFirstOrThrow({ + * where: { + * // ... provide filter here + * } + * }) + */ + findFirstOrThrow(args?: Prisma.SelectSubset>): Prisma.Prisma__GeneratedQuotesClient, T, "findFirstOrThrow", GlobalOmitOptions>, never, ExtArgs, GlobalOmitOptions> + + /** + * Find zero or more GeneratedQuotes that matches the filter. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {GeneratedQuotesFindManyArgs} args - Arguments to filter and select certain fields only. + * @example + * // Get all GeneratedQuotes + * const generatedQuotes = await prisma.generatedQuotes.findMany() + * + * // Get first 10 GeneratedQuotes + * const generatedQuotes = await prisma.generatedQuotes.findMany({ take: 10 }) + * + * // Only select the `id` + * const generatedQuotesWithIdOnly = await prisma.generatedQuotes.findMany({ select: { id: true } }) + * + */ + findMany(args?: Prisma.SelectSubset>): Prisma.PrismaPromise, T, "findMany", GlobalOmitOptions>> + + /** + * Create a GeneratedQuotes. + * @param {GeneratedQuotesCreateArgs} args - Arguments to create a GeneratedQuotes. + * @example + * // Create one GeneratedQuotes + * const GeneratedQuotes = await prisma.generatedQuotes.create({ + * data: { + * // ... data to create a GeneratedQuotes + * } + * }) + * + */ + create(args: Prisma.SelectSubset>): Prisma.Prisma__GeneratedQuotesClient, T, "create", GlobalOmitOptions>, never, ExtArgs, GlobalOmitOptions> + + /** + * Create many GeneratedQuotes. + * @param {GeneratedQuotesCreateManyArgs} args - Arguments to create many GeneratedQuotes. + * @example + * // Create many GeneratedQuotes + * const generatedQuotes = await prisma.generatedQuotes.createMany({ + * data: [ + * // ... provide data here + * ] + * }) + * + */ + createMany(args?: Prisma.SelectSubset>): Prisma.PrismaPromise + + /** + * Create many GeneratedQuotes and returns the data saved in the database. + * @param {GeneratedQuotesCreateManyAndReturnArgs} args - Arguments to create many GeneratedQuotes. + * @example + * // Create many GeneratedQuotes + * const generatedQuotes = await prisma.generatedQuotes.createManyAndReturn({ + * data: [ + * // ... provide data here + * ] + * }) + * + * // Create many GeneratedQuotes and only return the `id` + * const generatedQuotesWithIdOnly = await prisma.generatedQuotes.createManyAndReturn({ + * select: { id: true }, + * data: [ + * // ... provide data here + * ] + * }) + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * + */ + createManyAndReturn(args?: Prisma.SelectSubset>): Prisma.PrismaPromise, T, "createManyAndReturn", GlobalOmitOptions>> + + /** + * Delete a GeneratedQuotes. + * @param {GeneratedQuotesDeleteArgs} args - Arguments to delete one GeneratedQuotes. + * @example + * // Delete one GeneratedQuotes + * const GeneratedQuotes = await prisma.generatedQuotes.delete({ + * where: { + * // ... filter to delete one GeneratedQuotes + * } + * }) + * + */ + delete(args: Prisma.SelectSubset>): Prisma.Prisma__GeneratedQuotesClient, T, "delete", GlobalOmitOptions>, never, ExtArgs, GlobalOmitOptions> + + /** + * Update one GeneratedQuotes. + * @param {GeneratedQuotesUpdateArgs} args - Arguments to update one GeneratedQuotes. + * @example + * // Update one GeneratedQuotes + * const generatedQuotes = await prisma.generatedQuotes.update({ + * where: { + * // ... provide filter here + * }, + * data: { + * // ... provide data here + * } + * }) + * + */ + update(args: Prisma.SelectSubset>): Prisma.Prisma__GeneratedQuotesClient, T, "update", GlobalOmitOptions>, never, ExtArgs, GlobalOmitOptions> + + /** + * Delete zero or more GeneratedQuotes. + * @param {GeneratedQuotesDeleteManyArgs} args - Arguments to filter GeneratedQuotes to delete. + * @example + * // Delete a few GeneratedQuotes + * const { count } = await prisma.generatedQuotes.deleteMany({ + * where: { + * // ... provide filter here + * } + * }) + * + */ + deleteMany(args?: Prisma.SelectSubset>): Prisma.PrismaPromise + + /** + * Update zero or more GeneratedQuotes. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {GeneratedQuotesUpdateManyArgs} args - Arguments to update one or more rows. + * @example + * // Update many GeneratedQuotes + * const generatedQuotes = await prisma.generatedQuotes.updateMany({ + * where: { + * // ... provide filter here + * }, + * data: { + * // ... provide data here + * } + * }) + * + */ + updateMany(args: Prisma.SelectSubset>): Prisma.PrismaPromise + + /** + * Update zero or more GeneratedQuotes and returns the data updated in the database. + * @param {GeneratedQuotesUpdateManyAndReturnArgs} args - Arguments to update many GeneratedQuotes. + * @example + * // Update many GeneratedQuotes + * const generatedQuotes = await prisma.generatedQuotes.updateManyAndReturn({ + * where: { + * // ... provide filter here + * }, + * data: [ + * // ... provide data here + * ] + * }) + * + * // Update zero or more GeneratedQuotes and only return the `id` + * const generatedQuotesWithIdOnly = await prisma.generatedQuotes.updateManyAndReturn({ + * select: { id: true }, + * where: { + * // ... provide filter here + * }, + * data: [ + * // ... provide data here + * ] + * }) + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * + */ + updateManyAndReturn(args: Prisma.SelectSubset>): Prisma.PrismaPromise, T, "updateManyAndReturn", GlobalOmitOptions>> + + /** + * Create or update one GeneratedQuotes. + * @param {GeneratedQuotesUpsertArgs} args - Arguments to update or create a GeneratedQuotes. + * @example + * // Update or create a GeneratedQuotes + * const generatedQuotes = await prisma.generatedQuotes.upsert({ + * create: { + * // ... data to create a GeneratedQuotes + * }, + * update: { + * // ... in case it already exists, update + * }, + * where: { + * // ... the filter for the GeneratedQuotes we want to update + * } + * }) + */ + upsert(args: Prisma.SelectSubset>): Prisma.Prisma__GeneratedQuotesClient, T, "upsert", GlobalOmitOptions>, never, ExtArgs, GlobalOmitOptions> + + + /** + * Count the number of GeneratedQuotes. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {GeneratedQuotesCountArgs} args - Arguments to filter GeneratedQuotes to count. + * @example + * // Count the number of GeneratedQuotes + * const count = await prisma.generatedQuotes.count({ + * where: { + * // ... the filter for the GeneratedQuotes we want to count + * } + * }) + **/ + count( + args?: Prisma.Subset, + ): Prisma.PrismaPromise< + T extends runtime.Types.Utils.Record<'select', any> + ? T['select'] extends true + ? number + : Prisma.GetScalarType + : number + > + + /** + * Allows you to perform aggregations operations on a GeneratedQuotes. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {GeneratedQuotesAggregateArgs} args - Select which aggregations you would like to apply and on what fields. + * @example + * // Ordered by age ascending + * // Where email contains prisma.io + * // Limited to the 10 users + * const aggregations = await prisma.user.aggregate({ + * _avg: { + * age: true, + * }, + * where: { + * email: { + * contains: "prisma.io", + * }, + * }, + * orderBy: { + * age: "asc", + * }, + * take: 10, + * }) + **/ + aggregate(args: Prisma.Subset): Prisma.PrismaPromise> + + /** + * Group by GeneratedQuotes. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {GeneratedQuotesGroupByArgs} args - Group by arguments. + * @example + * // Group by city, order by createdAt, get count + * const result = await prisma.user.groupBy({ + * by: ['city', 'createdAt'], + * orderBy: { + * createdAt: true + * }, + * _count: { + * _all: true + * }, + * }) + * + **/ + groupBy< + T extends GeneratedQuotesGroupByArgs, + HasSelectOrTake extends Prisma.Or< + Prisma.Extends<'skip', Prisma.Keys>, + Prisma.Extends<'take', Prisma.Keys> + >, + OrderByArg extends Prisma.True extends HasSelectOrTake + ? { orderBy: GeneratedQuotesGroupByArgs['orderBy'] } + : { orderBy?: GeneratedQuotesGroupByArgs['orderBy'] }, + OrderFields extends Prisma.ExcludeUnderscoreKeys>>, + ByFields extends Prisma.MaybeTupleToUnion, + ByValid extends Prisma.Has, + HavingFields extends Prisma.GetHavingFields, + HavingValid extends Prisma.Has, + ByEmpty extends T['by'] extends never[] ? Prisma.True : Prisma.False, + InputErrors extends ByEmpty extends Prisma.True + ? `Error: "by" must not be empty.` + : HavingValid extends Prisma.False + ? { + [P in HavingFields]: P extends ByFields + ? never + : P extends string + ? `Error: Field "${P}" used in "having" needs to be provided in "by".` + : [ + Error, + 'Field ', + P, + ` in "having" needs to be provided in "by"`, + ] + }[HavingFields] + : 'take' extends Prisma.Keys + ? 'orderBy' extends Prisma.Keys + ? ByValid extends Prisma.True + ? {} + : { + [P in OrderFields]: P extends ByFields + ? never + : `Error: Field "${P}" in "orderBy" needs to be provided in "by"` + }[OrderFields] + : 'Error: If you provide "take", you also need to provide "orderBy"' + : 'skip' extends Prisma.Keys + ? 'orderBy' extends Prisma.Keys + ? ByValid extends Prisma.True + ? {} + : { + [P in OrderFields]: P extends ByFields + ? never + : `Error: Field "${P}" in "orderBy" needs to be provided in "by"` + }[OrderFields] + : 'Error: If you provide "skip", you also need to provide "orderBy"' + : ByValid extends Prisma.True + ? {} + : { + [P in OrderFields]: P extends ByFields + ? never + : `Error: Field "${P}" in "orderBy" needs to be provided in "by"` + }[OrderFields] + >(args: Prisma.SubsetIntersection & InputErrors): {} extends InputErrors ? GetGeneratedQuotesGroupByPayload : Prisma.PrismaPromise +/** + * Fields of the GeneratedQuotes model + */ +readonly fields: GeneratedQuotesFieldRefs; +} + +/** + * The delegate class that acts as a "Promise-like" for GeneratedQuotes. + * Why is this prefixed with `Prisma__`? + * Because we want to prevent naming conflicts as mentioned in + * https://github.com/prisma/prisma-client-js/issues/707 + */ +export interface Prisma__GeneratedQuotesClient extends Prisma.PrismaPromise { + readonly [Symbol.toStringTag]: "PrismaPromise" + opportunity = {}>(args?: Prisma.Subset>): Prisma.Prisma__OpportunityClient, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions> + createdBy = {}>(args?: Prisma.Subset>): Prisma.Prisma__UserClient, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions> + /** + * Attaches callbacks for the resolution and/or rejection of the Promise. + * @param onfulfilled The callback to execute when the Promise is resolved. + * @param onrejected The callback to execute when the Promise is rejected. + * @returns A Promise for the completion of which ever callback is executed. + */ + then(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): runtime.Types.Utils.JsPromise + /** + * Attaches a callback for only the rejection of the Promise. + * @param onrejected The callback to execute when the Promise is rejected. + * @returns A Promise for the completion of the callback. + */ + catch(onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null): runtime.Types.Utils.JsPromise + /** + * Attaches a callback that is invoked when the Promise is settled (fulfilled or rejected). The + * resolved value cannot be modified from the callback. + * @param onfinally The callback to execute when the Promise is settled (fulfilled or rejected). + * @returns A Promise for the completion of the callback. + */ + finally(onfinally?: (() => void) | undefined | null): runtime.Types.Utils.JsPromise +} + + + + +/** + * Fields of the GeneratedQuotes model + */ +export interface GeneratedQuotesFieldRefs { + readonly id: Prisma.FieldRef<"GeneratedQuotes", 'String'> + readonly quoteRegenData: Prisma.FieldRef<"GeneratedQuotes", 'Json'> + readonly quoteRegenParams: Prisma.FieldRef<"GeneratedQuotes", 'Json'> + readonly quoteRegenHash: Prisma.FieldRef<"GeneratedQuotes", 'String'> + readonly downloads: Prisma.FieldRef<"GeneratedQuotes", 'Json'> + readonly quoteFile: Prisma.FieldRef<"GeneratedQuotes", 'Bytes'> + readonly quoteFileName: Prisma.FieldRef<"GeneratedQuotes", 'String'> + readonly opportunityId: Prisma.FieldRef<"GeneratedQuotes", 'String'> + readonly createdById: Prisma.FieldRef<"GeneratedQuotes", 'String'> + readonly createdAt: Prisma.FieldRef<"GeneratedQuotes", 'DateTime'> + readonly updatedAt: Prisma.FieldRef<"GeneratedQuotes", 'DateTime'> +} + + +// Custom InputTypes +/** + * GeneratedQuotes findUnique + */ +export type GeneratedQuotesFindUniqueArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelect | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesInclude | null + /** + * Filter, which GeneratedQuotes to fetch. + */ + where: Prisma.GeneratedQuotesWhereUniqueInput +} + +/** + * GeneratedQuotes findUniqueOrThrow + */ +export type GeneratedQuotesFindUniqueOrThrowArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelect | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesInclude | null + /** + * Filter, which GeneratedQuotes to fetch. + */ + where: Prisma.GeneratedQuotesWhereUniqueInput +} + +/** + * GeneratedQuotes findFirst + */ +export type GeneratedQuotesFindFirstArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelect | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesInclude | null + /** + * Filter, which GeneratedQuotes to fetch. + */ + where?: Prisma.GeneratedQuotesWhereInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/sorting Sorting Docs} + * + * Determine the order of GeneratedQuotes to fetch. + */ + orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[] + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination#cursor-based-pagination Cursor Docs} + * + * Sets the position for searching for GeneratedQuotes. + */ + cursor?: Prisma.GeneratedQuotesWhereUniqueInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Take `±n` GeneratedQuotes from the position of the cursor. + */ + take?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Skip the first `n` GeneratedQuotes. + */ + skip?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/distinct Distinct Docs} + * + * Filter by unique combinations of GeneratedQuotes. + */ + distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[] +} + +/** + * GeneratedQuotes findFirstOrThrow + */ +export type GeneratedQuotesFindFirstOrThrowArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelect | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesInclude | null + /** + * Filter, which GeneratedQuotes to fetch. + */ + where?: Prisma.GeneratedQuotesWhereInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/sorting Sorting Docs} + * + * Determine the order of GeneratedQuotes to fetch. + */ + orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[] + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination#cursor-based-pagination Cursor Docs} + * + * Sets the position for searching for GeneratedQuotes. + */ + cursor?: Prisma.GeneratedQuotesWhereUniqueInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Take `±n` GeneratedQuotes from the position of the cursor. + */ + take?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Skip the first `n` GeneratedQuotes. + */ + skip?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/distinct Distinct Docs} + * + * Filter by unique combinations of GeneratedQuotes. + */ + distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[] +} + +/** + * GeneratedQuotes findMany + */ +export type GeneratedQuotesFindManyArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelect | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesInclude | null + /** + * Filter, which GeneratedQuotes to fetch. + */ + where?: Prisma.GeneratedQuotesWhereInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/sorting Sorting Docs} + * + * Determine the order of GeneratedQuotes to fetch. + */ + orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[] + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination#cursor-based-pagination Cursor Docs} + * + * Sets the position for listing GeneratedQuotes. + */ + cursor?: Prisma.GeneratedQuotesWhereUniqueInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Take `±n` GeneratedQuotes from the position of the cursor. + */ + take?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Skip the first `n` GeneratedQuotes. + */ + skip?: number + distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[] +} + +/** + * GeneratedQuotes create + */ +export type GeneratedQuotesCreateArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelect | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesInclude | null + /** + * The data needed to create a GeneratedQuotes. + */ + data: Prisma.XOR +} + +/** + * GeneratedQuotes createMany + */ +export type GeneratedQuotesCreateManyArgs = { + /** + * The data used to create many GeneratedQuotes. + */ + data: Prisma.GeneratedQuotesCreateManyInput | Prisma.GeneratedQuotesCreateManyInput[] + skipDuplicates?: boolean +} + +/** + * GeneratedQuotes createManyAndReturn + */ +export type GeneratedQuotesCreateManyAndReturnArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelectCreateManyAndReturn | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * The data used to create many GeneratedQuotes. + */ + data: Prisma.GeneratedQuotesCreateManyInput | Prisma.GeneratedQuotesCreateManyInput[] + skipDuplicates?: boolean + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesIncludeCreateManyAndReturn | null +} + +/** + * GeneratedQuotes update + */ +export type GeneratedQuotesUpdateArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelect | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesInclude | null + /** + * The data needed to update a GeneratedQuotes. + */ + data: Prisma.XOR + /** + * Choose, which GeneratedQuotes to update. + */ + where: Prisma.GeneratedQuotesWhereUniqueInput +} + +/** + * GeneratedQuotes updateMany + */ +export type GeneratedQuotesUpdateManyArgs = { + /** + * The data used to update GeneratedQuotes. + */ + data: Prisma.XOR + /** + * Filter which GeneratedQuotes to update + */ + where?: Prisma.GeneratedQuotesWhereInput + /** + * Limit how many GeneratedQuotes to update. + */ + limit?: number +} + +/** + * GeneratedQuotes updateManyAndReturn + */ +export type GeneratedQuotesUpdateManyAndReturnArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelectUpdateManyAndReturn | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * The data used to update GeneratedQuotes. + */ + data: Prisma.XOR + /** + * Filter which GeneratedQuotes to update + */ + where?: Prisma.GeneratedQuotesWhereInput + /** + * Limit how many GeneratedQuotes to update. + */ + limit?: number + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesIncludeUpdateManyAndReturn | null +} + +/** + * GeneratedQuotes upsert + */ +export type GeneratedQuotesUpsertArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelect | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesInclude | null + /** + * The filter to search for the GeneratedQuotes to update in case it exists. + */ + where: Prisma.GeneratedQuotesWhereUniqueInput + /** + * In case the GeneratedQuotes found by the `where` argument doesn't exist, create a new GeneratedQuotes with this data. + */ + create: Prisma.XOR + /** + * In case the GeneratedQuotes was found with the provided `where` argument, update it with this data. + */ + update: Prisma.XOR +} + +/** + * GeneratedQuotes delete + */ +export type GeneratedQuotesDeleteArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelect | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesInclude | null + /** + * Filter which GeneratedQuotes to delete. + */ + where: Prisma.GeneratedQuotesWhereUniqueInput +} + +/** + * GeneratedQuotes deleteMany + */ +export type GeneratedQuotesDeleteManyArgs = { + /** + * Filter which GeneratedQuotes to delete + */ + where?: Prisma.GeneratedQuotesWhereInput + /** + * Limit how many GeneratedQuotes to delete. + */ + limit?: number +} + +/** + * GeneratedQuotes.createdBy + */ +export type GeneratedQuotes$createdByArgs = { + /** + * Select specific fields to fetch from the User + */ + select?: Prisma.UserSelect | null + /** + * Omit specific fields from the User + */ + omit?: Prisma.UserOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.UserInclude | null + where?: Prisma.UserWhereInput +} + +/** + * GeneratedQuotes without action + */ +export type GeneratedQuotesDefaultArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelect | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesInclude | null +} diff --git a/generated/prisma/models/Opportunity.ts b/generated/prisma/models/Opportunity.ts index 362e0c1..b976b69 100644 --- a/generated/prisma/models/Opportunity.ts +++ b/generated/prisma/models/Opportunity.ts @@ -40,6 +40,7 @@ export type OpportunityAvgAggregateOutputType = { contactCwId: number | null siteCwId: number | null totalSalesTax: number | null + probability: number | null locationCwId: number | null departmentCwId: number | null closedByCwId: number | null @@ -60,6 +61,7 @@ export type OpportunitySumAggregateOutputType = { contactCwId: number | null siteCwId: number | null totalSalesTax: number | null + probability: number | null locationCwId: number | null departmentCwId: number | null closedByCwId: number | null @@ -98,6 +100,7 @@ export type OpportunityMinAggregateOutputType = { siteName: string | null customerPO: string | null totalSalesTax: number | null + probability: number | null locationName: string | null locationCwId: number | null departmentName: string | null @@ -147,6 +150,7 @@ export type OpportunityMaxAggregateOutputType = { siteName: string | null customerPO: string | null totalSalesTax: number | null + probability: number | null locationName: string | null locationCwId: number | null departmentName: string | null @@ -196,6 +200,7 @@ export type OpportunityCountAggregateOutputType = { siteName: number customerPO: number totalSalesTax: number + probability: number locationName: number locationCwId: number departmentName: number @@ -230,6 +235,7 @@ export type OpportunityAvgAggregateInputType = { contactCwId?: true siteCwId?: true totalSalesTax?: true + probability?: true locationCwId?: true departmentCwId?: true closedByCwId?: true @@ -250,6 +256,7 @@ export type OpportunitySumAggregateInputType = { contactCwId?: true siteCwId?: true totalSalesTax?: true + probability?: true locationCwId?: true departmentCwId?: true closedByCwId?: true @@ -288,6 +295,7 @@ export type OpportunityMinAggregateInputType = { siteName?: true customerPO?: true totalSalesTax?: true + probability?: true locationName?: true locationCwId?: true departmentName?: true @@ -337,6 +345,7 @@ export type OpportunityMaxAggregateInputType = { siteName?: true customerPO?: true totalSalesTax?: true + probability?: true locationName?: true locationCwId?: true departmentName?: true @@ -386,6 +395,7 @@ export type OpportunityCountAggregateInputType = { siteName?: true customerPO?: true totalSalesTax?: true + probability?: true locationName?: true locationCwId?: true departmentName?: true @@ -523,6 +533,7 @@ export type OpportunityGroupByOutputType = { siteName: string | null customerPO: string | null totalSalesTax: number + probability: number locationName: string | null locationCwId: number | null departmentName: string | null @@ -596,6 +607,7 @@ export type OpportunityWhereInput = { siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number + probability?: Prisma.FloatFilter<"Opportunity"> | number locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null @@ -612,6 +624,7 @@ export type OpportunityWhereInput = { cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string + generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter company?: Prisma.XOR | null } @@ -647,6 +660,7 @@ export type OpportunityOrderByWithRelationInput = { siteName?: Prisma.SortOrderInput | Prisma.SortOrder customerPO?: Prisma.SortOrderInput | Prisma.SortOrder totalSalesTax?: Prisma.SortOrder + probability?: Prisma.SortOrder locationName?: Prisma.SortOrderInput | Prisma.SortOrder locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder departmentName?: Prisma.SortOrderInput | Prisma.SortOrder @@ -663,6 +677,7 @@ export type OpportunityOrderByWithRelationInput = { cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder createdAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder + generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput company?: Prisma.CompanyOrderByWithRelationInput } @@ -701,6 +716,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{ siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number + probability?: Prisma.FloatFilter<"Opportunity"> | number locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null @@ -717,6 +733,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{ cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string + generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter company?: Prisma.XOR | null }, "id" | "cwOpportunityId"> @@ -752,6 +769,7 @@ export type OpportunityOrderByWithAggregationInput = { siteName?: Prisma.SortOrderInput | Prisma.SortOrder customerPO?: Prisma.SortOrderInput | Prisma.SortOrder totalSalesTax?: Prisma.SortOrder + probability?: Prisma.SortOrder locationName?: Prisma.SortOrderInput | Prisma.SortOrder locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder departmentName?: Prisma.SortOrderInput | Prisma.SortOrder @@ -810,6 +828,7 @@ export type OpportunityScalarWhereWithAggregatesInput = { siteName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null customerPO?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null totalSalesTax?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number + probability?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number locationName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null locationCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null departmentName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null @@ -860,6 +879,7 @@ export type OpportunityCreateInput = { siteName?: string | null customerPO?: string | null totalSalesTax?: number + probability?: number locationName?: string | null locationCwId?: number | null departmentName?: string | null @@ -875,6 +895,7 @@ export type OpportunityCreateInput = { cwLastUpdated?: Date | string | null createdAt?: Date | string updatedAt?: Date | string + generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput } @@ -910,6 +931,7 @@ export type OpportunityUncheckedCreateInput = { siteName?: string | null customerPO?: string | null totalSalesTax?: number + probability?: number locationName?: string | null locationCwId?: number | null departmentName?: string | null @@ -926,6 +948,7 @@ export type OpportunityUncheckedCreateInput = { cwLastUpdated?: Date | string | null createdAt?: Date | string updatedAt?: Date | string + generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput } export type OpportunityUpdateInput = { @@ -960,6 +983,7 @@ export type OpportunityUpdateInput = { siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number + probability?: Prisma.FloatFieldUpdateOperationsInput | number locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null @@ -975,6 +999,7 @@ export type OpportunityUpdateInput = { cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput } @@ -1010,6 +1035,7 @@ export type OpportunityUncheckedUpdateInput = { siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number + probability?: Prisma.FloatFieldUpdateOperationsInput | number locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null @@ -1026,6 +1052,7 @@ export type OpportunityUncheckedUpdateInput = { cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput } export type OpportunityCreateManyInput = { @@ -1060,6 +1087,7 @@ export type OpportunityCreateManyInput = { siteName?: string | null customerPO?: string | null totalSalesTax?: number + probability?: number locationName?: string | null locationCwId?: number | null departmentName?: string | null @@ -1110,6 +1138,7 @@ export type OpportunityUpdateManyMutationInput = { siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number + probability?: Prisma.FloatFieldUpdateOperationsInput | number locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null @@ -1159,6 +1188,7 @@ export type OpportunityUncheckedUpdateManyInput = { siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number + probability?: Prisma.FloatFieldUpdateOperationsInput | number locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null @@ -1227,6 +1257,7 @@ export type OpportunityCountOrderByAggregateInput = { siteName?: Prisma.SortOrder customerPO?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder + probability?: Prisma.SortOrder locationName?: Prisma.SortOrder locationCwId?: Prisma.SortOrder departmentName?: Prisma.SortOrder @@ -1259,6 +1290,7 @@ export type OpportunityAvgOrderByAggregateInput = { contactCwId?: Prisma.SortOrder siteCwId?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder + probability?: Prisma.SortOrder locationCwId?: Prisma.SortOrder departmentCwId?: Prisma.SortOrder closedByCwId?: Prisma.SortOrder @@ -1297,6 +1329,7 @@ export type OpportunityMaxOrderByAggregateInput = { siteName?: Prisma.SortOrder customerPO?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder + probability?: Prisma.SortOrder locationName?: Prisma.SortOrder locationCwId?: Prisma.SortOrder departmentName?: Prisma.SortOrder @@ -1346,6 +1379,7 @@ export type OpportunityMinOrderByAggregateInput = { siteName?: Prisma.SortOrder customerPO?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder + probability?: Prisma.SortOrder locationName?: Prisma.SortOrder locationCwId?: Prisma.SortOrder departmentName?: Prisma.SortOrder @@ -1377,12 +1411,18 @@ export type OpportunitySumOrderByAggregateInput = { contactCwId?: Prisma.SortOrder siteCwId?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder + probability?: Prisma.SortOrder locationCwId?: Prisma.SortOrder departmentCwId?: Prisma.SortOrder closedByCwId?: Prisma.SortOrder productSequence?: Prisma.SortOrder } +export type OpportunityScalarRelationFilter = { + is?: Prisma.OpportunityWhereInput + isNot?: Prisma.OpportunityWhereInput +} + export type OpportunityCreateNestedManyWithoutCompanyInput = { create?: Prisma.XOR | Prisma.OpportunityCreateWithoutCompanyInput[] | Prisma.OpportunityUncheckedCreateWithoutCompanyInput[] connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutCompanyInput | Prisma.OpportunityCreateOrConnectWithoutCompanyInput[] @@ -1434,6 +1474,20 @@ export type OpportunityUpdateproductSequenceInput = { push?: number | number[] } +export type OpportunityCreateNestedOneWithoutGeneratedQuotesInput = { + create?: Prisma.XOR + connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutGeneratedQuotesInput + connect?: Prisma.OpportunityWhereUniqueInput +} + +export type OpportunityUpdateOneRequiredWithoutGeneratedQuotesNestedInput = { + create?: Prisma.XOR + connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutGeneratedQuotesInput + upsert?: Prisma.OpportunityUpsertWithoutGeneratedQuotesInput + connect?: Prisma.OpportunityWhereUniqueInput + update?: Prisma.XOR, Prisma.OpportunityUncheckedUpdateWithoutGeneratedQuotesInput> +} + export type OpportunityCreateWithoutCompanyInput = { id?: string cwOpportunityId: number @@ -1466,6 +1520,7 @@ export type OpportunityCreateWithoutCompanyInput = { siteName?: string | null customerPO?: string | null totalSalesTax?: number + probability?: number locationName?: string | null locationCwId?: number | null departmentName?: string | null @@ -1481,6 +1536,7 @@ export type OpportunityCreateWithoutCompanyInput = { cwLastUpdated?: Date | string | null createdAt?: Date | string updatedAt?: Date | string + generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput } export type OpportunityUncheckedCreateWithoutCompanyInput = { @@ -1515,6 +1571,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = { siteName?: string | null customerPO?: string | null totalSalesTax?: number + probability?: number locationName?: string | null locationCwId?: number | null departmentName?: string | null @@ -1530,6 +1587,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = { cwLastUpdated?: Date | string | null createdAt?: Date | string updatedAt?: Date | string + generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput } export type OpportunityCreateOrConnectWithoutCompanyInput = { @@ -1593,6 +1651,7 @@ export type OpportunityScalarWhereInput = { siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number + probability?: Prisma.FloatFilter<"Opportunity"> | number locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null @@ -1611,6 +1670,226 @@ export type OpportunityScalarWhereInput = { updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string } +export type OpportunityCreateWithoutGeneratedQuotesInput = { + id?: string + cwOpportunityId: number + name: string + notes?: string | null + typeName?: string | null + typeCwId?: number | null + stageName?: string | null + stageCwId?: number | null + statusName?: string | null + statusCwId?: number | null + priorityName?: string | null + priorityCwId?: number | null + ratingName?: string | null + ratingCwId?: number | null + source?: string | null + campaignName?: string | null + campaignCwId?: number | null + primarySalesRepName?: string | null + primarySalesRepIdentifier?: string | null + primarySalesRepCwId?: number | null + secondarySalesRepName?: string | null + secondarySalesRepIdentifier?: string | null + secondarySalesRepCwId?: number | null + companyCwId?: number | null + companyName?: string | null + contactCwId?: number | null + contactName?: string | null + siteCwId?: number | null + siteName?: string | null + customerPO?: string | null + totalSalesTax?: number + probability?: number + locationName?: string | null + locationCwId?: number | null + departmentName?: string | null + departmentCwId?: number | null + expectedCloseDate?: Date | string | null + pipelineChangeDate?: Date | string | null + dateBecameLead?: Date | string | null + closedDate?: Date | string | null + closedFlag?: boolean + closedByName?: string | null + closedByCwId?: number | null + productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] + cwLastUpdated?: Date | string | null + createdAt?: Date | string + updatedAt?: Date | string + company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput +} + +export type OpportunityUncheckedCreateWithoutGeneratedQuotesInput = { + id?: string + cwOpportunityId: number + name: string + notes?: string | null + typeName?: string | null + typeCwId?: number | null + stageName?: string | null + stageCwId?: number | null + statusName?: string | null + statusCwId?: number | null + priorityName?: string | null + priorityCwId?: number | null + ratingName?: string | null + ratingCwId?: number | null + source?: string | null + campaignName?: string | null + campaignCwId?: number | null + primarySalesRepName?: string | null + primarySalesRepIdentifier?: string | null + primarySalesRepCwId?: number | null + secondarySalesRepName?: string | null + secondarySalesRepIdentifier?: string | null + secondarySalesRepCwId?: number | null + companyCwId?: number | null + companyName?: string | null + contactCwId?: number | null + contactName?: string | null + siteCwId?: number | null + siteName?: string | null + customerPO?: string | null + totalSalesTax?: number + probability?: number + locationName?: string | null + locationCwId?: number | null + departmentName?: string | null + departmentCwId?: number | null + expectedCloseDate?: Date | string | null + pipelineChangeDate?: Date | string | null + dateBecameLead?: Date | string | null + closedDate?: Date | string | null + closedFlag?: boolean + closedByName?: string | null + closedByCwId?: number | null + companyId?: string | null + productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] + cwLastUpdated?: Date | string | null + createdAt?: Date | string + updatedAt?: Date | string +} + +export type OpportunityCreateOrConnectWithoutGeneratedQuotesInput = { + where: Prisma.OpportunityWhereUniqueInput + create: Prisma.XOR +} + +export type OpportunityUpsertWithoutGeneratedQuotesInput = { + update: Prisma.XOR + create: Prisma.XOR + where?: Prisma.OpportunityWhereInput +} + +export type OpportunityUpdateToOneWithWhereWithoutGeneratedQuotesInput = { + where?: Prisma.OpportunityWhereInput + data: Prisma.XOR +} + +export type OpportunityUpdateWithoutGeneratedQuotesInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + cwOpportunityId?: Prisma.IntFieldUpdateOperationsInput | number + name?: Prisma.StringFieldUpdateOperationsInput | string + notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + typeName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + typeCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + stageName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + stageCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + statusName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + statusCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + priorityName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + priorityCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + ratingName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + ratingCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + source?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + campaignName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + campaignCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + primarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + primarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + primarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + secondarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + secondarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + secondarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + companyCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + contactCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + contactName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + siteCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number + probability?: Prisma.FloatFieldUpdateOperationsInput | number + locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + departmentCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + expectedCloseDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + pipelineChangeDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + dateBecameLead?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + closedDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean + closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] + cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput +} + +export type OpportunityUncheckedUpdateWithoutGeneratedQuotesInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + cwOpportunityId?: Prisma.IntFieldUpdateOperationsInput | number + name?: Prisma.StringFieldUpdateOperationsInput | string + notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + typeName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + typeCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + stageName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + stageCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + statusName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + statusCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + priorityName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + priorityCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + ratingName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + ratingCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + source?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + campaignName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + campaignCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + primarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + primarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + primarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + secondarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + secondarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + secondarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + companyCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + contactCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + contactName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + siteCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number + probability?: Prisma.FloatFieldUpdateOperationsInput | number + locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + departmentCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + expectedCloseDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + pipelineChangeDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + dateBecameLead?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + closedDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean + closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] + cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string +} + export type OpportunityCreateManyCompanyInput = { id?: string cwOpportunityId: number @@ -1643,6 +1922,7 @@ export type OpportunityCreateManyCompanyInput = { siteName?: string | null customerPO?: string | null totalSalesTax?: number + probability?: number locationName?: string | null locationCwId?: number | null departmentName?: string | null @@ -1692,6 +1972,7 @@ export type OpportunityUpdateWithoutCompanyInput = { siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number + probability?: Prisma.FloatFieldUpdateOperationsInput | number locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null @@ -1707,6 +1988,7 @@ export type OpportunityUpdateWithoutCompanyInput = { cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput } export type OpportunityUncheckedUpdateWithoutCompanyInput = { @@ -1741,6 +2023,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = { siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number + probability?: Prisma.FloatFieldUpdateOperationsInput | number locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null @@ -1756,6 +2039,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = { cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput } export type OpportunityUncheckedUpdateManyWithoutCompanyInput = { @@ -1790,6 +2074,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = { siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number + probability?: Prisma.FloatFieldUpdateOperationsInput | number locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null @@ -1808,6 +2093,35 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = { } +/** + * Count Type OpportunityCountOutputType + */ + +export type OpportunityCountOutputType = { + generatedQuotes: number +} + +export type OpportunityCountOutputTypeSelect = { + generatedQuotes?: boolean | OpportunityCountOutputTypeCountGeneratedQuotesArgs +} + +/** + * OpportunityCountOutputType without action + */ +export type OpportunityCountOutputTypeDefaultArgs = { + /** + * Select specific fields to fetch from the OpportunityCountOutputType + */ + select?: Prisma.OpportunityCountOutputTypeSelect | null +} + +/** + * OpportunityCountOutputType without action + */ +export type OpportunityCountOutputTypeCountGeneratedQuotesArgs = { + where?: Prisma.GeneratedQuotesWhereInput +} + export type OpportunitySelect = runtime.Types.Extensions.GetSelect<{ id?: boolean @@ -1841,6 +2155,7 @@ export type OpportunitySelect company?: boolean | Prisma.Opportunity$companyArgs + _count?: boolean | Prisma.OpportunityCountOutputTypeDefaultArgs }, ExtArgs["result"]["opportunity"]> export type OpportunitySelectCreateManyAndReturn = runtime.Types.Extensions.GetSelect<{ @@ -1892,6 +2209,7 @@ export type OpportunitySelectCreateManyAndReturn = 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 = 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 = { + generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs company?: boolean | Prisma.Opportunity$companyArgs + _count?: boolean | Prisma.OpportunityCountOutputTypeDefaultArgs } export type OpportunityIncludeCreateManyAndReturn = { company?: boolean | Prisma.Opportunity$companyArgs @@ -2026,6 +2348,7 @@ export type OpportunityIncludeUpdateManyAndReturn = { name: "Opportunity" objects: { + generatedQuotes: Prisma.$GeneratedQuotesPayload[] company: Prisma.$CompanyPayload | null } scalars: runtime.Types.Extensions.GetPayloadResult<{ @@ -2060,6 +2383,7 @@ export type $OpportunityPayload extends Prisma.PrismaPromise { readonly [Symbol.toStringTag]: "PrismaPromise" + generatedQuotes = {}>(args?: Prisma.Subset>): Prisma.PrismaPromise, T, "findMany", GlobalOmitOptions> | Null> company = {}>(args?: Prisma.Subset>): Prisma.Prisma__CompanyClient, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions> /** * Attaches callbacks for the resolution and/or rejection of the Promise. @@ -2531,6 +2856,7 @@ export interface OpportunityFieldRefs { readonly siteName: Prisma.FieldRef<"Opportunity", 'String'> readonly customerPO: Prisma.FieldRef<"Opportunity", 'String'> readonly totalSalesTax: Prisma.FieldRef<"Opportunity", 'Float'> + readonly probability: Prisma.FieldRef<"Opportunity", 'Float'> readonly locationName: Prisma.FieldRef<"Opportunity", 'String'> readonly locationCwId: Prisma.FieldRef<"Opportunity", 'Int'> readonly departmentName: Prisma.FieldRef<"Opportunity", 'String'> @@ -2942,6 +3268,30 @@ export type OpportunityDeleteManyArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelect | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesInclude | null + where?: Prisma.GeneratedQuotesWhereInput + orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[] + cursor?: Prisma.GeneratedQuotesWhereUniqueInput + take?: number + skip?: number + distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[] +} + /** * Opportunity.company */ diff --git a/generated/prisma/models/User.ts b/generated/prisma/models/User.ts index bcf6b05..2a81b2b 100644 --- a/generated/prisma/models/User.ts +++ b/generated/prisma/models/User.ts @@ -240,6 +240,7 @@ export type UserWhereInput = { updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string roles?: Prisma.RoleListRelationFilter sessions?: Prisma.SessionListRelationFilter + generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter } export type UserOrderByWithRelationInput = { @@ -257,6 +258,7 @@ export type UserOrderByWithRelationInput = { updatedAt?: Prisma.SortOrder roles?: Prisma.RoleOrderByRelationAggregateInput sessions?: Prisma.SessionOrderByRelationAggregateInput + generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput } export type UserWhereUniqueInput = Prisma.AtLeast<{ @@ -277,6 +279,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{ updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string roles?: Prisma.RoleListRelationFilter sessions?: Prisma.SessionListRelationFilter + generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter }, "id" | "login" | "email" | "userId"> export type UserOrderByWithAggregationInput = { @@ -330,6 +333,7 @@ export type UserCreateInput = { updatedAt?: Date | string roles?: Prisma.RoleCreateNestedManyWithoutUsersInput sessions?: Prisma.SessionCreateNestedManyWithoutUserInput + generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput } export type UserUncheckedCreateInput = { @@ -347,6 +351,7 @@ export type UserUncheckedCreateInput = { updatedAt?: Date | string roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput + generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput } export type UserUpdateInput = { @@ -364,6 +369,7 @@ export type UserUpdateInput = { updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput + generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput } export type UserUncheckedUpdateInput = { @@ -381,6 +387,7 @@ export type UserUncheckedUpdateInput = { updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput + generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput } export type UserCreateManyInput = { @@ -488,6 +495,11 @@ export type UserOrderByRelationAggregateInput = { _count?: Prisma.SortOrder } +export type UserNullableScalarRelationFilter = { + is?: Prisma.UserWhereInput | null + isNot?: Prisma.UserWhereInput | null +} + export type UserCreateNestedOneWithoutSessionsInput = { create?: Prisma.XOR connectOrCreate?: Prisma.UserCreateOrConnectWithoutSessionsInput @@ -544,6 +556,22 @@ export type UserUncheckedUpdateManyWithoutRolesNestedInput = { deleteMany?: Prisma.UserScalarWhereInput | Prisma.UserScalarWhereInput[] } +export type UserCreateNestedOneWithoutGeneratedQuotesInput = { + create?: Prisma.XOR + connectOrCreate?: Prisma.UserCreateOrConnectWithoutGeneratedQuotesInput + connect?: Prisma.UserWhereUniqueInput +} + +export type UserUpdateOneWithoutGeneratedQuotesNestedInput = { + create?: Prisma.XOR + connectOrCreate?: Prisma.UserCreateOrConnectWithoutGeneratedQuotesInput + upsert?: Prisma.UserUpsertWithoutGeneratedQuotesInput + disconnect?: Prisma.UserWhereInput | boolean + delete?: Prisma.UserWhereInput | boolean + connect?: Prisma.UserWhereUniqueInput + update?: Prisma.XOR, Prisma.UserUncheckedUpdateWithoutGeneratedQuotesInput> +} + export type UserCreateWithoutSessionsInput = { id?: string permissions?: string | null @@ -558,6 +586,7 @@ export type UserCreateWithoutSessionsInput = { createdAt?: Date | string updatedAt?: Date | string roles?: Prisma.RoleCreateNestedManyWithoutUsersInput + generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput } export type UserUncheckedCreateWithoutSessionsInput = { @@ -574,6 +603,7 @@ export type UserUncheckedCreateWithoutSessionsInput = { createdAt?: Date | string updatedAt?: Date | string roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput + generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput } export type UserCreateOrConnectWithoutSessionsInput = { @@ -606,6 +636,7 @@ export type UserUpdateWithoutSessionsInput = { createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput + generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput } export type UserUncheckedUpdateWithoutSessionsInput = { @@ -622,6 +653,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = { createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput + generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput } export type UserCreateWithoutRolesInput = { @@ -638,6 +670,7 @@ export type UserCreateWithoutRolesInput = { createdAt?: Date | string updatedAt?: Date | string sessions?: Prisma.SessionCreateNestedManyWithoutUserInput + generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput } export type UserUncheckedCreateWithoutRolesInput = { @@ -654,6 +687,7 @@ export type UserUncheckedCreateWithoutRolesInput = { createdAt?: Date | string updatedAt?: Date | string sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput + generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput } export type UserCreateOrConnectWithoutRolesInput = { @@ -695,6 +729,90 @@ export type UserScalarWhereInput = { updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string } +export type UserCreateWithoutGeneratedQuotesInput = { + id?: string + permissions?: string | null + login: string + name?: string | null + email: string + emailVerified?: Date | string | null + image?: string | null + cwIdentifier?: string | null + userId: string + token?: string | null + createdAt?: Date | string + updatedAt?: Date | string + roles?: Prisma.RoleCreateNestedManyWithoutUsersInput + sessions?: Prisma.SessionCreateNestedManyWithoutUserInput +} + +export type UserUncheckedCreateWithoutGeneratedQuotesInput = { + id?: string + permissions?: string | null + login: string + name?: string | null + email: string + emailVerified?: Date | string | null + image?: string | null + cwIdentifier?: string | null + userId: string + token?: string | null + createdAt?: Date | string + updatedAt?: Date | string + roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput + sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput +} + +export type UserCreateOrConnectWithoutGeneratedQuotesInput = { + where: Prisma.UserWhereUniqueInput + create: Prisma.XOR +} + +export type UserUpsertWithoutGeneratedQuotesInput = { + update: Prisma.XOR + create: Prisma.XOR + where?: Prisma.UserWhereInput +} + +export type UserUpdateToOneWithWhereWithoutGeneratedQuotesInput = { + where?: Prisma.UserWhereInput + data: Prisma.XOR +} + +export type UserUpdateWithoutGeneratedQuotesInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + login?: Prisma.StringFieldUpdateOperationsInput | string + name?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + email?: Prisma.StringFieldUpdateOperationsInput | string + emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + userId?: Prisma.StringFieldUpdateOperationsInput | string + token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput + sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput +} + +export type UserUncheckedUpdateWithoutGeneratedQuotesInput = { + id?: Prisma.StringFieldUpdateOperationsInput | string + permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + login?: Prisma.StringFieldUpdateOperationsInput | string + name?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + email?: Prisma.StringFieldUpdateOperationsInput | string + emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + userId?: Prisma.StringFieldUpdateOperationsInput | string + token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string + roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput + sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput +} + export type UserUpdateWithoutRolesInput = { id?: Prisma.StringFieldUpdateOperationsInput | string permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null @@ -709,6 +827,7 @@ export type UserUpdateWithoutRolesInput = { createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput + generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput } export type UserUncheckedUpdateWithoutRolesInput = { @@ -725,6 +844,7 @@ export type UserUncheckedUpdateWithoutRolesInput = { createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput + generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput } export type UserUncheckedUpdateManyWithoutRolesInput = { @@ -750,11 +870,13 @@ export type UserUncheckedUpdateManyWithoutRolesInput = { export type UserCountOutputType = { roles: number sessions: number + generatedQuotes: number } export type UserCountOutputTypeSelect = { roles?: boolean | UserCountOutputTypeCountRolesArgs sessions?: boolean | UserCountOutputTypeCountSessionsArgs + generatedQuotes?: boolean | UserCountOutputTypeCountGeneratedQuotesArgs } /** @@ -781,6 +903,13 @@ export type UserCountOutputTypeCountSessionsArgs = { + where?: Prisma.GeneratedQuotesWhereInput +} + export type UserSelect = runtime.Types.Extensions.GetSelect<{ id?: boolean @@ -797,6 +926,7 @@ export type UserSelect sessions?: boolean | Prisma.User$sessionsArgs + generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs }, ExtArgs["result"]["user"]> @@ -849,6 +979,7 @@ export type UserOmit = { roles?: boolean | Prisma.User$rolesArgs sessions?: boolean | Prisma.User$sessionsArgs + generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs } export type UserIncludeCreateManyAndReturn = {} @@ -859,6 +990,7 @@ export type $UserPayload[] sessions: Prisma.$SessionPayload[] + generatedQuotes: Prisma.$GeneratedQuotesPayload[] } scalars: runtime.Types.Extensions.GetPayloadResult<{ id: string @@ -1269,6 +1401,7 @@ export interface Prisma__UserClient = {}>(args?: Prisma.Subset>): Prisma.PrismaPromise, T, "findMany", GlobalOmitOptions> | Null> sessions = {}>(args?: Prisma.Subset>): Prisma.PrismaPromise, T, "findMany", GlobalOmitOptions> | Null> + generatedQuotes = {}>(args?: Prisma.Subset>): Prisma.PrismaPromise, T, "findMany", GlobalOmitOptions> | Null> /** * Attaches callbacks for the resolution and/or rejection of the Promise. * @param onfulfilled The callback to execute when the Promise is resolved. @@ -1745,6 +1878,30 @@ export type User$sessionsArgs = { + /** + * Select specific fields to fetch from the GeneratedQuotes + */ + select?: Prisma.GeneratedQuotesSelect | null + /** + * Omit specific fields from the GeneratedQuotes + */ + omit?: Prisma.GeneratedQuotesOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: Prisma.GeneratedQuotesInclude | null + where?: Prisma.GeneratedQuotesWhereInput + orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[] + cursor?: Prisma.GeneratedQuotesWhereUniqueInput + take?: number + skip?: number + distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[] +} + /** * User without action */ diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1390802201d62ef4e3cd44d181933720f0b8d06b GIT binary patch literal 52102 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 Ef>-ca0ssI2 literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 3890201..5ec18fd 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "ioredis": "^5.10.0", "jsonwebtoken": "^9.0.3", "keypair": "^1.0.4", + "pdf-lib": "^1.17.1", + "pdfmake": "^0.3.5", "prisma": "^7.3.0", "socket.io": "^4.8.3", "zod": "^4.3.6", diff --git a/prisma/migrations/20260305012604_store_generated_pdf_bytes/migration.sql b/prisma/migrations/20260305012604_store_generated_pdf_bytes/migration.sql new file mode 100644 index 0000000..cedb5f6 --- /dev/null +++ b/prisma/migrations/20260305012604_store_generated_pdf_bytes/migration.sql @@ -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") +); diff --git a/prisma/migrations/20260305013723_expand_generated_quotes_model/migration.sql b/prisma/migrations/20260305013723_expand_generated_quotes_model/migration.sql new file mode 100644 index 0000000..98bd515 --- /dev/null +++ b/prisma/migrations/20260305013723_expand_generated_quotes_model/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ae11322..45a2911 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -41,8 +41,9 @@ model User { sessions Session[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + generatedQuotes GeneratedQuotes[] } model Role { @@ -130,6 +131,8 @@ model Opportunity { name String notes String? + generatedQuotes GeneratedQuotes[] + // Stage / status / priority / type / rating stored as JSON references // so we don't need separate lookup tables for CW enums typeName String? @@ -165,6 +168,7 @@ model Opportunity { // Financials totalSalesTax Float @default(0) + probability Float @default(0) // Location / department locationName String? @@ -244,3 +248,25 @@ model Credential { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model GeneratedQuotes { + id String @id @default(uuid()) + + quoteRegenData Json @default("{}") // Store any additional data needed for quote regeneration, such as product details, pricing, etc. + quoteRegenParams Json @default("{}") // Store parameters used for quote regeneration, such as template ID, formatting options, etc. + quoteRegenHash String @unique @default("") + + downloads Json @default("[]") // Array of download records with timestamp and user info + + quoteFile Bytes + quoteFileName String + + opportunityId String + opportunity Opportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade) + + createdById String? + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/src/api/sales/[id]/fetch.ts b/src/api/sales/[id]/fetch.ts index 50ebbe8..b1eab92 100644 --- a/src/api/sales/[id]/fetch.ts +++ b/src/api/sales/[id]/fetch.ts @@ -18,8 +18,9 @@ import { fetchAndCacheProducts, fetchAndCacheSite, } from "../../../modules/cache/opportunityCache"; +import { generatedQuotes } from "../../../managers/generatedQuotes"; -/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products */ +/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */ export default createRoute( "get", ["/opportunities/:identifier"], @@ -140,6 +141,11 @@ export default createRoute( .fetchProducts() .then((products) => products.map((p) => p.toJson())); } + if (includes.has("quotes")) { + subResourcePromises.quotes = generatedQuotes + .fetchByOpportunity(item.id) + .then((quotes) => quotes.map((q) => q.toJson())); + } const keys = Object.keys(subResourcePromises); const results = await Promise.all(keys.map((k) => subResourcePromises[k])); diff --git a/src/api/sales/index.ts b/src/api/sales/index.ts index 28cd291..9c17080 100644 --- a/src/api/sales/index.ts +++ b/src/api/sales/index.ts @@ -1,22 +1,27 @@ -import { default as fetchAll } from "./fetchAll"; +import { default as fetchAll } from "./opportunities/fetchAll"; import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes"; -import { default as count } from "./count"; -import { default as fetch } from "./[id]/fetch"; -import { default as refresh } from "./[id]/refresh"; -import { default as products } from "./[id]/products"; -import { default as addProduct } from "./[id]/addProduct"; -import { default as addSpecialOrderProduct } from "./[id]/addSpecialOrderProduct"; -import { default as addLabor } from "./[id]/addLabor"; -import { default as laborOptions } from "./[id]/laborOptions"; -import { default as resequenceProducts } from "./[id]/resequenceProducts"; -import { default as updateProduct } from "./[id]/updateProduct"; -import { default as cancelProduct } from "./[id]/cancelProduct"; -import { default as notes } from "./[id]/notes"; -import { default as fetchNote } from "./[id]/fetchNote"; -import { default as createNote } from "./[id]/createNote"; -import { default as updateNote } from "./[id]/updateNote"; -import { default as deleteNote } from "./[id]/deleteNote"; -import { default as contacts } from "./[id]/contacts"; +import { default as count } from "./opportunities/count"; +import { default as fetch } from "./opportunities/[id]/fetch"; +import { default as refresh } from "./opportunities/[id]/refresh"; +import { default as products } from "./opportunities/[id]/products/fetchAll"; +import { default as addProduct } from "./opportunities/[id]/products/add"; +import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder"; +import { default as addLabor } from "./opportunities/[id]/products/addLabor"; +import { default as laborOptions } from "./opportunities/[id]/products/laborOptions"; +import { default as resequenceProducts } from "./opportunities/[id]/products/resequence"; +import { default as updateProduct } from "./opportunities/[id]/products/update"; +import { default as cancelProduct } from "./opportunities/[id]/products/cancel"; +import { default as notes } from "./opportunities/[id]/notes/fetchAll"; +import { default as fetchNote } from "./opportunities/[id]/notes/fetch"; +import { default as createNote } from "./opportunities/[id]/notes/create"; +import { default as updateNote } from "./opportunities/[id]/notes/update"; +import { default as deleteNote } from "./opportunities/[id]/notes/delete"; +import { default as contacts } from "./opportunities/[id]/contacts"; +import { default as commitQuote } from "./opportunities/[id]/quotes/commit"; +import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll"; +import { default as previewQuote } from "./opportunities/[id]/quotes/preview"; +import { default as downloadQuote } from "./opportunities/[id]/quotes/download"; +import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads"; export { addProduct, @@ -37,5 +42,10 @@ export { updateNote, deleteNote, contacts, + commitQuote, + fetchQuotes, + previewQuote, + downloadQuote, + fetchDownloads, refresh, }; diff --git a/src/api/sales/[id]/contacts.ts b/src/api/sales/opportunities/[id]/contacts.ts similarity index 69% rename from src/api/sales/[id]/contacts.ts rename to src/api/sales/opportunities/[id]/contacts.ts index e73b630..fc426cc 100644 --- a/src/api/sales/[id]/contacts.ts +++ b/src/api/sales/opportunities/[id]/contacts.ts @@ -1,8 +1,8 @@ -import { createRoute } from "../../../modules/api-utils/createRoute"; -import { opportunities } from "../../../managers/opportunities"; -import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../../managers/opportunities"; +import { apiResponse } from "../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../../middleware/authorization"; +import { authMiddleware } from "../../../middleware/authorization"; /* GET /v1/sales/opportunities/:identifier/contacts */ export default createRoute( diff --git a/src/api/sales/opportunities/[id]/fetch.ts b/src/api/sales/opportunities/[id]/fetch.ts new file mode 100644 index 0000000..9869ae9 --- /dev/null +++ b/src/api/sales/opportunities/[id]/fetch.ts @@ -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) => p.catch(() => {}); + + const prewarmPromises: Promise[] = []; + 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> = { + _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"] }), +); diff --git a/src/api/sales/[id]/createNote.ts b/src/api/sales/opportunities/[id]/notes/create.ts similarity index 76% rename from src/api/sales/[id]/createNote.ts rename to src/api/sales/opportunities/[id]/notes/create.ts index fb00635..78899bc 100644 --- a/src/api/sales/[id]/createNote.ts +++ b/src/api/sales/opportunities/[id]/notes/create.ts @@ -1,9 +1,9 @@ -import { createRoute } from "../../../modules/api-utils/createRoute"; -import { opportunities } from "../../../managers/opportunities"; -import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../../../managers/opportunities"; +import { apiResponse } from "../../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../../middleware/authorization"; -import { resolveMember } from "../../../modules/cw-utils/members/memberCache"; +import { authMiddleware } from "../../../../middleware/authorization"; +import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache"; import { z } from "zod"; /* POST /v1/sales/opportunities/:identifier/notes */ diff --git a/src/api/sales/[id]/deleteNote.ts b/src/api/sales/opportunities/[id]/notes/delete.ts similarity index 70% rename from src/api/sales/[id]/deleteNote.ts rename to src/api/sales/opportunities/[id]/notes/delete.ts index 8e6a107..ec6b339 100644 --- a/src/api/sales/[id]/deleteNote.ts +++ b/src/api/sales/opportunities/[id]/notes/delete.ts @@ -1,9 +1,9 @@ -import { createRoute } from "../../../modules/api-utils/createRoute"; -import { opportunities } from "../../../managers/opportunities"; -import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../../../managers/opportunities"; +import { apiResponse } from "../../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../../middleware/authorization"; -import GenericError from "../../../Errors/GenericError"; +import { authMiddleware } from "../../../../middleware/authorization"; +import GenericError from "../../../../../Errors/GenericError"; /* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */ export default createRoute( diff --git a/src/api/sales/[id]/fetchNote.ts b/src/api/sales/opportunities/[id]/notes/fetch.ts similarity index 70% rename from src/api/sales/[id]/fetchNote.ts rename to src/api/sales/opportunities/[id]/notes/fetch.ts index 8611adb..b1680d1 100644 --- a/src/api/sales/[id]/fetchNote.ts +++ b/src/api/sales/opportunities/[id]/notes/fetch.ts @@ -1,9 +1,9 @@ -import { createRoute } from "../../../modules/api-utils/createRoute"; -import { opportunities } from "../../../managers/opportunities"; -import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../../../managers/opportunities"; +import { apiResponse } from "../../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../../middleware/authorization"; -import GenericError from "../../../Errors/GenericError"; +import { authMiddleware } from "../../../../middleware/authorization"; +import GenericError from "../../../../../Errors/GenericError"; /* GET /v1/sales/opportunities/:identifier/notes/:noteId */ export default createRoute( diff --git a/src/api/sales/[id]/notes.ts b/src/api/sales/opportunities/[id]/notes/fetchAll.ts similarity index 67% rename from src/api/sales/[id]/notes.ts rename to src/api/sales/opportunities/[id]/notes/fetchAll.ts index 9dc1fd8..0ef7582 100644 --- a/src/api/sales/[id]/notes.ts +++ b/src/api/sales/opportunities/[id]/notes/fetchAll.ts @@ -1,8 +1,8 @@ -import { createRoute } from "../../../modules/api-utils/createRoute"; -import { opportunities } from "../../../managers/opportunities"; -import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../../../managers/opportunities"; +import { apiResponse } from "../../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../../middleware/authorization"; +import { authMiddleware } from "../../../../middleware/authorization"; /* GET /v1/sales/opportunities/:identifier/notes */ export default createRoute( diff --git a/src/api/sales/[id]/updateNote.ts b/src/api/sales/opportunities/[id]/notes/update.ts similarity index 77% rename from src/api/sales/[id]/updateNote.ts rename to src/api/sales/opportunities/[id]/notes/update.ts index e09500a..3631dcd 100644 --- a/src/api/sales/[id]/updateNote.ts +++ b/src/api/sales/opportunities/[id]/notes/update.ts @@ -1,10 +1,10 @@ -import { createRoute } from "../../../modules/api-utils/createRoute"; -import { opportunities } from "../../../managers/opportunities"; -import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../../../managers/opportunities"; +import { apiResponse } from "../../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../../middleware/authorization"; -import GenericError from "../../../Errors/GenericError"; -import { resolveMember } from "../../../modules/cw-utils/members/memberCache"; +import { authMiddleware } from "../../../../middleware/authorization"; +import GenericError from "../../../../../Errors/GenericError"; +import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache"; import { z } from "zod"; /* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */ diff --git a/src/api/sales/[id]/addProduct.ts b/src/api/sales/opportunities/[id]/products/add.ts similarity index 85% rename from src/api/sales/[id]/addProduct.ts rename to src/api/sales/opportunities/[id]/products/add.ts index 37d1934..6915ec2 100644 --- a/src/api/sales/[id]/addProduct.ts +++ b/src/api/sales/opportunities/[id]/products/add.ts @@ -1,9 +1,9 @@ -import { createRoute } from "../../../modules/api-utils/createRoute"; -import { opportunities } from "../../../managers/opportunities"; -import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../../../managers/opportunities"; +import { apiResponse } from "../../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../../middleware/authorization"; -import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions"; +import { authMiddleware } from "../../../../middleware/authorization"; +import { processObjectValuePerms } from "../../../../../modules/permission-utils/processObjectPermissions"; import { z } from "zod"; const productItemSchema = z diff --git a/src/api/sales/[id]/addLabor.ts b/src/api/sales/opportunities/[id]/products/addLabor.ts similarity index 92% rename from src/api/sales/[id]/addLabor.ts rename to src/api/sales/opportunities/[id]/products/addLabor.ts index 459b62b..a715472 100644 --- a/src/api/sales/[id]/addLabor.ts +++ b/src/api/sales/opportunities/[id]/products/addLabor.ts @@ -1,9 +1,9 @@ -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 { 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 { authMiddleware } from "../../../../middleware/authorization"; import { z } from "zod"; const LABOR_DEFAULT_RATE = { diff --git a/src/api/sales/[id]/addSpecialOrderProduct.ts b/src/api/sales/opportunities/[id]/products/addSpecialOrder.ts similarity index 91% rename from src/api/sales/[id]/addSpecialOrderProduct.ts rename to src/api/sales/opportunities/[id]/products/addSpecialOrder.ts index 264300b..1605fcc 100644 --- a/src/api/sales/[id]/addSpecialOrderProduct.ts +++ b/src/api/sales/opportunities/[id]/products/addSpecialOrder.ts @@ -1,9 +1,9 @@ -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 { 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 { authMiddleware } from "../../../../middleware/authorization"; import { z } from "zod"; const specialOrderItemSchema = z diff --git a/src/api/sales/[id]/cancelProduct.ts b/src/api/sales/opportunities/[id]/products/cancel.ts similarity index 83% rename from src/api/sales/[id]/cancelProduct.ts rename to src/api/sales/opportunities/[id]/products/cancel.ts index 737d275..216e5aa 100644 --- a/src/api/sales/[id]/cancelProduct.ts +++ b/src/api/sales/opportunities/[id]/products/cancel.ts @@ -1,9 +1,9 @@ -import { createRoute } from "../../../modules/api-utils/createRoute"; -import { opportunities } from "../../../managers/opportunities"; -import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../../../managers/opportunities"; +import { apiResponse } from "../../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../../middleware/authorization"; -import GenericError from "../../../Errors/GenericError"; +import { authMiddleware } from "../../../../middleware/authorization"; +import GenericError from "../../../../../Errors/GenericError"; import { z } from "zod"; const cancelProductSchema = z @@ -59,7 +59,9 @@ export default createRoute( }); const refreshedProducts = await opportunity.fetchProducts({ fresh: true }); - const updated = refreshedProducts.find((item) => item.cwForecastId === productId); + const updated = refreshedProducts.find( + (item) => item.cwForecastId === productId, + ); if (!updated) { throw new GenericError({ diff --git a/src/api/sales/[id]/products.ts b/src/api/sales/opportunities/[id]/products/fetchAll.ts similarity index 69% rename from src/api/sales/[id]/products.ts rename to src/api/sales/opportunities/[id]/products/fetchAll.ts index b40917f..2c0caa8 100644 --- a/src/api/sales/[id]/products.ts +++ b/src/api/sales/opportunities/[id]/products/fetchAll.ts @@ -1,8 +1,8 @@ -import { createRoute } from "../../../modules/api-utils/createRoute"; -import { opportunities } from "../../../managers/opportunities"; -import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../../../managers/opportunities"; +import { apiResponse } from "../../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../../middleware/authorization"; +import { authMiddleware } from "../../../../middleware/authorization"; /* GET /v1/sales/opportunities/:identifier/products */ export default createRoute( diff --git a/src/api/sales/[id]/laborOptions.ts b/src/api/sales/opportunities/[id]/products/laborOptions.ts similarity index 79% rename from src/api/sales/[id]/laborOptions.ts rename to src/api/sales/opportunities/[id]/products/laborOptions.ts index 4b11293..8f71cdb 100644 --- a/src/api/sales/[id]/laborOptions.ts +++ b/src/api/sales/opportunities/[id]/products/laborOptions.ts @@ -1,9 +1,9 @@ -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 { 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 { authMiddleware } from "../../../../middleware/authorization"; /* GET /v1/sales/opportunities/:identifier/products/labor/options */ export default createRoute( diff --git a/src/api/sales/[id]/resequenceProducts.ts b/src/api/sales/opportunities/[id]/products/resequence.ts similarity index 77% rename from src/api/sales/[id]/resequenceProducts.ts rename to src/api/sales/opportunities/[id]/products/resequence.ts index 64cb15e..f45347f 100644 --- a/src/api/sales/[id]/resequenceProducts.ts +++ b/src/api/sales/opportunities/[id]/products/resequence.ts @@ -1,8 +1,8 @@ -import { createRoute } from "../../../modules/api-utils/createRoute"; -import { opportunities } from "../../../managers/opportunities"; -import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../../../managers/opportunities"; +import { apiResponse } from "../../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../../middleware/authorization"; +import { authMiddleware } from "../../../../middleware/authorization"; import { z } from "zod"; /* PATCH /v1/sales/opportunities/:identifier/products/sequence */ diff --git a/src/api/sales/[id]/updateProduct.ts b/src/api/sales/opportunities/[id]/products/update.ts similarity index 78% rename from src/api/sales/[id]/updateProduct.ts rename to src/api/sales/opportunities/[id]/products/update.ts index 02f27df..214947f 100644 --- a/src/api/sales/[id]/updateProduct.ts +++ b/src/api/sales/opportunities/[id]/products/update.ts @@ -1,9 +1,9 @@ -import { createRoute } from "../../../modules/api-utils/createRoute"; -import { opportunities } from "../../../managers/opportunities"; -import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../../../managers/opportunities"; +import { apiResponse } from "../../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../../middleware/authorization"; -import GenericError from "../../../Errors/GenericError"; +import { authMiddleware } from "../../../../middleware/authorization"; +import GenericError from "../../../../../Errors/GenericError"; import { z } from "zod"; const PRODUCT_NARRATIVE_FIELD_ID = 46; @@ -22,9 +22,7 @@ const updateProductSchema = z .strict() .refine( (value) => - Object.values(value).some( - (item) => item !== undefined && item !== null, - ), + Object.values(value).some((item) => item !== undefined && item !== null), "At least one editable field is required", ); @@ -100,7 +98,10 @@ export default createRoute( if (input.quantity !== undefined) { forecastPatch.quantity = input.quantity; } - if (input.customerDescription !== undefined && input.customerDescription !== null) { + if ( + input.customerDescription !== undefined && + input.customerDescription !== null + ) { forecastPatch.customerDescription = input.customerDescription; } if (input.unitPrice !== undefined) { @@ -109,7 +110,9 @@ export default createRoute( ); } if (input.unitCost !== undefined) { - forecastPatch.cost = Number((input.unitCost * effectiveQuantity).toFixed(2)); + forecastPatch.cost = Number( + (input.unitCost * effectiveQuantity).toFixed(2), + ); } const existingProcurement = @@ -155,7 +158,10 @@ export default createRoute( : []; let updatedFields = existingFields as Array>; - if (input.procurementNotes !== undefined && input.procurementNotes !== null) { + if ( + input.procurementNotes !== undefined && + input.procurementNotes !== null + ) { updatedFields = upsertCustomTextField( updatedFields, PROCUREMENT_NOTES_FIELD_ID, @@ -163,7 +169,10 @@ export default createRoute( input.procurementNotes, ); } - if (input.productNarrative !== undefined && input.productNarrative !== null) { + if ( + input.productNarrative !== undefined && + input.productNarrative !== null + ) { updatedFields = upsertCustomTextField( updatedFields, PRODUCT_NARRATIVE_FIELD_ID, @@ -199,33 +208,33 @@ export default createRoute( ? updatedProcurement.customFields : []; const procurementNotes = - updatedFields.find((field: any) => field?.id === PROCUREMENT_NOTES_FIELD_ID) - ?.value ?? null; + 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; + updatedFields.find( + (field: any) => field?.id === PRODUCT_NARRATIVE_FIELD_ID, + )?.value ?? null; - const quantity = updatedProcurement?.quantity ?? updatedForecast.quantity ?? null; + const quantity = + updatedProcurement?.quantity ?? updatedForecast.quantity ?? null; const unitPrice = updatedProcurement?.price ?? null; const 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, - }, - ); + 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); }, diff --git a/src/api/sales/opportunities/[id]/quotes/commit.ts b/src/api/sales/opportunities/[id]/quotes/commit.ts new file mode 100644 index 0000000..da903b5 --- /dev/null +++ b/src/api/sales/opportunities/[id]/quotes/commit.ts @@ -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"] }), +); diff --git a/src/api/sales/opportunities/[id]/quotes/download.ts b/src/api/sales/opportunities/[id]/quotes/download.ts new file mode 100644 index 0000000..c1283d8 --- /dev/null +++ b/src/api/sales/opportunities/[id]/quotes/download.ts @@ -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"] }), +); diff --git a/src/api/sales/opportunities/[id]/quotes/fetchAll.ts b/src/api/sales/opportunities/[id]/quotes/fetchAll.ts new file mode 100644 index 0000000..5549a77 --- /dev/null +++ b/src/api/sales/opportunities/[id]/quotes/fetchAll.ts @@ -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"] }), +); diff --git a/src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts b/src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts new file mode 100644 index 0000000..e0fd706 --- /dev/null +++ b/src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts @@ -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"] }), +); diff --git a/src/api/sales/opportunities/[id]/quotes/preview.ts b/src/api/sales/opportunities/[id]/quotes/preview.ts new file mode 100644 index 0000000..9312dbe --- /dev/null +++ b/src/api/sales/opportunities/[id]/quotes/preview.ts @@ -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) + : {}; + + const options = + regenData.options && typeof regenData.options === "object" + ? (regenData.options as Record) + : {}; + + 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"] }), +); diff --git a/src/api/sales/[id]/refresh.ts b/src/api/sales/opportunities/[id]/refresh.ts similarity index 70% rename from src/api/sales/[id]/refresh.ts rename to src/api/sales/opportunities/[id]/refresh.ts index b29fab0..6caecad 100644 --- a/src/api/sales/[id]/refresh.ts +++ b/src/api/sales/opportunities/[id]/refresh.ts @@ -1,8 +1,8 @@ -import { createRoute } from "../../../modules/api-utils/createRoute"; -import { opportunities } from "../../../managers/opportunities"; -import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../../managers/opportunities"; +import { apiResponse } from "../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../../middleware/authorization"; +import { authMiddleware } from "../../../middleware/authorization"; /* POST /v1/sales/opportunities/:identifier/refresh */ export default createRoute( diff --git a/src/api/sales/count.ts b/src/api/sales/opportunities/count.ts similarity index 67% rename from src/api/sales/count.ts rename to src/api/sales/opportunities/count.ts index 5437c33..2e73147 100644 --- a/src/api/sales/count.ts +++ b/src/api/sales/opportunities/count.ts @@ -1,8 +1,8 @@ -import { createRoute } from "../../modules/api-utils/createRoute"; -import { opportunities } from "../../managers/opportunities"; -import { apiResponse } from "../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../managers/opportunities"; +import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../middleware/authorization"; +import { authMiddleware } from "../../middleware/authorization"; /* GET /v1/sales/opportunities/count */ export default createRoute( diff --git a/src/api/sales/fetchAll.ts b/src/api/sales/opportunities/fetchAll.ts similarity index 80% rename from src/api/sales/fetchAll.ts rename to src/api/sales/opportunities/fetchAll.ts index 39b7b6f..d7f8ef5 100644 --- a/src/api/sales/fetchAll.ts +++ b/src/api/sales/opportunities/fetchAll.ts @@ -1,9 +1,9 @@ -import { createRoute } from "../../modules/api-utils/createRoute"; -import { opportunities } from "../../managers/opportunities"; -import { apiResponse } from "../../modules/api-utils/apiResponse"; +import { createRoute } from "../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../managers/opportunities"; +import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; -import { authMiddleware } from "../middleware/authorization"; -import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions"; +import { authMiddleware } from "../../middleware/authorization"; +import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions"; /* GET /v1/sales/opportunities */ export default createRoute( diff --git a/src/api/sockets/events/liveQuotePreview.ts b/src/api/sockets/events/liveQuotePreview.ts new file mode 100644 index 0000000..924c2ec --- /dev/null +++ b/src/api/sockets/events/liveQuotePreview.ts @@ -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(); + + 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, + }); + }, + ); +}; diff --git a/src/api/sockets/index.ts b/src/api/sockets/index.ts new file mode 100644 index 0000000..5e35486 --- /dev/null +++ b/src/api/sockets/index.ts @@ -0,0 +1,5 @@ +import { setupSecureNamespace } from "./secure"; + +export const setupSockets = () => { + setupSecureNamespace(); +}; diff --git a/src/api/sockets/middleware/authorization.ts b/src/api/sockets/middleware/authorization.ts new file mode 100644 index 0000000..7d52221 --- /dev/null +++ b/src/api/sockets/middleware/authorization.ts @@ -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, +): 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; +}) => { + 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(); + }; +}; diff --git a/src/api/sockets/secure.ts b/src/api/sockets/secure.ts new file mode 100644 index 0000000..fbef09b --- /dev/null +++ b/src/api/sockets/secure.ts @@ -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; + headers?: Record; +}): 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 | 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; +}; diff --git a/src/controllers/ForecastProductController.ts b/src/controllers/ForecastProductController.ts index d9908dc..25291d8 100644 --- a/src/controllers/ForecastProductController.ts +++ b/src/controllers/ForecastProductController.ts @@ -23,6 +23,8 @@ export class ForecastProductController { public catalogItemIdentifier: string | null; public productDescription: string; + public customerDescription: string | null; + public productNarrative: string | null; public productClass: string; public forecastType: string; @@ -74,6 +76,9 @@ export class ForecastProductController { this.catalogItemIdentifier = data.catalogItem?.identifier ?? null; this.productDescription = data.productDescription; + this.customerDescription = data.customerDescription ?? null; + this.productNarrative = + data.customFields?.find((f) => f.id === 46)?.value?.toString() ?? null; this.productClass = data.productClass; this.forecastType = data.forecastType; @@ -118,6 +123,24 @@ export class ForecastProductController { * Enriches this forecast product with cancellation data from the * procurement products endpoint. */ + /** + * Apply Procurement Custom Fields + * + * Enriches this forecast product with custom field data from the + * procurement products endpoint (the forecast endpoint does not + * return customFields). + */ + public applyProcurementCustomFields(data: { + customFields?: Array<{ id: number; value?: unknown }>; + }): void { + const narrative = data.customFields + ?.find((f) => f.id === 46) + ?.value?.toString(); + if (narrative) { + this.productNarrative = narrative; + } + } + public applyCancellationData(data: { cancelledFlag?: boolean; quantityCancelled?: number; @@ -154,6 +177,38 @@ export class ForecastProductController { return this.revenue - this.cost; } + /** + * Effective Quantity + * + * Returns the quantity adjusted for cancellations (minimum 0). + */ + public get effectiveQuantity(): number { + if (this.cancellationType === "full") return 0; + return Math.max(0, this.quantity - this.quantityCancelled); + } + + /** + * Effective Revenue + * + * Returns the revenue adjusted proportionally for cancelled units. + */ + public get effectiveRevenue(): number { + if (this.cancellationType === "full" || this.quantity <= 0) return 0; + const unitPrice = this.revenue / this.quantity; + return unitPrice * this.effectiveQuantity; + } + + /** + * Effective Cost + * + * Returns the cost adjusted proportionally for cancelled units. + */ + public get effectiveCost(): number { + if (this.cancellationType === "full" || this.quantity <= 0) return 0; + const unitCost = this.cost / this.quantity; + return unitCost * this.effectiveQuantity; + } + /** * Cancelled * @@ -201,12 +256,17 @@ export class ForecastProductController { ? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier } : null, productDescription: this.productDescription, + customerDescription: this.customerDescription, + productNarrative: this.productNarrative, productClass: this.productClass, forecastType: this.forecastType, revenue: this.revenue, cost: this.cost, margin: this.margin, profit: this.profit, + effectiveQuantity: this.effectiveQuantity, + effectiveRevenue: this.effectiveRevenue, + effectiveCost: this.effectiveCost, percentage: this.percentage, includeFlag: this.includeFlag, linkFlag: this.linkFlag, diff --git a/src/controllers/GeneratedQuoteController.ts b/src/controllers/GeneratedQuoteController.ts new file mode 100644 index 0000000..1b44842 --- /dev/null +++ b/src/controllers/GeneratedQuoteController.ts @@ -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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/src/controllers/OpportunityController.ts b/src/controllers/OpportunityController.ts index 992cadb..f74ae65 100644 --- a/src/controllers/OpportunityController.ts +++ b/src/controllers/OpportunityController.ts @@ -20,11 +20,13 @@ import { import { resolveMember, resolveMembers, + getMemberCache, } from "../modules/cw-utils/members/memberCache"; import { ForecastProductController } from "./ForecastProductController"; import GenericError from "../Errors/GenericError"; import { computeSubResourceCacheTTL } from "../modules/algorithms/computeSubResourceCacheTTL"; import { computeProductsCacheTTL } from "../modules/algorithms/computeProductsCacheTTL"; +import UserController from "./UserController"; import { getCachedNotes, getCachedContacts, @@ -37,6 +39,11 @@ import { invalidateNotesCache, invalidateProductsCache, } from "../modules/cache/opportunityCache"; +import { + generateQuote as generateQuotePdf, + type QuoteMetadata, +} from "../modules/pdf-utils"; +import { generatedQuotes } from "../managers/generatedQuotes"; /** * Opportunity Controller @@ -81,6 +88,7 @@ export class OpportunityController { public customerPO: string | null; public totalSalesTax: number; + public probability: number; public locationName: string | 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( data: Opportunity & { company?: Company | null }, opts?: { @@ -174,6 +205,7 @@ export class OpportunityController { this.customerPO = data.customerPO; this.totalSalesTax = data.totalSalesTax; + this.probability = data.probability; this.locationName = data.locationName; this.locationCwId = data.locationCwId; @@ -203,6 +235,18 @@ export class OpportunityController { this._activities = opts?.activities ?? null; } + /** + * Hydrate Custom Fields + * + * Lazily fetches the opportunity's custom fields from ConnectWise + * if they haven't been loaded yet. + */ + private async _hydrateCustomFields(): Promise { + if (this._customFields !== null) return; + const cwData = await fetchOpportunity(this.cwOpportunityId); + this._customFields = cwData.customFields ?? []; + } + /** * Fetch Company * @@ -297,6 +341,7 @@ export class OpportunityController { customerPO: item.customerPO ?? null, totalSalesTax: item.totalSalesTax ?? 0, + probability: Number(item.probability?.name) || 0, locationName: item.location?.name ?? null, locationCwId: item.location?.id ?? null, @@ -536,6 +581,372 @@ export class OpportunityController { return this._buildProductControllers(forecast, procProducts); } + /** + * Generate Quote PDF + * + * Builds a customer-facing quote PDF using the opportunity, company, site, + * and product data available to this controller. + */ + public async generateQuote(opts?: { + lineItemPricing?: boolean; + includeQuoteNarrative?: boolean; + includeItemNarratives?: boolean; + showPreview?: boolean; // INTERNAL ONLY + logoPath?: string; + metadata?: QuoteMetadata; + }): Promise { + 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. * @@ -593,6 +1004,7 @@ export class OpportunityController { const procData = cancellationMap.get(item.id); if (procData) { ctrl.applyCancellationData(procData as any); + ctrl.applyProcurementCustomFields(procData as any); } return ctrl; }, @@ -1115,6 +1527,7 @@ export class OpportunityController { : null, customerPO: this.customerPO, totalSalesTax: this.totalSalesTax, + probability: this.probability, location: this.locationCwId ? { id: this.locationCwId, name: this.locationName } : null, diff --git a/src/index.ts b/src/index.ts index 58c3f20..f2c8f31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { refresh } from "./api/auth"; import app from "./api/server"; +import { setupSockets } from "./api/sockets"; import { engine, PORT, @@ -68,6 +69,9 @@ Bun.serve({ console.log(`[startup] Server listening on port ${PORT}`); +setupSockets(); +console.log("[startup] Socket namespaces initialized"); + // --------------------------------------------------------------------------- // Background initialisation — none of this blocks the server. // --------------------------------------------------------------------------- diff --git a/src/managers/generatedQuotes.ts b/src/managers/generatedQuotes.ts new file mode 100644 index 0000000..ade9603 --- /dev/null +++ b/src/managers/generatedQuotes.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + }, +}; diff --git a/src/managers/opportunities.ts b/src/managers/opportunities.ts index 46783bc..cc9e8bf 100644 --- a/src/managers/opportunities.ts +++ b/src/managers/opportunities.ts @@ -377,7 +377,7 @@ export const opportunities = { include: { company: true }, skip, take: rpp, - orderBy: { expectedCloseDate: "asc" }, + orderBy: { createdAt: "desc" }, }); return Promise.all( diff --git a/src/modules/cw-utils/opportunities/opportunities.ts b/src/modules/cw-utils/opportunities/opportunities.ts index 59f2ccc..94d509a 100644 --- a/src/modules/cw-utils/opportunities/opportunities.ts +++ b/src/modules/cw-utils/opportunities/opportunities.ts @@ -354,7 +354,7 @@ export const opportunityCw = { opportunityId: number, ): Promise[]> => { 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; }, diff --git a/src/modules/cw-utils/opportunities/opportunity.types.ts b/src/modules/cw-utils/opportunities/opportunity.types.ts index c719b5c..b17f9f8 100644 --- a/src/modules/cw-utils/opportunities/opportunity.types.ts +++ b/src/modules/cw-utils/opportunities/opportunity.types.ts @@ -68,6 +68,7 @@ export interface CWOpportunity { closedDate: string; closedBy: CWMemberReference; totalSalesTax: number; + probability: CWReference; shipToCompany: CWCompanyReference; shipToContact: CWContactReference; shipToSite: CWSiteReference; diff --git a/src/modules/cw-utils/opportunities/processOpportunityResponse.ts b/src/modules/cw-utils/opportunities/processOpportunityResponse.ts index 67ff098..c0b51b8 100644 --- a/src/modules/cw-utils/opportunities/processOpportunityResponse.ts +++ b/src/modules/cw-utils/opportunities/processOpportunityResponse.ts @@ -14,6 +14,7 @@ export const processOpportunityResponse = (opportunity: CWOpportunity) => ({ expectedCloseDate: opportunity.expectedCloseDate, closedDate: opportunity.closedDate, closedFlag: opportunity.closedFlag, + probability: Number(opportunity.probability?.name) || 0, type: opportunity.type ? { id: opportunity.type.id, name: opportunity.type.name } : null, diff --git a/src/modules/pdf-utils/generateQuote.ts b/src/modules/pdf-utils/generateQuote.ts new file mode 100644 index 0000000..50b5302 --- /dev/null +++ b/src/modules/pdf-utils/generateQuote.ts @@ -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 = {}, + logoPath = DEFAULT_LOGO_PATH, +): Promise { + 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[][] = []; + 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 = 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((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); + } + }); +} diff --git a/src/modules/pdf-utils/index.ts b/src/modules/pdf-utils/index.ts new file mode 100644 index 0000000..0e04998 --- /dev/null +++ b/src/modules/pdf-utils/index.ts @@ -0,0 +1,2 @@ +export * from "./generateQuote"; +export * from "./injectPdfMetadata"; diff --git a/src/modules/pdf-utils/injectPdfMetadata.ts b/src/modules/pdf-utils/injectPdfMetadata.ts new file mode 100644 index 0000000..b2942d4 --- /dev/null +++ b/src/modules/pdf-utils/injectPdfMetadata.ts @@ -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 { + 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(); +} diff --git a/src/types/PermissionNodes.ts b/src/types/PermissionNodes.ts index 74ecb9a..ac5f55a 100644 --- a/src/types/PermissionNodes.ts +++ b/src/types/PermissionNodes.ts @@ -399,11 +399,12 @@ export const PERMISSION_NODES = { description: "Fetch a single opportunity and its sub-resources (products, notes, contacts)", usedIn: [ - "src/api/sales/[id]/fetch.ts", - "src/api/sales/[id]/products.ts", - "src/api/sales/[id]/notes.ts", - "src/api/sales/[id]/fetchNote.ts", - "src/api/sales/[id]/contacts.ts", + "src/api/sales/opportunities/[id]/fetch.ts", + "src/api/sales/opportunities/[id]/products/fetchAll.ts", + "src/api/sales/opportunities/[id]/notes/fetchAll.ts", + "src/api/sales/opportunities/[id]/notes/fetch.ts", + "src/api/sales/opportunities/[id]/contacts.ts", + "src/api/sockets/events/liveQuotePreview.ts", ], }, { @@ -411,33 +412,33 @@ export const PERMISSION_NODES = { description: "Fetch multiple opportunities, count, or opportunity types", usedIn: [ - "src/api/sales/fetchAll.ts", - "src/api/sales/count.ts", + "src/api/sales/opportunities/fetchAll.ts", + "src/api/sales/opportunities/count.ts", "src/api/sales/fetchOpportunityTypes.ts", ], }, { node: "sales.opportunity.refresh", description: "Refresh a single opportunity from ConnectWise", - usedIn: ["src/api/sales/[id]/refresh.ts"], + usedIn: ["src/api/sales/opportunities/[id]/refresh.ts"], dependencies: ["sales.opportunity.fetch"], }, { node: "sales.opportunity.note.create", description: "Create a new note on an opportunity", - usedIn: ["src/api/sales/[id]/createNote.ts"], + usedIn: ["src/api/sales/opportunities/[id]/notes/create.ts"], dependencies: ["sales.opportunity.fetch"], }, { node: "sales.opportunity.note.update", description: "Update an existing note on an opportunity", - usedIn: ["src/api/sales/[id]/updateNote.ts"], + usedIn: ["src/api/sales/opportunities/[id]/notes/update.ts"], dependencies: ["sales.opportunity.fetch"], }, { node: "sales.opportunity.note.delete", description: "Delete a note from an opportunity", - usedIn: ["src/api/sales/[id]/deleteNote.ts"], + usedIn: ["src/api/sales/opportunities/[id]/notes/delete.ts"], dependencies: ["sales.opportunity.fetch"], }, { @@ -445,9 +446,9 @@ export const PERMISSION_NODES = { description: "Update products (forecast items) on an opportunity, including resequencing", usedIn: [ - "src/api/sales/[id]/resequenceProducts.ts", - "src/api/sales/[id]/updateProduct.ts", - "src/api/sales/[id]/cancelProduct.ts", + "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"], }, @@ -455,7 +456,7 @@ export const PERMISSION_NODES = { node: "sales.opportunity.product.add", description: "Add a new product (forecast item) to an opportunity. Individual fields are gated by sales.opportunity.product.field. permissions.", - usedIn: ["src/api/sales/[id]/addProduct.ts"], + usedIn: ["src/api/sales/opportunities/[id]/products/add.ts"], dependencies: ["sales.opportunity.fetch"], fieldLevelPermissions: [ "sales.opportunity.product.field.catalogItem", @@ -481,7 +482,9 @@ export const PERMISSION_NODES = { node: "sales.opportunity.product.add.specialOrder", description: 'Add one or more "SPECIAL ORDER" products to an opportunity via the dedicated special-order route.', - usedIn: ["src/api/sales/[id]/addSpecialOrderProduct.ts"], + usedIn: [ + "src/api/sales/opportunities/[id]/products/addSpecialOrder.ts", + ], dependencies: ["sales.opportunity.fetch"], }, { @@ -489,11 +492,45 @@ export const PERMISSION_NODES = { description: "Add labor products to an opportunity using the dedicated labor route with Field/Tech catalog selection and pricing inputs.", usedIn: [ - "src/api/sales/[id]/addLabor.ts", - "src/api/sales/[id]/laborOptions.ts", + "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"], + }, ], }, @@ -954,6 +991,7 @@ export const PERMISSION_NODES = { "obj.opportunity.site", "obj.opportunity.customerPO", "obj.opportunity.totalSalesTax", + "obj.opportunity.probability", "obj.opportunity.location", "obj.opportunity.department", "obj.opportunity.expectedCloseDate", diff --git a/tests/setup.ts b/tests/setup.ts index e49e3a9..92f14b5 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -368,6 +368,23 @@ export function buildMockCWForecastItem(overrides: Record = {}) { }; } +/** Build a minimal Prisma-shaped GeneratedQuotes row. */ +export function buildMockGeneratedQuote(overrides: Record = {}) { + return { + id: "quote-1", + quoteRegenData: { theme: "default" }, + quoteFile: new Uint8Array([0x25, 0x50, 0x44, 0x46]), + quoteFileName: "Quote-TestOpp.pdf", + opportunityId: "opp-1", + createdById: "user-1", + createdAt: new Date("2026-03-01"), + updatedAt: new Date("2026-03-01"), + opportunity: null, + createdBy: null, + ...overrides, + }; +} + /** Build a minimal Prisma-shaped CatalogItem row. */ export function buildMockCatalogItem(overrides: Record = {}) { return { diff --git a/tests/unit/controllers/CatalogItemController.test.ts b/tests/unit/controllers/CatalogItemController.test.ts new file mode 100644 index 0000000..8754a29 --- /dev/null +++ b/tests/unit/controllers/CatalogItemController.test.ts @@ -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(); + }); + }); +}); diff --git a/tests/unit/controllers/ForecastProductController.test.ts b/tests/unit/controllers/ForecastProductController.test.ts index 0aa1cae..0899c94 100644 --- a/tests/unit/controllers/ForecastProductController.test.ts +++ b/tests/unit/controllers/ForecastProductController.test.ts @@ -138,6 +138,40 @@ describe("ForecastProductController", () => { }); }); + // ------------------------------------------------------------------- + // applyProcurementCustomFields + // ------------------------------------------------------------------- + describe("applyProcurementCustomFields()", () => { + test("sets productNarrative from custom field id 46", () => { + const ctrl = new ForecastProductController(buildMockCWForecastItem()); + ctrl.applyProcurementCustomFields({ + customFields: [{ id: 46, value: "Custom narrative text" }], + }); + expect(ctrl.productNarrative).toBe("Custom narrative text"); + }); + + test("does not overwrite productNarrative when field 46 is missing", () => { + const ctrl = new ForecastProductController(buildMockCWForecastItem()); + ctrl.productNarrative = "existing"; + ctrl.applyProcurementCustomFields({ + customFields: [{ id: 99, value: "other" }], + }); + expect(ctrl.productNarrative).toBe("existing"); + }); + + test("handles empty customFields array", () => { + const ctrl = new ForecastProductController(buildMockCWForecastItem()); + ctrl.applyProcurementCustomFields({ customFields: [] }); + expect(ctrl.productNarrative).toBeNull(); + }); + + test("handles undefined customFields", () => { + const ctrl = new ForecastProductController(buildMockCWForecastItem()); + ctrl.applyProcurementCustomFields({}); + expect(ctrl.productNarrative).toBeNull(); + }); + }); + // ------------------------------------------------------------------- // applyInventoryData // ------------------------------------------------------------------- @@ -203,6 +237,67 @@ describe("ForecastProductController", () => { }); expect(ctrl.cancellationType).toBe("partial"); }); + + test("effectiveQuantity returns full quantity when not cancelled", () => { + const ctrl = new ForecastProductController( + buildMockCWForecastItem({ quantity: 5 }), + ); + expect(ctrl.effectiveQuantity).toBe(5); + }); + + test("effectiveQuantity returns reduced quantity for partial cancel", () => { + const ctrl = new ForecastProductController( + buildMockCWForecastItem({ quantity: 5 }), + ); + ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 2 }); + expect(ctrl.effectiveQuantity).toBe(3); + }); + + test("effectiveQuantity returns 0 for full cancellation", () => { + const ctrl = new ForecastProductController( + buildMockCWForecastItem({ quantity: 5 }), + ); + ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 5 }); + expect(ctrl.effectiveQuantity).toBe(0); + }); + + test("effectiveRevenue returns full revenue when not cancelled", () => { + const ctrl = new ForecastProductController( + buildMockCWForecastItem({ quantity: 5, revenue: 2500 }), + ); + expect(ctrl.effectiveRevenue).toBe(2500); + }); + + test("effectiveRevenue returns proportional revenue for partial cancel", () => { + const ctrl = new ForecastProductController( + buildMockCWForecastItem({ quantity: 5, revenue: 2500 }), + ); + ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 2 }); + expect(ctrl.effectiveRevenue).toBe(1500); + }); + + test("effectiveRevenue returns 0 for full cancellation", () => { + const ctrl = new ForecastProductController( + buildMockCWForecastItem({ quantity: 5, revenue: 2500 }), + ); + ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 5 }); + expect(ctrl.effectiveRevenue).toBe(0); + }); + + test("effectiveCost returns full cost when not cancelled", () => { + const ctrl = new ForecastProductController( + buildMockCWForecastItem({ quantity: 5, cost: 1800 }), + ); + expect(ctrl.effectiveCost).toBe(1800); + }); + + test("effectiveCost returns 0 for full cancellation", () => { + const ctrl = new ForecastProductController( + buildMockCWForecastItem({ quantity: 5, cost: 1800 }), + ); + ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 5 }); + expect(ctrl.effectiveCost).toBe(0); + }); }); // ------------------------------------------------------------------- @@ -279,5 +374,24 @@ describe("ForecastProductController", () => { expect(json.subNumber).toBe(0); expect(json.cwLastUpdated).toBeInstanceOf(Date); }); + + test("includes customerDescription and productNarrative", () => { + const ctrl = new ForecastProductController( + buildMockCWForecastItem({ customerDescription: "Customer desc" }), + ); + const json = ctrl.toJson(); + expect(json.customerDescription).toBe("Customer desc"); + expect(json.productNarrative).toBeNull(); + }); + + test("includes effective* computed fields", () => { + const ctrl = new ForecastProductController( + buildMockCWForecastItem({ quantity: 5, revenue: 2500, cost: 1800 }), + ); + const json = ctrl.toJson(); + expect(json.effectiveQuantity).toBe(5); + expect(json.effectiveRevenue).toBe(2500); + expect(json.effectiveCost).toBe(1800); + }); }); }); diff --git a/tests/unit/controllers/GeneratedQuoteController.test.ts b/tests/unit/controllers/GeneratedQuoteController.test.ts new file mode 100644 index 0000000..9530cb8 --- /dev/null +++ b/tests/unit/controllers/GeneratedQuoteController.test.ts @@ -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"); + }); + }); +}); diff --git a/tests/unit/cwCallback.test.ts b/tests/unit/cwCallback.test.ts new file mode 100644 index 0000000..6763ab6 --- /dev/null +++ b/tests/unit/cwCallback.test.ts @@ -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).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).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(); + }); +}); diff --git a/tests/unit/cwConcurrencyLimiter.test.ts b/tests/unit/cwConcurrencyLimiter.test.ts new file mode 100644 index 0000000..bced62f --- /dev/null +++ b/tests/unit/cwConcurrencyLimiter.test.ts @@ -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); + }); +}); diff --git a/tests/unit/generatedQuotesManager.test.ts b/tests/unit/generatedQuotesManager.test.ts new file mode 100644 index 0000000..dc952b9 --- /dev/null +++ b/tests/unit/generatedQuotesManager.test.ts @@ -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> = {}, +) { + 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(); + }); + }); +}); diff --git a/tests/unit/permissionNodes.test.ts b/tests/unit/permissionNodes.test.ts index 80f1013..b6326a9 100644 --- a/tests/unit/permissionNodes.test.ts +++ b/tests/unit/permissionNodes.test.ts @@ -32,9 +32,17 @@ describe("PermissionNodes", () => { expect(PERMISSION_NODES).toHaveProperty("global"); expect(PERMISSION_NODES).toHaveProperty("company"); expect(PERMISSION_NODES).toHaveProperty("credential"); + expect(PERMISSION_NODES).toHaveProperty("credentialType"); expect(PERMISSION_NODES).toHaveProperty("sales"); expect(PERMISSION_NODES).toHaveProperty("procurement"); expect(PERMISSION_NODES).toHaveProperty("objectTypes"); + expect(PERMISSION_NODES).toHaveProperty("permission"); + expect(PERMISSION_NODES).toHaveProperty("role"); + expect(PERMISSION_NODES).toHaveProperty("user"); + expect(PERMISSION_NODES).toHaveProperty("uiNavigation"); + expect(PERMISSION_NODES).toHaveProperty("adminUI"); + expect(PERMISSION_NODES).toHaveProperty("cwCallbacks"); + expect(PERMISSION_NODES).toHaveProperty("unifi"); }); test("each category has name, description, and permissions", () => { diff --git a/tests/unit/procurement.test.ts b/tests/unit/procurement.test.ts index aa9224a..54a8fd3 100644 --- a/tests/unit/procurement.test.ts +++ b/tests/unit/procurement.test.ts @@ -61,6 +61,7 @@ describe("procurement manager", () => { expect(typeof procurement.fetchDistinctValues).toBe("function"); expect(typeof procurement.linkItems).toBe("function"); expect(typeof procurement.unlinkItems).toBe("function"); + expect(typeof procurement.fetchLaborCatalogItems).toBe("function"); }); test("fetchPages calls through without errors (mock absorbs)", async () => { diff --git a/tests/unit/secureValues.test.ts b/tests/unit/secureValues.test.ts new file mode 100644 index 0000000..237da66 --- /dev/null +++ b/tests/unit/secureValues.test.ts @@ -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); + } + }); +}); diff --git a/tests/unit/signPermissions.test.ts b/tests/unit/signPermissions.test.ts new file mode 100644 index 0000000..9d27b67 --- /dev/null +++ b/tests/unit/signPermissions.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/withCwRetry.test.ts b/tests/unit/withCwRetry.test.ts new file mode 100644 index 0000000..e50bb6c --- /dev/null +++ b/tests/unit/withCwRetry.test.ts @@ -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); + }); +});