From e764932c39328fe34dbbc8f6434980447f21d7da Mon Sep 17 00:00:00 2001 From: Jackson Roberts Date: Sun, 15 Mar 2026 23:38:56 -0500 Subject: [PATCH] feat: expand sales opportunity workflow and metrics APIs --- API_ROUTES.md | 276 ++- CACHING.md | 36 + PERMISSIONS.md | 69 +- generated/prisma/internal/class.ts | 4 +- generated/prisma/internal/prismaNamespace.ts | 1 + .../prisma/internal/prismaNamespaceBrowser.ts | 1 + generated/prisma/models/Opportunity.ts | 41 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + src/api/sales/[id]/fetch.ts | 4 +- src/api/sales/index.ts | 6 + src/api/sales/opportunities/[id]/contacts.ts | 4 +- src/api/sales/opportunities/[id]/delete.ts | 4 +- src/api/sales/opportunities/[id]/fetch.ts | 4 +- .../sales/opportunities/[id]/notes/create.ts | 4 +- .../sales/opportunities/[id]/notes/delete.ts | 4 +- .../sales/opportunities/[id]/notes/fetch.ts | 4 +- .../opportunities/[id]/notes/fetchAll.ts | 4 +- .../sales/opportunities/[id]/notes/update.ts | 4 +- .../sales/opportunities/[id]/products/add.ts | 4 +- .../opportunities/[id]/products/addLabor.ts | 4 +- .../[id]/products/addSpecialOrder.ts | 4 +- .../opportunities/[id]/products/cancel.ts | 4 +- .../opportunities/[id]/products/delete.ts | 4 +- .../opportunities/[id]/products/fetchAll.ts | 4 +- .../[id]/products/laborOptions.ts | 4 +- .../opportunities/[id]/products/resequence.ts | 4 +- .../opportunities/[id]/products/update.ts | 4 +- .../sales/opportunities/[id]/quotes/commit.ts | 4 +- .../opportunities/[id]/quotes/download.ts | 4 +- .../opportunities/[id]/quotes/fetchAll.ts | 4 +- .../[id]/quotes/fetchDownloads.ts | 4 +- .../opportunities/[id]/quotes/preview.ts | 4 +- src/api/sales/opportunities/[id]/refresh.ts | 4 +- src/api/sales/opportunities/[id]/update.ts | 4 +- .../opportunities/[id]/workflow/dispatch.ts | 8 +- .../opportunities/[id]/workflow/history.ts | 4 +- .../opportunities/[id]/workflow/status.ts | 51 +- src/api/sales/opportunities/fetchByUser.ts | 25 + src/api/sales/opportunities/fetchByUserId.ts | 25 + src/api/sales/opportunities/metrics.ts | 95 ++ src/controllers/OpportunityController.ts | 44 +- src/index.ts | 17 + src/managers/opportunities.ts | 72 +- .../cache/salesOpportunityMetricsCache.ts | 900 ++++++++++ .../processOpportunityResponse.ts | 3 +- .../opportunities/refreshOpportunities.ts | 21 +- src/modules/globalEvents.ts | 18 + src/modules/pdf-utils/generateQuote.ts | 4 +- src/modules/sales-utils/expectedSalesTax.ts | 98 ++ .../sales-utils/normalizeProbability.ts | 24 + src/modules/sales-utils/salesTaxRates.json | 1501 +++++++++++++++++ src/types/PermissionNodes.ts | 33 + src/types/QuoteStatuses.ts | 21 + src/workflows/wf.opportunity.ts | 81 +- 55 files changed, 3425 insertions(+), 157 deletions(-) create mode 100644 prisma/migrations/20260316031915_add_cw_date_entered/migration.sql create mode 100644 src/api/sales/opportunities/fetchByUser.ts create mode 100644 src/api/sales/opportunities/fetchByUserId.ts create mode 100644 src/api/sales/opportunities/metrics.ts create mode 100644 src/modules/cache/salesOpportunityMetricsCache.ts create mode 100644 src/modules/sales-utils/expectedSalesTax.ts create mode 100644 src/modules/sales-utils/normalizeProbability.ts create mode 100644 src/modules/sales-utils/salesTaxRates.json diff --git a/API_ROUTES.md b/API_ROUTES.md index b9615eb..259afbf 100644 --- a/API_ROUTES.md +++ b/API_ROUTES.md @@ -127,7 +127,7 @@ All fetch and fetchAll endpoints gate response object keys via `processObjectVal | User | `obj.user` | `GET /user/@me`, `GET /user/users/:identifier`, `GET /user/users`, `GET /role/:identifier/users` | | Role | `obj.role` | `GET /role/:identifier`, `GET /role/`, `GET /user/users/:identifier/roles` | | Catalog Item | `obj.catalogItem` | `GET /procurement/items`, `GET /procurement/items/:identifier`, `GET /procurement/items/:identifier/linked` | -| Opportunity | `obj.opportunity` | `GET /sales/opportunities`, `GET /sales/opportunities/:identifier` | +| Opportunity | `obj.opportunity` | `GET /sales/opportunities`, `GET /sales/opportunities/opportunity/:identifier` | | UniFi Site | `obj.unifiSite` | `GET /unifi/sites`, `GET /unifi/site/:id`, `GET /company/companies/:identifier/unifi/sites` | | WiFi Network | `unifi.site.wifi.read` | `GET /unifi/site/:id/wifi` | @@ -2956,7 +2956,11 @@ Fetch the list of all opportunity quote statuses (types). Returns a static list **Authentication Required:** Yes -**Required Permissions:** `sales.opportunity.fetch.many` +**Required Permissions:** + +- Base access: `sales.opportunity.fetch.many` +- `scope=all`: `sales.opportunity.metrics.all` +- `identifier=` (when different from caller's own identifier): `sales.opportunity.metrics.identifier.override` **Response:** @@ -3064,6 +3068,7 @@ Fetch a paginated list of opportunities. Supports search. Each opportunity inclu "site": { "id": 50, "name": "Main Office" }, "customerPO": null, "totalSalesTax": 0, + "expectedSalesTax": 0.06, "probability": 50, "location": { "id": 1, "name": "Murray" }, "department": { "id": 5, "name": "Sales" }, @@ -3161,9 +3166,198 @@ Get the total number of opportunities. --- +### Get My Opportunities + +**GET** `/sales/opportunities/@me` + +Returns all opportunities where the authenticated user is assigned as the primary or secondary sales rep. The user's ConnectWise member identifier is resolved from their account. + +**Authentication Required:** Yes + +**Required Permissions:** `sales.opportunity.fetch.many` + +**Query Parameters:** + +- `includeClosed` _(optional, default `false`)_ — When `true`, includes closed opportunities in the result. + +**Response:** + +```json +{ + "status": 200, + "message": "Opportunities fetched successfully!", + "data": [ /* array of opportunity objects */ ], + "successful": true +} +``` + +**Notes:** + +- Returns an empty array if the authenticated user has no ConnectWise identifier linked to their account. + +--- + +### Get Opportunities by User ID + +**GET** `/sales/opportunities/user/:id` + +Returns all opportunities where the specified user is assigned as the primary or secondary sales rep. + +**Authentication Required:** Yes + +**Required Permissions:** `sales.opportunity.fetch.many` + +**Path Parameters:** + +- `id` — Internal user ID (cuid) + +**Query Parameters:** + +- `includeClosed` _(optional, default `false`)_ — When `true`, includes closed opportunities in the result. + +**Response:** + +```json +{ + "status": 200, + "message": "Opportunities fetched successfully!", + "data": [ /* array of opportunity objects */ ], + "successful": true +} +``` + +**Error Responses:** + +- `404` — User not found +- Returns an empty array if the user has no ConnectWise identifier linked. + +--- + +### Get Sales Opportunity Metrics (Cached) + +**GET** `/sales/opportunities/metrics` + +Fetch precomputed sales opportunity metrics from Redis. Metrics are refreshed in the background every 5 minutes for active ConnectWise members and are designed for fast dashboard/reporting reads. + +**Authentication Required:** Yes + +**Required Permissions:** `sales.opportunity.fetch.many` + +**Query Parameters:** + +- `scope` _(optional, default `me`)_ — `me` returns metrics for the current user's `cwIdentifier`; `all` returns cached metrics for all active CW members. +- `identifier` _(optional)_ — Explicit CW member identifier override (e.g. `jroberts`). Ignored when `scope=all`. + +**Strict parameter gating:** + +- `scope=all` is rejected with `403` unless caller has `sales.opportunity.metrics.all`. +- `identifier=` is rejected with `403` when the identifier differs from caller's own `cwIdentifier` and caller lacks `sales.opportunity.metrics.identifier.override`. + +**Response (`scope=me`):** + +```json +{ + "status": 200, + "message": "Sales opportunity metrics fetched successfully!", + "data": { + "identifier": "jroberts", + "metrics": { + "memberIdentifier": "jroberts", + "memberName": "John Roberts", + "generatedAt": "2026-03-15T20:10:00.000Z", + "pipelineRevenue": 250000, + "closedWonRevenueMtd": 142500, + "closedWonRevenueYtd": 512000, + "winCount": { "mtd": 3, "ytd": 11 }, + "lossCount": { "mtd": 1, "ytd": 4 }, + "avgDaysToClose": 27.35, + "openOpportunityCount": 9, + "weightedPipelineRevenue": 173500, + "taxablePipelineRevenue": 198000, + "nonTaxablePipelineRevenue": 52000, + "avgOpenDealSize": 27777.78, + "avgWonDealSize": { "mtd": 47500, "ytd": 46545.45 }, + "winRate": { "mtd": 0.75, "ytd": 0.7333 }, + "lossRate": { "mtd": 0.25, "ytd": 0.2667 }, + "assignedOpportunityCount": 14, + "cacheHitCount": 12, + "cacheMissCount": 2, + "cacheHitRate": 0.8571, + "opportunityBreakdown": { + "pipeline": [ + { + "id": "clx1abc123", + "cwId": 98765, + "name": "Acme Corp - Network Upgrade", + "revenue": 45000, + "taxableRevenue": 45000, + "nonTaxableRevenue": 0, + "probability": 70, + "weightedRevenue": 31500, + "closedDate": null + } + ], + "closedWonMtd": [ + { + "id": "clx1def456", + "cwId": 98720, + "name": "Globex - Security Stack", + "revenue": 142500, + "taxableRevenue": 120000, + "nonTaxableRevenue": 22500, + "probability": 100, + "weightedRevenue": 142500, + "closedDate": "2026-03-08T00:00:00.000Z" + } + ], + "closedWonYtd": [ /* same shape, all won opps this year */ ], + "closedLostMtd": [ /* same shape, lost opps this month */ ], + "closedLostYtd": [ /* same shape, lost opps this year */ ] + } + } + }, + "successful": true +} +``` + +Each entry in an `opportunityBreakdown` list contains: + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | Internal DB id (cuid) | +| `cwId` | `number` | ConnectWise opportunity ID | +| `name` | `string` | Opportunity name | +| `revenue` | `number` | Computed total revenue | +| `taxableRevenue` | `number` | Taxable portion of revenue | +| `nonTaxableRevenue` | `number` | Non-taxable portion of revenue | +| `probability` | `number` | Probability as a 0–100 percent value | +| `weightedRevenue` | `number` | `revenue × (probability / 100)` | +| `closedDate` | `string \| null` | ISO 8601 close date, or `null` if open | + +**Response (`scope=all`):** + +```json +{ + "status": 200, + "message": "Sales opportunity metrics fetched successfully!", + "data": { + "generatedAt": "2026-03-15T20:10:00.000Z", + "activeMemberCount": 26, + "memberIdentifiers": ["jroberts", "asmith"], + "members": { + "jroberts": { "memberIdentifier": "jroberts" }, + "asmith": { "memberIdentifier": "asmith" } + } + }, + "successful": true +} +``` + +--- + ### Get Opportunity -**GET** `/sales/opportunities/:identifier` +**GET** `/sales/opportunities/opportunity/:identifier` Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The response includes hydrated company data (with address and contacts) and full site details (with address) when available. CW data (activities, company, site) is served from the Redis cache when available; on cache miss, data is fetched live from CW and cached with an adaptive TTL. @@ -3183,6 +3377,8 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The - `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`. +`expectedSalesTax` is computed from address using site address first and company address as fallback. Logic first checks state, then city-level jurisdiction when available in the sales tax data file, and otherwise falls back to the state's `avg_combined_rate`. Tennessee (`TN`) remains hard-coded to `0.0975` regardless of data-file values. + **Response:** ```json @@ -3255,6 +3451,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The }, "customerPO": null, "totalSalesTax": 0, + "expectedSalesTax": 0.06, "probability": 50, "location": { "id": 1, "name": "Murray" }, "department": { "id": 5, "name": "Sales" }, @@ -3310,7 +3507,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The ### Refresh Opportunity -**POST** `/sales/opportunities/:identifier/refresh` +**POST** `/sales/opportunities/opportunity/:identifier/refresh` Refresh an opportunity's local data by fetching the latest from ConnectWise. The response includes hydrated company data and site details. @@ -3377,6 +3574,7 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise. The "site": { "id": 50, "name": "Main Office" }, "customerPO": null, "totalSalesTax": 0, + "expectedSalesTax": 0.06, "probability": 50, "location": { "id": 1, "name": "Murray" }, "department": { "id": 5, "name": "Sales" }, @@ -3518,6 +3716,7 @@ Create a new opportunity in ConnectWise. The created opportunity is synced to th "site": { "id": 50, "name": "Main Office" }, "customerPO": "PO-12345", "totalSalesTax": 0, + "expectedSalesTax": 0, "probability": 0, "location": { "id": 1, "name": "Murray" }, "department": null, @@ -3552,7 +3751,7 @@ Create a new opportunity in ConnectWise. The created opportunity is synced to th ### Update Opportunity -**PATCH** `/sales/opportunities/:identifier` +**PATCH** `/sales/opportunities/opportunity/:identifier` Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company, contact, site, description). Only the provided fields are patched; omitted fields remain unchanged. The updated opportunity is synced back to the local database and returned in the response. @@ -3643,6 +3842,7 @@ Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company, "site": { "id": 50, "name": "Main Office" }, "customerPO": "PO-12345", "totalSalesTax": 0, + "expectedSalesTax": 0, "probability": 50, "location": { "id": 1, "name": "Murray" }, "department": { "id": 5, "name": "Sales" }, @@ -3667,7 +3867,7 @@ Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company, ### Delete Opportunity -**DELETE** `/sales/opportunities/:identifier` +**DELETE** `/sales/opportunities/opportunity/:identifier` Delete an opportunity from ConnectWise and the local database. All related Redis caches (activities, notes, contacts, products, CW data) are invalidated. @@ -3702,7 +3902,7 @@ Delete an opportunity from ConnectWise and the local database. All related Redis ### Get Opportunity Products -**GET** `/sales/opportunities/:identifier/products` +**GET** `/sales/opportunities/opportunity/:identifier/products` Fetch products for an opportunity, scoped to items that have a matching ConnectWise procurement product (`forecastDetailId` link). Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Hot opportunities (updated within 3 days) have a 15-second TTL; others use a 30-minute lazy TTL. Products are returned sorted by the opportunity's local `productSequence` array when set; otherwise, items are sorted by their ConnectWise `sequenceNumber`. @@ -3788,7 +3988,7 @@ Internal inventory data is sourced from the local CatalogItem database. If the p ### Resequence Opportunity Products -**PATCH** `/sales/opportunities/:identifier/products/sequence` +**PATCH** `/sales/opportunities/opportunity/:identifier/products/sequence` Update the display order of products (forecast items) on an opportunity. The sequence is stored **locally** in the database (`productSequence` field on the Opportunity model) — no modifications are made to ConnectWise. This means forecast item IDs remain stable and procurement product linkages are unaffected. @@ -3850,7 +4050,7 @@ When a `productSequence` is set, `GET .../products` returns items in that order. ### Edit Opportunity Product -**PATCH** `/sales/opportunities/:identifier/products/:productId/edit` +**PATCH** `/sales/opportunities/opportunity/:identifier/products/:productId/edit` Edit a product line item on an opportunity. This route supports forecast-backed fields and procurement-backed fields (including custom fields for narrative/notes). @@ -3915,7 +4115,7 @@ At least one field is required. ### Cancel / Uncancel Opportunity Product -**PATCH** `/sales/opportunities/:identifier/products/:productId/cancel` +**PATCH** `/sales/opportunities/opportunity/:identifier/products/:productId/cancel` Set cancellation state for a product line item using procurement cancellation fields. @@ -3974,7 +4174,7 @@ Set cancellation state for a product line item using procurement cancellation fi ### Delete Product from Opportunity -**DELETE** `/sales/opportunities/:identifier/products/:productId` +**DELETE** `/sales/opportunities/opportunity/:identifier/products/:productId` Remove a forecast item (product) from an opportunity in ConnectWise. The item is also removed from the local `productSequence` array and the products cache is invalidated. @@ -4011,7 +4211,7 @@ Remove a forecast item (product) from an opportunity in ConnectWise. The item is ### Add Product to Opportunity -**POST** `/sales/opportunities/:identifier/products` +**POST** `/sales/opportunities/opportunity/:identifier/products` Add a new product (forecast item) to an opportunity in ConnectWise. The request body is validated with Zod, then each submitted field is gated against `sales.opportunity.product.field.` permissions — only fields the user has permission for are forwarded to ConnectWise. @@ -4121,7 +4321,7 @@ All fields are optional. Only fields the user has the corresponding `sales.oppor ### Add SPECIAL ORDER Product -**POST** `/sales/opportunities/:identifier/products/special-order` +**POST** `/sales/opportunities/opportunity/:identifier/products/special-order` Add one or more products as **SPECIAL ORDER** procurement line items. This route creates ConnectWise procurement products (not forecast items) and enforces stable defaults for quick-entry workflows. @@ -4201,7 +4401,7 @@ Accepts either a single object or an array of objects. ### Get Labor Product Options -**GET** `/sales/opportunities/:identifier/products/labor/options` +**GET** `/sales/opportunities/opportunity/:identifier/products/labor/options` Fetch the resolved **Field** and **Tech** labor catalog products plus default labor pricing metadata so the UI can hydrate the labor-entry form without hardcoding catalog IDs. @@ -4252,7 +4452,7 @@ Fetch the resolved **Field** and **Tech** labor catalog products plus default la ### Add Labor Product -**POST** `/sales/opportunities/:identifier/products/labor` +**POST** `/sales/opportunities/opportunity/:identifier/products/labor` Add a labor line item to an opportunity using one of the two canonical labor catalog products (**Field** or **Tech**). The route resolves both labor products from the local catalog, then picks the selected style and creates a ConnectWise procurement product. @@ -4342,7 +4542,7 @@ Add a labor line item to an opportunity using one of the two canonical labor cat ### Fetch Committed Quotes -**GET** `/sales/opportunities/:identifier/quotes` +**GET** `/sales/opportunities/opportunity/:identifier/quotes` Fetch all committed (finalized) quotes for an opportunity, ordered by most recent first. @@ -4386,7 +4586,7 @@ Fetch all committed (finalized) quotes for an opportunity, ordered by most recen ### Commit Quote -**POST** `/sales/opportunities/:identifier/quote/commit` +**POST** `/sales/opportunities/opportunity/: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. @@ -4503,7 +4703,7 @@ Generate a finalized (non-preview) quote PDF for an opportunity and store it in ### Preview Quote -**GET** `/sales/opportunities/:identifier/quote/:quoteId/preview` +**GET** `/sales/opportunities/opportunity/: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. @@ -4534,7 +4734,7 @@ Regenerate a preview-stamped version of an existing committed quote PDF using it ### Download Quote -**GET** `/sales/opportunities/:identifier/quote/:quoteId/download` +**GET** `/sales/opportunities/opportunity/: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. @@ -4588,7 +4788,7 @@ Download a committed quote PDF by its ID. Returns the PDF file as a base64-encod ### Fetch Quote Download History -**GET** `/sales/opportunities/:identifier/quotes/downloads` +**GET** `/sales/opportunities/opportunity/: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. @@ -4638,7 +4838,7 @@ Fetch download/print history for all committed quotes on an opportunity. Returns ### Get Opportunity Notes -**GET** `/sales/opportunities/:identifier/notes` +**GET** `/sales/opportunities/opportunity/:identifier/notes` Fetch notes for an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when notes are created, updated, or deleted. @@ -4678,7 +4878,7 @@ Fetch notes for an opportunity. Data is served from the Redis cache when availab ### Get Single Opportunity Note -**GET** `/sales/opportunities/:identifier/notes/:noteId` +**GET** `/sales/opportunities/opportunity/:identifier/notes/:noteId` Fetch a single note by its ConnectWise note ID for an opportunity. @@ -4717,7 +4917,7 @@ Fetch a single note by its ConnectWise note ID for an opportunity. ### Create Opportunity Note -**POST** `/sales/opportunities/:identifier/notes` +**POST** `/sales/opportunities/opportunity/:identifier/notes` Create a new note on an opportunity in ConnectWise. @@ -4769,7 +4969,7 @@ Create a new note on an opportunity in ConnectWise. ### Update Opportunity Note -**PATCH** `/sales/opportunities/:identifier/notes/:noteId` +**PATCH** `/sales/opportunities/opportunity/:identifier/notes/:noteId` Update an existing note on an opportunity in ConnectWise. @@ -4824,7 +5024,7 @@ Update an existing note on an opportunity in ConnectWise. ### Delete Opportunity Note -**DELETE** `/sales/opportunities/:identifier/notes/:noteId` +**DELETE** `/sales/opportunities/opportunity/:identifier/notes/:noteId` Delete a note from an opportunity in ConnectWise. @@ -4851,7 +5051,7 @@ Delete a note from an opportunity in ConnectWise. ### Get Opportunity Contacts -**GET** `/sales/opportunities/:identifier/contacts` +**GET** `/sales/opportunities/opportunity/:identifier/contacts` Fetch contacts associated with an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when contacts are created, updated, or deleted. @@ -4905,7 +5105,8 @@ The opportunity workflow system is an internal engine that manages the lifecycle | QuoteSent | 43 | 03. Quote Sent | No | Quote has been sent to the customer | | ConfirmedQuote | 57 | 04. Confirmed Quote | No | Customer acknowledged receipt | | Active | 58 | 05. Active | No | Quote in revision/flux | -| PendingSent | 60 | Pending Sent | No | Review approved, awaiting rep to send | +| ReadyToSend | 63 | Ready to Send | No | Review approved; ready for rep send | +| PendingSent | 60 | Pending Sent | No | Deprecated legacy status; existing records may still use it | | PendingRevision | 61 | Pending Revision | No | Review rejected, awaiting rep revision | | PendingWon | 49 | 91. Pending Won | No | Won pending finalization by authorized user | | Won | 29 | 95. Won | Yes | Final positive outcome — immutable | @@ -4919,8 +5120,9 @@ The opportunity workflow system is an internal engine that manages the lifecycle | --------------- | ------------------------------------------------------------------------------- | | PendingNew | New | | New | InternalReview, QuoteSent, Canceled | -| InternalReview | PendingSent (approve), PendingRevision (reject), QuoteSent (send), Canceled | -| PendingSent | QuoteSent | +| InternalReview | ReadyToSend (approve), PendingRevision (reject), QuoteSent (send), Canceled | +| ReadyToSend | QuoteSent | +| PendingSent | QuoteSent _(deprecated legacy path for existing records only)_ | | PendingRevision | Active | | QuoteSent | ConfirmedQuote, Won/PendingWon, PendingLost, Active, InternalReview (cold auto) | | ConfirmedQuote | Won/PendingWon, PendingLost, Active, InternalReview (cold auto) | @@ -4983,11 +5185,11 @@ Triggered via `triggerColdDetection()` (intended for schedulers/automation, not ### Workflow API Routes -These routes expose the opportunity workflow to the UI. They live under `/v1/sales/opportunities/:identifier/workflow`. +These routes expose the opportunity workflow to the UI. They live under `/v1/sales/opportunities/opportunity/:identifier/workflow`. --- -#### `GET /v1/sales/opportunities/:identifier/workflow` +#### `GET /v1/sales/opportunities/opportunity/:identifier/workflow` Fetch the current workflow state for an opportunity — current status, stage, available actions, cold-detection result, and whether each action is permitted for the authenticated user. @@ -5038,7 +5240,7 @@ Fetch the current workflow state for an opportunity — current status, stage, a --- -#### `POST /v1/sales/opportunities/:identifier/workflow` +#### `POST /v1/sales/opportunities/opportunity/:identifier/workflow` Execute a workflow action. Accepts a discriminated union of `{ action, payload }` and routes it through the workflow engine. The body is validated with Zod. @@ -5073,8 +5275,8 @@ Execute a workflow action. Accepts a discriminated union of `{ action, payload } | ---------------- | --------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------- | | `acceptNew` | — | `note`, `timeSpent` | PendingNew → New | | `requestReview` | `note` | `timeSpent` | New/Active → InternalReview | -| `reviewDecision` | `note`, `decision` | `timeSpent` | InternalReview → PendingSent/PendingRevision/QuoteSent/Canceled | -| `sendQuote` | — | `note`, `timeSpent`, `quoteConfirmed`, `won`, `lost`, `needsRevision` | PendingSent/New → QuoteSent (+ compound transitions) | +| `reviewDecision` | `note`, `decision` | `timeSpent` | InternalReview → ReadyToSend/PendingRevision/QuoteSent/Canceled | +| `sendQuote` | — | `note`, `timeSpent`, `quoteConfirmed`, `won`, `lost`, `needsRevision` | ReadyToSend/New → QuoteSent (+ compound transitions) | | `confirmQuote` | — | `note`, `timeSpent` | QuoteSent → ConfirmedQuote | | `finalize` | `note`, `outcome` (`"won"` or `"lost"`) | `timeSpent` | Pending → Won/Lost (or direct if permitted) | | `resurrect` | `note` | `timeSpent` | PendingLost → Active | @@ -5094,8 +5296,8 @@ Execute a workflow action. Accepts a discriminated union of `{ action, payload } "message": "Workflow action completed successfully.", "status": 200, "data": { - "previousStatusId": 60, - "previousStatus": "PendingSent", + "previousStatusId": 63, + "previousStatus": "ReadyToSend", "newStatusId": 43, "newStatus": "QuoteSent", "activitiesCreated": [ { "...activity JSON..." } ], @@ -5122,7 +5324,7 @@ Execute a workflow action. Accepts a discriminated union of `{ action, payload } --- -#### `GET /v1/sales/opportunities/:identifier/workflow/history` +#### `GET /v1/sales/opportunities/opportunity/:identifier/workflow/history` Fetch the workflow activity history for an opportunity. Returns only CW activities that have a valid `Optima_Type` custom field set, sorted newest first. diff --git a/CACHING.md b/CACHING.md index 141b114..6398c52 100644 --- a/CACHING.md +++ b/CACHING.md @@ -8,6 +8,8 @@ This document describes the caching layer used in the Optima API, covering the R The API caches expensive ConnectWise (CW) API responses in **Redis** to reduce latency and avoid CW rate limits. The primary cache layer is the **opportunity cache** (`src/modules/cache/opportunityCache.ts`), which proactively warms data for all non-closed opportunities on a background interval. +The API also maintains a Redis-backed **sales member metrics cache** (`src/modules/cache/salesOpportunityMetricsCache.ts`) refreshed every 5 minutes. It precomputes per-member dashboard/reporting figures (pipeline revenue, won/lost counts, win rate, avg days to close, and related metrics) for fast reads from `/v1/sales/opportunities/metrics`. + ### Key design principles - **Adaptive TTLs** — cache durations are computed dynamically based on how "hot" an opportunity is (recently updated = shorter TTL = fresher data). @@ -38,6 +40,14 @@ Inventory-adjustment-driven catalog sync adds a targeted product cache: | ------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `catalog:item:cw:{cwId}` | Full CW catalog item + computed `onHand` + DB row snapshot | `GET /procurement/adjustments` + `GET /procurement/catalog/:id` + catalog inventory endpoint | +Sales opportunity metrics caching adds member-focused keys: + +| Cache Key Pattern | Data | Source | +| ------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------- | +| `sales:metrics:members:all` | Envelope of all active-member metrics | Precomputed from active CW members + assigned opportunities + products cache/CW fetch | +| `sales:metrics:member:{cwIdentifier}` | One member's computed metrics snapshot | Same as above | +| `sales:metrics:oppRevenue:{cwOppId}` | Per-opportunity computed revenue blob | Metrics refresh lookups (products cache-first, then manager/controller fallback) | + --- ## TTL Algorithms @@ -172,6 +182,31 @@ The thunk pattern is critical. Previously, tasks were pushed as already-executin At these settings, a full sweep of ~500 expired keys completes in ~1-2 minutes with zero CW errors and ~230ms median latency. +### Sales metrics refresh job + +**Function:** `refreshSalesOpportunityMetricsCache()` in `src/modules/cache/salesOpportunityMetricsCache.ts` + +**Interval:** Every 5 minutes, triggered from `src/index.ts`. + +**Startup behavior:** On app startup, the refresh is invoked once with `forceColdLoad=true`, which clears metrics-owned Redis keys and bypasses metrics/product cache reuse for that initial rebuild. Subsequent interval runs use the normal warm path. + +Refresh flow: + +1. Fetch all active CW members (`inactiveFlag=false`). + Source: local `CwMember` table (kept in sync by the existing members refresh job). +2. Query DB opportunities assigned to those members (primary or secondary rep), scoped to open opportunities plus YTD-closed opportunities. +3. For each opportunity, compute revenue cache-first from `sales:metrics:oppRevenue:{cwOppId}` then `opp:products:{cwOpportunityId}`, and fallback through the manager/controller path (`opportunities.fetchRecord(...).fetchProducts()`) on miss. +4. Aggregate member metrics (pipeline revenue, won/lost MTD+YTD counts, avg days to close, weighted pipeline, win/loss rates, and related KPIs). +5. Write per-opportunity revenue blobs plus all-member and per-member snapshots to Redis with a 10-minute TTL. + +Safety controls: + +- **Single-flight lock** prevents overlapping refresh runs if a prior run is still in progress. +- **Per-opportunity timeout guard** ensures slow CW product lookups degrade to zero-revenue fallback instead of stalling the full refresh. +- **Force-cold-load mode** clears `sales:metrics:*` runtime state owned by the metrics cache before rebuilding startup data. + +This cache-first model prioritizes metrics-owned opportunity revenue keys first, then opportunity product cache entries, and only reaches CW when needed. + --- ## Retry Logic (`withCwRetry`) @@ -343,6 +378,7 @@ src/index.ts | `src/modules/cw-utils/cwApiLogger.ts` | Axios interceptor for JSONL call logging | | `src/modules/cw-utils/fetchCompany.ts` | Company fetch with retry | | `src/modules/cw-utils/procurement/listenInventoryAdjustments.ts` | Adjustment listener for targeted catalog-item cache + DB sync | +| `src/modules/cache/salesOpportunityMetricsCache.ts` | 5-minute active-member opportunity metrics cache | | `src/constants.ts` | CW Axios instance config (timeout, logger) | | `src/index.ts` | Refresh interval registration | | `debug-scripts/analyze-cw-calls.py` | CW API call analysis script | diff --git a/PERMISSIONS.md b/PERMISSIONS.md index 39b43b8..691dbfe 100644 --- a/PERMISSIONS.md +++ b/PERMISSIONS.md @@ -142,38 +142,43 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy **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.update` | Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise | [src/api/sales/opportunities/[id]/update.ts](src/api/sales/opportunities/[id]/update.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.create` | Create a new opportunity in ConnectWise | [src/api/sales/opportunities/create.ts](src/api/sales/opportunities/create.ts) | | -| `sales.opportunity.delete` | Delete an opportunity from ConnectWise and the local database | [src/api/sales/opportunities/[id]/delete.ts](src/api/sales/opportunities/[id]/delete.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.delete` | Delete a product (forecast item) from an opportunity | [src/api/sales/opportunities/[id]/products/delete.ts](src/api/sales/opportunities/[id]/products/delete.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` | -| `sales.opportunity.view_margin` | View margin and markup data on opportunity products. Controls visibility of margin %, markup %, and related progress bars in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` | -| `sales.opportunity.view_cost` | View cost data on opportunity products. Controls visibility of unit cost, total cost, and recurring cost in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` | -| `sales.opportunity.view_profit` | View profit data on opportunity products. Controls visibility of profit values in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` | -| `sales.opportunity.workflow` | Execute opportunity workflow actions (status transitions, review decisions, quote sending, etc.). Base gate for the workflow dispatch endpoint. | [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.finalize` | Finalize an opportunity as Won or Lost. Without this permission, win/lose actions route to PendingWon/PendingLost instead. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts), [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.workflow` | -| `sales.opportunity.cancel` | Cancel an opportunity. Required to transition any eligible opportunity to the Canceled status. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts), [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.workflow` | -| `sales.opportunity.review` | Submit an opportunity for internal review. Required to transition an opportunity into the InternalReview status. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` | -| `sales.opportunity.send` | Send a quote to the customer. Required to transition an opportunity to QuoteSent (and compound transitions like immediate won/lost/confirmed). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` | -| `sales.opportunity.reopen` | Re-open a cancelled opportunity. Required to transition an opportunity from Canceled back to Active. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` | -| `sales.opportunity.win` | Mark an opportunity as won (or pending won). Gates the win button in the UI. Required for finalize(won), sendQuote(won), and transitionToPending(won). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` | -| `sales.opportunity.lose` | Mark an opportunity as lost (or pending lost). Gates the lose button in the UI. Required for finalize(lost), sendQuote(lost), and transitionToPending(lost). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` | +| 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.fetch.@me` | View the personal sales dashboard showing opportunities assigned to the current user | UI-only (client-side gate) | | +| `sales.opportunity.fetch.all` | View all opportunities across all users (All Opportunities tab and View All button in the sales dashboard) | UI-only (client-side gate) | `sales.opportunity.fetch.many` | +| `sales.opportunity.metrics.all` | Allow `scope=all` on sales opportunity metrics endpoint to read cached metrics for all active members | [src/api/sales/opportunities/metrics.ts](src/api/sales/opportunities/metrics.ts) | `sales.opportunity.fetch.many` | +| `sales.opportunity.metrics.identifier.override` | Allow `identifier=` override on sales opportunity metrics endpoint for querying another member | [src/api/sales/opportunities/metrics.ts](src/api/sales/opportunities/metrics.ts) | `sales.opportunity.fetch.many` | +| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/opportunities/[id]/refresh.ts](src/api/sales/opportunities/[id]/refresh.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.update` | Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise | [src/api/sales/opportunities/[id]/update.ts](src/api/sales/opportunities/[id]/update.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.create` | Create a new opportunity in ConnectWise | [src/api/sales/opportunities/create.ts](src/api/sales/opportunities/create.ts) | | +| `sales.opportunity.delete` | Delete an opportunity from ConnectWise and the local database | [src/api/sales/opportunities/[id]/delete.ts](src/api/sales/opportunities/[id]/delete.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.delete` | Delete a product (forecast item) from an opportunity | [src/api/sales/opportunities/[id]/products/delete.ts](src/api/sales/opportunities/[id]/products/delete.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` | +| `sales.opportunity.view_margin` | View margin and markup data on opportunity products. Controls visibility of margin %, markup %, and related progress bars in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` | +| `sales.opportunity.view_cost` | View cost data on opportunity products. Controls visibility of unit cost, total cost, and recurring cost in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` | +| `sales.opportunity.view_profit` | View profit data on opportunity products. Controls visibility of profit values in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` | +| `sales.opportunity.workflow` | Execute opportunity workflow actions (status transitions, review decisions, quote sending, etc.). Base gate for the workflow dispatch endpoint. | [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.finalize` | Finalize an opportunity as Won or Lost. Without this permission, win/lose actions route to PendingWon/PendingLost instead. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts), [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.workflow` | +| `sales.opportunity.cancel` | Cancel an opportunity. Required to transition any eligible opportunity to the Canceled status. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts), [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.workflow` | +| `sales.opportunity.review` | Submit an opportunity for internal review. Required to transition an opportunity into the InternalReview status. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` | +| `sales.opportunity.send` | Send a quote to the customer. Required to transition an opportunity to QuoteSent (and compound transitions like immediate won/lost/confirmed). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` | +| `sales.opportunity.reopen` | Re-open a cancelled opportunity. Required to transition an opportunity from Canceled back to Active. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` | +| `sales.opportunity.win` | Mark an opportunity as won (or pending won). Gates the win button in the UI. Required for finalize(won), sendQuote(won), and transitionToPending(won). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` | +| `sales.opportunity.lose` | Mark an opportunity as lost (or pending lost). Gates the lose button in the UI. Required for finalize(lost), sendQuote(lost), and transitionToPending(lost). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` | +| `sales.isRepresentative` | Designates the user as a sales representative; used for reporting and filtering purposes. | _(not yet used in routes)_ | |
Field-level permissions for sales.opportunity.product.add diff --git a/generated/prisma/internal/class.ts b/generated/prisma/internal/class.ts index cdb7b12..aa277ad 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 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\nmodel CwMember {\n id String @id @default(cuid())\n\n cwMemberId Int @unique\n identifier String @unique\n firstName String\n lastName String\n officeEmail String?\n inactiveFlag Boolean @default(false)\n\n apiKey String?\n\n cwLastUpdated DateTime?\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 cwDateEntered 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\nmodel CwMember {\n id String @id @default(cuid())\n\n cwMemberId Int @unique\n identifier String @unique\n firstName String\n lastName String\n officeEmail String?\n inactiveFlag Boolean @default(false)\n\n apiKey String?\n\n cwLastUpdated DateTime?\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\"},{\"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},\"CwMember\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwMemberId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"firstName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"lastName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"officeEmail\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"inactiveFlag\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"apiKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwLastUpdated\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"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\":\"cwDateEntered\",\"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},\"CwMember\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwMemberId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"firstName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"lastName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"officeEmail\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"inactiveFlag\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"apiKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwLastUpdated\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"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') diff --git a/generated/prisma/internal/prismaNamespace.ts b/generated/prisma/internal/prismaNamespace.ts index f2f18c5..741fd2a 100644 --- a/generated/prisma/internal/prismaNamespace.ts +++ b/generated/prisma/internal/prismaNamespace.ts @@ -1487,6 +1487,7 @@ export const OpportunityScalarFieldEnum = { companyId: 'companyId', productSequence: 'productSequence', cwLastUpdated: 'cwLastUpdated', + cwDateEntered: 'cwDateEntered', createdAt: 'createdAt', updatedAt: 'updatedAt' } as const diff --git a/generated/prisma/internal/prismaNamespaceBrowser.ts b/generated/prisma/internal/prismaNamespaceBrowser.ts index d203a3a..ed8e0ff 100644 --- a/generated/prisma/internal/prismaNamespaceBrowser.ts +++ b/generated/prisma/internal/prismaNamespaceBrowser.ts @@ -226,6 +226,7 @@ export const OpportunityScalarFieldEnum = { companyId: 'companyId', productSequence: 'productSequence', cwLastUpdated: 'cwLastUpdated', + cwDateEntered: 'cwDateEntered', createdAt: 'createdAt', updatedAt: 'updatedAt' } as const diff --git a/generated/prisma/models/Opportunity.ts b/generated/prisma/models/Opportunity.ts index b976b69..8ea0f09 100644 --- a/generated/prisma/models/Opportunity.ts +++ b/generated/prisma/models/Opportunity.ts @@ -114,6 +114,7 @@ export type OpportunityMinAggregateOutputType = { closedByCwId: number | null companyId: string | null cwLastUpdated: Date | null + cwDateEntered: Date | null createdAt: Date | null updatedAt: Date | null } @@ -164,6 +165,7 @@ export type OpportunityMaxAggregateOutputType = { closedByCwId: number | null companyId: string | null cwLastUpdated: Date | null + cwDateEntered: Date | null createdAt: Date | null updatedAt: Date | null } @@ -215,6 +217,7 @@ export type OpportunityCountAggregateOutputType = { companyId: number productSequence: number cwLastUpdated: number + cwDateEntered: number createdAt: number updatedAt: number _all: number @@ -309,6 +312,7 @@ export type OpportunityMinAggregateInputType = { closedByCwId?: true companyId?: true cwLastUpdated?: true + cwDateEntered?: true createdAt?: true updatedAt?: true } @@ -359,6 +363,7 @@ export type OpportunityMaxAggregateInputType = { closedByCwId?: true companyId?: true cwLastUpdated?: true + cwDateEntered?: true createdAt?: true updatedAt?: true } @@ -410,6 +415,7 @@ export type OpportunityCountAggregateInputType = { companyId?: true productSequence?: true cwLastUpdated?: true + cwDateEntered?: true createdAt?: true updatedAt?: true _all?: true @@ -548,6 +554,7 @@ export type OpportunityGroupByOutputType = { companyId: string | null productSequence: number[] cwLastUpdated: Date | null + cwDateEntered: Date | null createdAt: Date updatedAt: Date _count: OpportunityCountAggregateOutputType | null @@ -622,6 +629,7 @@ export type OpportunityWhereInput = { companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null productSequence?: Prisma.IntNullableListFilter<"Opportunity"> cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null + cwDateEntered?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter @@ -675,6 +683,7 @@ export type OpportunityOrderByWithRelationInput = { companyId?: Prisma.SortOrderInput | Prisma.SortOrder productSequence?: Prisma.SortOrder cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder + cwDateEntered?: Prisma.SortOrderInput | Prisma.SortOrder createdAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput @@ -731,6 +740,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{ companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null productSequence?: Prisma.IntNullableListFilter<"Opportunity"> cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null + cwDateEntered?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter @@ -784,6 +794,7 @@ export type OpportunityOrderByWithAggregationInput = { companyId?: Prisma.SortOrderInput | Prisma.SortOrder productSequence?: Prisma.SortOrder cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder + cwDateEntered?: Prisma.SortOrderInput | Prisma.SortOrder createdAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder _count?: Prisma.OpportunityCountOrderByAggregateInput @@ -843,6 +854,7 @@ export type OpportunityScalarWhereWithAggregatesInput = { companyId?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null productSequence?: Prisma.IntNullableListFilter<"Opportunity"> cwLastUpdated?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null + cwDateEntered?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null createdAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string } @@ -893,6 +905,7 @@ export type OpportunityCreateInput = { closedByCwId?: number | null productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null + cwDateEntered?: Date | string | null createdAt?: Date | string updatedAt?: Date | string generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput @@ -946,6 +959,7 @@ export type OpportunityUncheckedCreateInput = { companyId?: string | null productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null + cwDateEntered?: Date | string | null createdAt?: Date | string updatedAt?: Date | string generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput @@ -997,6 +1011,7 @@ export type OpportunityUpdateInput = { closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput @@ -1050,6 +1065,7 @@ export type OpportunityUncheckedUpdateInput = { companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput @@ -1102,6 +1118,7 @@ export type OpportunityCreateManyInput = { companyId?: string | null productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null + cwDateEntered?: Date | string | null createdAt?: Date | string updatedAt?: Date | string } @@ -1152,6 +1169,7 @@ export type OpportunityUpdateManyMutationInput = { closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string } @@ -1203,6 +1221,7 @@ export type OpportunityUncheckedUpdateManyInput = { companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string } @@ -1272,6 +1291,7 @@ export type OpportunityCountOrderByAggregateInput = { companyId?: Prisma.SortOrder productSequence?: Prisma.SortOrder cwLastUpdated?: Prisma.SortOrder + cwDateEntered?: Prisma.SortOrder createdAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder } @@ -1343,6 +1363,7 @@ export type OpportunityMaxOrderByAggregateInput = { closedByCwId?: Prisma.SortOrder companyId?: Prisma.SortOrder cwLastUpdated?: Prisma.SortOrder + cwDateEntered?: Prisma.SortOrder createdAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder } @@ -1393,6 +1414,7 @@ export type OpportunityMinOrderByAggregateInput = { closedByCwId?: Prisma.SortOrder companyId?: Prisma.SortOrder cwLastUpdated?: Prisma.SortOrder + cwDateEntered?: Prisma.SortOrder createdAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder } @@ -1534,6 +1556,7 @@ export type OpportunityCreateWithoutCompanyInput = { closedByCwId?: number | null productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null + cwDateEntered?: Date | string | null createdAt?: Date | string updatedAt?: Date | string generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput @@ -1585,6 +1608,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = { closedByCwId?: number | null productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null + cwDateEntered?: Date | string | null createdAt?: Date | string updatedAt?: Date | string generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput @@ -1666,6 +1690,7 @@ export type OpportunityScalarWhereInput = { companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null productSequence?: Prisma.IntNullableListFilter<"Opportunity"> cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null + cwDateEntered?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string } @@ -1716,6 +1741,7 @@ export type OpportunityCreateWithoutGeneratedQuotesInput = { closedByCwId?: number | null productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null + cwDateEntered?: Date | string | null createdAt?: Date | string updatedAt?: Date | string company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput @@ -1768,6 +1794,7 @@ export type OpportunityUncheckedCreateWithoutGeneratedQuotesInput = { companyId?: string | null productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null + cwDateEntered?: Date | string | null createdAt?: Date | string updatedAt?: Date | string } @@ -1834,6 +1861,7 @@ export type OpportunityUpdateWithoutGeneratedQuotesInput = { closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput @@ -1886,6 +1914,7 @@ export type OpportunityUncheckedUpdateWithoutGeneratedQuotesInput = { companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string } @@ -1936,6 +1965,7 @@ export type OpportunityCreateManyCompanyInput = { closedByCwId?: number | null productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null + cwDateEntered?: Date | string | null createdAt?: Date | string updatedAt?: Date | string } @@ -1986,6 +2016,7 @@ export type OpportunityUpdateWithoutCompanyInput = { closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput @@ -2037,6 +2068,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = { closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput @@ -2088,6 +2120,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = { closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null + cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string } @@ -2170,6 +2203,7 @@ export type OpportunitySelect @@ -2224,6 +2258,7 @@ export type OpportunitySelectCreateManyAndReturn @@ -2276,6 +2311,7 @@ export type OpportunitySelectUpdateManyAndReturn @@ -2328,11 +2364,12 @@ export type OpportunitySelectScalar = { companyId?: boolean productSequence?: boolean cwLastUpdated?: boolean + cwDateEntered?: boolean createdAt?: boolean updatedAt?: boolean } -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 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" | "cwDateEntered" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]> export type OpportunityInclude = { generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs company?: boolean | Prisma.Opportunity$companyArgs @@ -2398,6 +2435,7 @@ export type $OpportunityPayload @@ -2871,6 +2909,7 @@ export interface OpportunityFieldRefs { readonly companyId: Prisma.FieldRef<"Opportunity", 'String'> readonly productSequence: Prisma.FieldRef<"Opportunity", 'Int[]'> readonly cwLastUpdated: Prisma.FieldRef<"Opportunity", 'DateTime'> + readonly cwDateEntered: Prisma.FieldRef<"Opportunity", 'DateTime'> readonly createdAt: Prisma.FieldRef<"Opportunity", 'DateTime'> readonly updatedAt: Prisma.FieldRef<"Opportunity", 'DateTime'> } diff --git a/prisma/migrations/20260316031915_add_cw_date_entered/migration.sql b/prisma/migrations/20260316031915_add_cw_date_entered/migration.sql new file mode 100644 index 0000000..6225df3 --- /dev/null +++ b/prisma/migrations/20260316031915_add_cw_date_entered/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Opportunity" ADD COLUMN "cwDateEntered" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ffc0076..51d2a99 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -194,6 +194,7 @@ model Opportunity { productSequence Int[] @default([]) cwLastUpdated DateTime? + cwDateEntered DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/api/sales/[id]/fetch.ts b/src/api/sales/[id]/fetch.ts index b1eab92..bc4c090 100644 --- a/src/api/sales/[id]/fetch.ts +++ b/src/api/sales/[id]/fetch.ts @@ -20,10 +20,10 @@ import { } from "../../../modules/cache/opportunityCache"; import { generatedQuotes } from "../../../managers/generatedQuotes"; -/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */ +/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */ export default createRoute( "get", - ["/opportunities/:identifier"], + ["/opportunities/opportunity/:identifier"], async (c) => { const identifier = c.req.param("identifier"); const includeParam = c.req.query("include") ?? ""; diff --git a/src/api/sales/index.ts b/src/api/sales/index.ts index 5d781b5..c544d01 100644 --- a/src/api/sales/index.ts +++ b/src/api/sales/index.ts @@ -1,4 +1,5 @@ import { default as fetchAll } from "./opportunities/fetchAll"; +import { default as metrics } from "./opportunities/metrics"; import { default as createOpportunity } from "./opportunities/create"; import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes"; import { default as count } from "./opportunities/count"; @@ -26,18 +27,23 @@ 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"; +import { default as fetchByUser } from "./opportunities/fetchByUser"; +import { default as fetchByUserId } from "./opportunities/fetchByUserId"; import { default as workflowDispatch } from "./opportunities/[id]/workflow/dispatch"; import { default as workflowStatus } from "./opportunities/[id]/workflow/status"; import { default as workflowHistory } from "./opportunities/[id]/workflow/history"; export { addProduct, + fetchByUser, + fetchByUserId, addLabor, laborOptions, addSpecialOrderProduct, count, createOpportunity, deleteOpportunity, + metrics, fetch, fetchAll, fetchOpportunityTypes, diff --git a/src/api/sales/opportunities/[id]/contacts.ts b/src/api/sales/opportunities/[id]/contacts.ts index fc426cc..f20b338 100644 --- a/src/api/sales/opportunities/[id]/contacts.ts +++ b/src/api/sales/opportunities/[id]/contacts.ts @@ -4,10 +4,10 @@ import { apiResponse } from "../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; import { authMiddleware } from "../../../middleware/authorization"; -/* GET /v1/sales/opportunities/:identifier/contacts */ +/* GET /v1/sales/opportunities/opportunity/:identifier/contacts */ export default createRoute( "get", - ["/opportunities/:identifier/contacts"], + ["/opportunities/opportunity/:identifier/contacts"], async (c) => { const identifier = c.req.param("identifier"); const item = await opportunities.fetchRecord(identifier); diff --git a/src/api/sales/opportunities/[id]/delete.ts b/src/api/sales/opportunities/[id]/delete.ts index 4223b00..5ded8f2 100644 --- a/src/api/sales/opportunities/[id]/delete.ts +++ b/src/api/sales/opportunities/[id]/delete.ts @@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status"; import { authMiddleware } from "../../../middleware/authorization"; import GenericError from "../../../../Errors/GenericError"; -/* DELETE /v1/sales/opportunities/:identifier */ +/* DELETE /v1/sales/opportunities/opportunity/:identifier */ export default createRoute( "delete", - ["/opportunities/:identifier"], + ["/opportunities/opportunity/:identifier"], async (c) => { const identifier = c.req.param("identifier"); diff --git a/src/api/sales/opportunities/[id]/fetch.ts b/src/api/sales/opportunities/[id]/fetch.ts index 9869ae9..5f44ebf 100644 --- a/src/api/sales/opportunities/[id]/fetch.ts +++ b/src/api/sales/opportunities/[id]/fetch.ts @@ -20,10 +20,10 @@ import { } from "../../../../modules/cache/opportunityCache"; import { generatedQuotes } from "../../../../managers/generatedQuotes"; -/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */ +/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */ export default createRoute( "get", - ["/opportunities/:identifier"], + ["/opportunities/opportunity/:identifier"], async (c) => { const identifier = c.req.param("identifier"); const includeParam = c.req.query("include") ?? ""; diff --git a/src/api/sales/opportunities/[id]/notes/create.ts b/src/api/sales/opportunities/[id]/notes/create.ts index 78899bc..dbcb9ee 100644 --- a/src/api/sales/opportunities/[id]/notes/create.ts +++ b/src/api/sales/opportunities/[id]/notes/create.ts @@ -6,10 +6,10 @@ import { authMiddleware } from "../../../../middleware/authorization"; import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache"; import { z } from "zod"; -/* POST /v1/sales/opportunities/:identifier/notes */ +/* POST /v1/sales/opportunities/opportunity/:identifier/notes */ export default createRoute( "post", - ["/opportunities/:identifier/notes"], + ["/opportunities/opportunity/:identifier/notes"], async (c) => { const identifier = c.req.param("identifier"); const body = await c.req.json(); diff --git a/src/api/sales/opportunities/[id]/notes/delete.ts b/src/api/sales/opportunities/[id]/notes/delete.ts index ec6b339..a9138cd 100644 --- a/src/api/sales/opportunities/[id]/notes/delete.ts +++ b/src/api/sales/opportunities/[id]/notes/delete.ts @@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status"; import { authMiddleware } from "../../../../middleware/authorization"; import GenericError from "../../../../../Errors/GenericError"; -/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */ +/* DELETE /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */ export default createRoute( "delete", - ["/opportunities/:identifier/notes/:noteId"], + ["/opportunities/opportunity/:identifier/notes/:noteId"], async (c) => { const identifier = c.req.param("identifier"); const noteId = Number(c.req.param("noteId")); diff --git a/src/api/sales/opportunities/[id]/notes/fetch.ts b/src/api/sales/opportunities/[id]/notes/fetch.ts index b1680d1..5c54387 100644 --- a/src/api/sales/opportunities/[id]/notes/fetch.ts +++ b/src/api/sales/opportunities/[id]/notes/fetch.ts @@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status"; import { authMiddleware } from "../../../../middleware/authorization"; import GenericError from "../../../../../Errors/GenericError"; -/* GET /v1/sales/opportunities/:identifier/notes/:noteId */ +/* GET /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */ export default createRoute( "get", - ["/opportunities/:identifier/notes/:noteId"], + ["/opportunities/opportunity/:identifier/notes/:noteId"], async (c) => { const identifier = c.req.param("identifier"); const noteId = Number(c.req.param("noteId")); diff --git a/src/api/sales/opportunities/[id]/notes/fetchAll.ts b/src/api/sales/opportunities/[id]/notes/fetchAll.ts index 0ef7582..1cc0513 100644 --- a/src/api/sales/opportunities/[id]/notes/fetchAll.ts +++ b/src/api/sales/opportunities/[id]/notes/fetchAll.ts @@ -4,10 +4,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; import { authMiddleware } from "../../../../middleware/authorization"; -/* GET /v1/sales/opportunities/:identifier/notes */ +/* GET /v1/sales/opportunities/opportunity/:identifier/notes */ export default createRoute( "get", - ["/opportunities/:identifier/notes"], + ["/opportunities/opportunity/:identifier/notes"], async (c) => { const identifier = c.req.param("identifier"); const item = await opportunities.fetchRecord(identifier); diff --git a/src/api/sales/opportunities/[id]/notes/update.ts b/src/api/sales/opportunities/[id]/notes/update.ts index 3631dcd..0058edc 100644 --- a/src/api/sales/opportunities/[id]/notes/update.ts +++ b/src/api/sales/opportunities/[id]/notes/update.ts @@ -7,10 +7,10 @@ import GenericError from "../../../../../Errors/GenericError"; import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache"; import { z } from "zod"; -/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */ +/* PATCH /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */ export default createRoute( "patch", - ["/opportunities/:identifier/notes/:noteId"], + ["/opportunities/opportunity/:identifier/notes/:noteId"], async (c) => { const identifier = c.req.param("identifier"); const noteId = Number(c.req.param("noteId")); diff --git a/src/api/sales/opportunities/[id]/products/add.ts b/src/api/sales/opportunities/[id]/products/add.ts index 6749609..4701b56 100644 --- a/src/api/sales/opportunities/[id]/products/add.ts +++ b/src/api/sales/opportunities/[id]/products/add.ts @@ -34,10 +34,10 @@ const addProductSchema = z.union([ z.array(productItemSchema).min(1, "At least one product is required"), ]); -/* POST /v1/sales/opportunities/:identifier/products */ +/* POST /v1/sales/opportunities/opportunity/:identifier/products */ export default createRoute( "post", - ["/opportunities/:identifier/products"], + ["/opportunities/opportunity/:identifier/products"], async (c) => { const identifier = c.req.param("identifier"); const body = await c.req.json(); diff --git a/src/api/sales/opportunities/[id]/products/addLabor.ts b/src/api/sales/opportunities/[id]/products/addLabor.ts index a715472..c99a4ca 100644 --- a/src/api/sales/opportunities/[id]/products/addLabor.ts +++ b/src/api/sales/opportunities/[id]/products/addLabor.ts @@ -30,10 +30,10 @@ const addLaborSchema = z }) .strict(); -/* POST /v1/sales/opportunities/:identifier/products/labor */ +/* POST /v1/sales/opportunities/opportunity/:identifier/products/labor */ export default createRoute( "post", - ["/opportunities/:identifier/products/labor"], + ["/opportunities/opportunity/:identifier/products/labor"], async (c) => { const identifier = c.req.param("identifier"); const body = await c.req.json(); diff --git a/src/api/sales/opportunities/[id]/products/addSpecialOrder.ts b/src/api/sales/opportunities/[id]/products/addSpecialOrder.ts index 1605fcc..be3e577 100644 --- a/src/api/sales/opportunities/[id]/products/addSpecialOrder.ts +++ b/src/api/sales/opportunities/[id]/products/addSpecialOrder.ts @@ -27,10 +27,10 @@ const addSpecialOrderSchema = z.union([ .min(1, "At least one special-order product is required"), ]); -/* POST /v1/sales/opportunities/:identifier/products/special-order */ +/* POST /v1/sales/opportunities/opportunity/:identifier/products/special-order */ export default createRoute( "post", - ["/opportunities/:identifier/products/special-order"], + ["/opportunities/opportunity/:identifier/products/special-order"], async (c) => { const identifier = c.req.param("identifier"); const body = await c.req.json(); diff --git a/src/api/sales/opportunities/[id]/products/cancel.ts b/src/api/sales/opportunities/[id]/products/cancel.ts index 216e5aa..d124656 100644 --- a/src/api/sales/opportunities/[id]/products/cancel.ts +++ b/src/api/sales/opportunities/[id]/products/cancel.ts @@ -13,10 +13,10 @@ const cancelProductSchema = z }) .strict(); -/* PATCH /v1/sales/opportunities/:identifier/products/:productId/cancel */ +/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/cancel */ export default createRoute( "patch", - ["/opportunities/:identifier/products/:productId/cancel"], + ["/opportunities/opportunity/:identifier/products/:productId/cancel"], async (c) => { const identifier = c.req.param("identifier"); const productId = Number(c.req.param("productId")); diff --git a/src/api/sales/opportunities/[id]/products/delete.ts b/src/api/sales/opportunities/[id]/products/delete.ts index b82c108..5ea5fe8 100644 --- a/src/api/sales/opportunities/[id]/products/delete.ts +++ b/src/api/sales/opportunities/[id]/products/delete.ts @@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status"; import { authMiddleware } from "../../../../middleware/authorization"; import GenericError from "../../../../../Errors/GenericError"; -/* DELETE /v1/sales/opportunities/:identifier/products/:productId */ +/* DELETE /v1/sales/opportunities/opportunity/:identifier/products/:productId */ export default createRoute( "delete", - ["/opportunities/:identifier/products/:productId"], + ["/opportunities/opportunity/:identifier/products/:productId"], async (c) => { const identifier = c.req.param("identifier"); const productId = Number(c.req.param("productId")); diff --git a/src/api/sales/opportunities/[id]/products/fetchAll.ts b/src/api/sales/opportunities/[id]/products/fetchAll.ts index 2c0caa8..389ee3a 100644 --- a/src/api/sales/opportunities/[id]/products/fetchAll.ts +++ b/src/api/sales/opportunities/[id]/products/fetchAll.ts @@ -4,10 +4,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; import { authMiddleware } from "../../../../middleware/authorization"; -/* GET /v1/sales/opportunities/:identifier/products */ +/* GET /v1/sales/opportunities/opportunity/:identifier/products */ export default createRoute( "get", - ["/opportunities/:identifier/products"], + ["/opportunities/opportunity/:identifier/products"], async (c) => { const identifier = c.req.param("identifier"); const item = await opportunities.fetchRecord(identifier); diff --git a/src/api/sales/opportunities/[id]/products/laborOptions.ts b/src/api/sales/opportunities/[id]/products/laborOptions.ts index 8f71cdb..c8ad2d8 100644 --- a/src/api/sales/opportunities/[id]/products/laborOptions.ts +++ b/src/api/sales/opportunities/[id]/products/laborOptions.ts @@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; import { authMiddleware } from "../../../../middleware/authorization"; -/* GET /v1/sales/opportunities/:identifier/products/labor/options */ +/* GET /v1/sales/opportunities/opportunity/:identifier/products/labor/options */ export default createRoute( "get", - ["/opportunities/:identifier/products/labor/options"], + ["/opportunities/opportunity/:identifier/products/labor/options"], async (c) => { const identifier = c.req.param("identifier"); diff --git a/src/api/sales/opportunities/[id]/products/resequence.ts b/src/api/sales/opportunities/[id]/products/resequence.ts index f45347f..8774a77 100644 --- a/src/api/sales/opportunities/[id]/products/resequence.ts +++ b/src/api/sales/opportunities/[id]/products/resequence.ts @@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status"; import { authMiddleware } from "../../../../middleware/authorization"; import { z } from "zod"; -/* PATCH /v1/sales/opportunities/:identifier/products/sequence */ +/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/sequence */ export default createRoute( "patch", - ["/opportunities/:identifier/products/sequence"], + ["/opportunities/opportunity/:identifier/products/sequence"], async (c) => { const identifier = c.req.param("identifier"); const body = await c.req.json(); diff --git a/src/api/sales/opportunities/[id]/products/update.ts b/src/api/sales/opportunities/[id]/products/update.ts index 8f4582d..b5625ac 100644 --- a/src/api/sales/opportunities/[id]/products/update.ts +++ b/src/api/sales/opportunities/[id]/products/update.ts @@ -56,10 +56,10 @@ const upsertCustomTextField = ( return next; }; -/* PATCH /v1/sales/opportunities/:identifier/products/:productId/edit */ +/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/edit */ export default createRoute( "patch", - ["/opportunities/:identifier/products/:productId/edit"], + ["/opportunities/opportunity/:identifier/products/:productId/edit"], async (c) => { const identifier = c.req.param("identifier"); const productId = Number(c.req.param("productId")); diff --git a/src/api/sales/opportunities/[id]/quotes/commit.ts b/src/api/sales/opportunities/[id]/quotes/commit.ts index 14bc607..5b892c7 100644 --- a/src/api/sales/opportunities/[id]/quotes/commit.ts +++ b/src/api/sales/opportunities/[id]/quotes/commit.ts @@ -19,10 +19,10 @@ const commitQuoteSchema = z .strict() .optional(); -/* POST /v1/sales/opportunities/:identifier/quote/commit */ +/* POST /v1/sales/opportunities/opportunity/:identifier/quote/commit */ export default createRoute( "post", - ["/opportunities/:identifier/quote/commit"], + ["/opportunities/opportunity/:identifier/quote/commit"], async (c) => { const identifier = c.req.param("identifier"); const body = await c.req.json().catch(() => undefined); diff --git a/src/api/sales/opportunities/[id]/quotes/download.ts b/src/api/sales/opportunities/[id]/quotes/download.ts index c1283d8..6ce3378 100644 --- a/src/api/sales/opportunities/[id]/quotes/download.ts +++ b/src/api/sales/opportunities/[id]/quotes/download.ts @@ -9,10 +9,10 @@ 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 */ +/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/download?fetchAction=download|print */ export default createRoute( "get", - ["/opportunities/:identifier/quote/:quoteId/download"], + ["/opportunities/opportunity/:identifier/quote/:quoteId/download"], async (c) => { const quoteId = c.req.param("quoteId"); const user = c.get("user"); diff --git a/src/api/sales/opportunities/[id]/quotes/fetchAll.ts b/src/api/sales/opportunities/[id]/quotes/fetchAll.ts index 5549a77..c5b0950 100644 --- a/src/api/sales/opportunities/[id]/quotes/fetchAll.ts +++ b/src/api/sales/opportunities/[id]/quotes/fetchAll.ts @@ -5,10 +5,10 @@ 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 */ +/* GET /v1/sales/opportunities/opportunity/:identifier/quotes */ export default createRoute( "get", - ["/opportunities/:identifier/quotes"], + ["/opportunities/opportunity/:identifier/quotes"], async (c) => { const identifier = c.req.param("identifier"); const includeRegenData = c.req.query("includeRegenData") === "true"; diff --git a/src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts b/src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts index e0fd706..2095b29 100644 --- a/src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts +++ b/src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts @@ -5,10 +5,10 @@ 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 */ +/* GET /v1/sales/opportunities/opportunity/:identifier/quotes/downloads */ export default createRoute( "get", - ["/opportunities/:identifier/quotes/downloads"], + ["/opportunities/opportunity/:identifier/quotes/downloads"], async (c) => { const identifier = c.req.param("identifier"); diff --git a/src/api/sales/opportunities/[id]/quotes/preview.ts b/src/api/sales/opportunities/[id]/quotes/preview.ts index 9312dbe..bd47321 100644 --- a/src/api/sales/opportunities/[id]/quotes/preview.ts +++ b/src/api/sales/opportunities/[id]/quotes/preview.ts @@ -5,10 +5,10 @@ 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 */ +/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/preview */ export default createRoute( "get", - ["/opportunities/:identifier/quote/:quoteId/preview"], + ["/opportunities/opportunity/:identifier/quote/:quoteId/preview"], async (c) => { const identifier = c.req.param("identifier"); const quoteId = c.req.param("quoteId"); diff --git a/src/api/sales/opportunities/[id]/refresh.ts b/src/api/sales/opportunities/[id]/refresh.ts index 6caecad..0ef1395 100644 --- a/src/api/sales/opportunities/[id]/refresh.ts +++ b/src/api/sales/opportunities/[id]/refresh.ts @@ -4,10 +4,10 @@ import { apiResponse } from "../../../../modules/api-utils/apiResponse"; import { ContentfulStatusCode } from "hono/utils/http-status"; import { authMiddleware } from "../../../middleware/authorization"; -/* POST /v1/sales/opportunities/:identifier/refresh */ +/* POST /v1/sales/opportunities/opportunity/:identifier/refresh */ export default createRoute( "post", - ["/opportunities/:identifier/refresh"], + ["/opportunities/opportunity/:identifier/refresh"], async (c) => { const identifier = c.req.param("identifier"); const item = await opportunities.fetchItem(identifier); diff --git a/src/api/sales/opportunities/[id]/update.ts b/src/api/sales/opportunities/[id]/update.ts index 2ffa570..de9b37d 100644 --- a/src/api/sales/opportunities/[id]/update.ts +++ b/src/api/sales/opportunities/[id]/update.ts @@ -34,10 +34,10 @@ const updateSchema = z message: "At least one field must be provided", }); -/* PATCH /v1/sales/opportunities/:identifier */ +/* PATCH /v1/sales/opportunities/opportunity/:identifier */ export default createRoute( "patch", - ["/opportunities/:identifier"], + ["/opportunities/opportunity/:identifier"], async (c) => { const identifier = c.req.param("identifier"); const body = await c.req.json(); diff --git a/src/api/sales/opportunities/[id]/workflow/dispatch.ts b/src/api/sales/opportunities/[id]/workflow/dispatch.ts index 0747cc5..72eec34 100644 --- a/src/api/sales/opportunities/[id]/workflow/dispatch.ts +++ b/src/api/sales/opportunities/[id]/workflow/dispatch.ts @@ -51,6 +51,10 @@ const dispatchSchema = z.discriminatedUnion("action", [ needsRevision: z.boolean().optional(), }), }), + z.object({ + action: z.literal("markReadyToSend"), + payload: basePayload, + }), z.object({ action: z.literal("confirmQuote"), payload: basePayload, @@ -91,10 +95,10 @@ const dispatchSchema = z.discriminatedUnion("action", [ // ── Route ───────────────────────────────────────────────────────────────── -/* POST /v1/sales/opportunities/:identifier/workflow */ +/* POST /v1/sales/opportunities/opportunity/:identifier/workflow */ export default createRoute( "post", - ["/opportunities/:identifier/workflow"], + ["/opportunities/opportunity/:identifier/workflow"], async (c) => { try { const identifier = c.req.param("identifier"); diff --git a/src/api/sales/opportunities/[id]/workflow/history.ts b/src/api/sales/opportunities/[id]/workflow/history.ts index fee4ca7..8153b20 100644 --- a/src/api/sales/opportunities/[id]/workflow/history.ts +++ b/src/api/sales/opportunities/[id]/workflow/history.ts @@ -73,10 +73,10 @@ function extractCloseDate( // ROUTE // ═══════════════════════════════════════════════════════════════════════════ -/* GET /v1/sales/opportunities/:identifier/workflow/history */ +/* GET /v1/sales/opportunities/opportunity/:identifier/workflow/history */ export default createRoute( "get", - ["/opportunities/:identifier/workflow/history"], + ["/opportunities/opportunity/:identifier/workflow/history"], async (c) => { try { const identifier = c.req.param("identifier"); diff --git a/src/api/sales/opportunities/[id]/workflow/status.ts b/src/api/sales/opportunities/[id]/workflow/status.ts index 827803e..eb4a470 100644 --- a/src/api/sales/opportunities/[id]/workflow/status.ts +++ b/src/api/sales/opportunities/[id]/workflow/status.ts @@ -47,6 +47,15 @@ const ACTION_MAP: Record = { ], [OpportunityStatus.New]: [ + { + action: "markReadyToSend", + label: "Mark Ready to Send", + targetStatuses: [ + { key: "ReadyToSend", id: OpportunityStatus.ReadyToSend }, + ], + requiresNote: false, + requiresPermission: null, + }, { action: "sendQuote", label: "Send Quote (skip review)", @@ -81,9 +90,9 @@ const ACTION_MAP: Record = { [OpportunityStatus.InternalReview]: [ { action: "reviewDecision", - label: "Approve (move to Pending Sent)", + label: "Approve (move to Ready to Send)", targetStatuses: [ - { key: "PendingSent", id: OpportunityStatus.PendingSent }, + { key: "ReadyToSend", id: OpportunityStatus.ReadyToSend }, ], requiresNote: true, requiresPermission: null, @@ -117,7 +126,32 @@ const ACTION_MAP: Record = { }, ], + [OpportunityStatus.ReadyToSend]: [ + { + action: "sendQuote", + label: "Send Quote to Customer", + targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }], + requiresNote: false, + requiresPermission: null, + payloadHints: { + quoteConfirmed: "boolean — mark confirmed simultaneously", + won: "boolean — immediate win", + lost: "boolean — immediate rejection", + needsRevision: "boolean — needs revision", + }, + }, + ], + [OpportunityStatus.PendingSent]: [ + { + action: "markReadyToSend", + label: "Mark Ready to Send", + targetStatuses: [ + { key: "ReadyToSend", id: OpportunityStatus.ReadyToSend }, + ], + requiresNote: false, + requiresPermission: null, + }, { action: "sendQuote", label: "Send Quote to Customer", @@ -217,6 +251,15 @@ const ACTION_MAP: Record = { ], [OpportunityStatus.Active]: [ + { + action: "markReadyToSend", + label: "Mark Ready to Send", + targetStatuses: [ + { key: "ReadyToSend", id: OpportunityStatus.ReadyToSend }, + ], + requiresNote: false, + requiresPermission: null, + }, { action: "resendQuote", label: "Send Revised Quote", @@ -294,10 +337,10 @@ const ACTION_MAP: Record = { // ROUTE // ═══════════════════════════════════════════════════════════════════════════ -/* GET /v1/sales/opportunities/:identifier/workflow */ +/* GET /v1/sales/opportunities/opportunity/:identifier/workflow */ export default createRoute( "get", - ["/opportunities/:identifier/workflow"], + ["/opportunities/opportunity/:identifier/workflow"], async (c) => { try { const identifier = c.req.param("identifier"); diff --git a/src/api/sales/opportunities/fetchByUser.ts b/src/api/sales/opportunities/fetchByUser.ts new file mode 100644 index 0000000..7397dd9 --- /dev/null +++ b/src/api/sales/opportunities/fetchByUser.ts @@ -0,0 +1,25 @@ +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"; + +/* GET /v1/sales/opportunities/@me */ +export default createRoute( + "get", + ["/opportunities/@me"], + async (c) => { + const user = c.get("user"); + const includeClosed = c.req.query("includeClosed") === "true"; + + const data = await opportunities.fetchByUser(user.id, { includeClosed }); + + const response = apiResponse.successful( + "Opportunities fetched successfully!", + data.map((item) => item.toJson()), + ); + + return c.json(response, response.status as ContentfulStatusCode); + }, + authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }), +); diff --git a/src/api/sales/opportunities/fetchByUserId.ts b/src/api/sales/opportunities/fetchByUserId.ts new file mode 100644 index 0000000..a76de1b --- /dev/null +++ b/src/api/sales/opportunities/fetchByUserId.ts @@ -0,0 +1,25 @@ +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"; + +/* GET /v1/sales/opportunities/user/:id */ +export default createRoute( + "get", + ["/opportunities/user/:id"], + async (c) => { + const userId = c.req.param("id"); + const includeClosed = c.req.query("includeClosed") === "true"; + + const data = await opportunities.fetchByUser(userId, { includeClosed }); + + const response = apiResponse.successful( + "Opportunities fetched successfully!", + data.map((item) => item.toJson()), + ); + + return c.json(response, response.status as ContentfulStatusCode); + }, + authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }), +); diff --git a/src/api/sales/opportunities/metrics.ts b/src/api/sales/opportunities/metrics.ts new file mode 100644 index 0000000..ca8ae57 --- /dev/null +++ b/src/api/sales/opportunities/metrics.ts @@ -0,0 +1,95 @@ +import { createRoute } from "../../../modules/api-utils/createRoute"; +import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { ContentfulStatusCode } from "hono/utils/http-status"; +import { authMiddleware } from "../../middleware/authorization"; +import GenericError from "../../../Errors/GenericError"; +import { + getSalesOpportunityMetricsAll, + getSalesOpportunityMetricsForMember, + refreshSalesOpportunityMetricsCache, +} from "../../../modules/cache/salesOpportunityMetricsCache"; + +/* GET /v1/sales/opportunities/metrics */ +export default createRoute( + "get", + ["/opportunities/metrics"], + async (c) => { + const user = c.get("user"); + const scope = (c.req.query("scope") ?? "me").toLowerCase(); + const requestedIdentifier = c.req.query("identifier")?.trim().toLowerCase(); + const currentUserIdentifier = user?.cwIdentifier?.trim().toLowerCase(); + + if ( + scope === "all" && + !(await user.hasPermission("sales.opportunity.metrics.all")) + ) { + throw new GenericError({ + name: "InsufficientPermission", + message: + "You do not have permission to view metrics for all active members.", + status: 403, + }); + } + + const usingIdentifierOverride = + scope !== "all" && + !!requestedIdentifier && + requestedIdentifier !== currentUserIdentifier; + + if ( + usingIdentifierOverride && + !(await user.hasPermission( + "sales.opportunity.metrics.identifier.override", + )) + ) { + throw new GenericError({ + name: "InsufficientPermission", + message: + "You do not have permission to query metrics by overriding the member identifier.", + status: 403, + }); + } + + const requireWarmCache = async () => { + const all = await getSalesOpportunityMetricsAll(); + if (all) return all; + await refreshSalesOpportunityMetricsCache(); + return getSalesOpportunityMetricsAll(); + }; + + if (scope === "all") { + const all = await requireWarmCache(); + const response = apiResponse.successful( + "Sales opportunity metrics fetched successfully!", + all, + ); + return c.json(response, response.status as ContentfulStatusCode); + } + + const targetIdentifier = requestedIdentifier ?? currentUserIdentifier; + + if (!targetIdentifier) { + const response = apiResponse.successful( + "Sales opportunity metrics fetched successfully!", + null, + ); + return c.json(response, response.status as ContentfulStatusCode); + } + + let metrics = await getSalesOpportunityMetricsForMember(targetIdentifier); + if (!metrics) { + await refreshSalesOpportunityMetricsCache(); + metrics = await getSalesOpportunityMetricsForMember(targetIdentifier); + } + + const response = apiResponse.successful( + "Sales opportunity metrics fetched successfully!", + { + identifier: targetIdentifier, + metrics, + }, + ); + return c.json(response, response.status as ContentfulStatusCode); + }, + authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }), +); diff --git a/src/controllers/OpportunityController.ts b/src/controllers/OpportunityController.ts index 43f79ae..c247031 100644 --- a/src/controllers/OpportunityController.ts +++ b/src/controllers/OpportunityController.ts @@ -45,6 +45,8 @@ import { type QuoteMetadata, } from "../modules/pdf-utils"; import { generatedQuotes } from "../managers/generatedQuotes"; +import { getExpectedSalesTaxRate } from "../modules/sales-utils/expectedSalesTax"; +import { normalizeProbabilityPercent } from "../modules/sales-utils/normalizeProbability"; /** * Opportunity Controller @@ -106,6 +108,7 @@ export class OpportunityController { public companyId: string | null; public cwLastUpdated: Date | null; + public cwDateEntered: Date | null; // Local product display order — array of CW forecast item IDs. // When non-empty, fetchProducts() uses this instead of CW sequenceNumber. @@ -223,6 +226,7 @@ export class OpportunityController { this.companyId = data.companyId; this.cwLastUpdated = data.cwLastUpdated; + this.cwDateEntered = data.cwDateEntered ?? null; this.productSequence = data.productSequence; this.createdAt = data.createdAt; @@ -366,7 +370,7 @@ export class OpportunityController { customerPO: item.customerPO ?? null, totalSalesTax: item.totalSalesTax ?? 0, - probability: Number(item.probability?.name) || 0, + probability: normalizeProbabilityPercent(item.probability?.name), locationName: item.location?.name ?? null, locationCwId: item.location?.id ?? null, @@ -390,6 +394,9 @@ export class OpportunityController { cwLastUpdated: item._info?.lastUpdated ? new Date(item._info.lastUpdated) : new Date(), + cwDateEntered: item._info?.dateEntered + ? new Date(item._info.dateEntered) + : null, }; } @@ -690,6 +697,17 @@ export class OpportunityController { }; }); + const taxableSubTotal = activeProducts.reduce((sum, item) => { + if (!item.taxableFlag) return sum; + + 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; + + return sum + (isLabor ? 1 : quantity) * unitPrice; + }, 0); + const quoteDescription = this.name; const primaryContactFullName = [ @@ -705,12 +723,9 @@ export class OpportunityController { this.companyName || "Customer"; - const subTotal = lineItems.reduce( - (sum, item) => sum + item.qty * item.unitPrice, - 0, + const normalizedTaxRate = getExpectedSalesTaxRate( + site?.address ?? companyJson?.cw_Data?.address, ); - const normalizedTaxRate = - subTotal > 0 ? Math.max(0, this.totalSalesTax / subTotal) : 0; const taxLabel = normalizedTaxRate > 0 ? `Sales Tax (${(normalizedTaxRate * 100).toFixed(2)}%)` @@ -778,6 +793,7 @@ export class OpportunityController { description: quoteDescription, }, lineItems, + taxableSubtotal: taxableSubTotal, quoteNarrative, tax: { rate: normalizedTaxRate, @@ -1542,6 +1558,21 @@ export class OpportunityController { * Serializes the opportunity into a safe, API-friendly object. */ public toJson(): Record { + const siteAddress = this._siteData?.address; + const companyAddress = this._company?.cw_Data?.company + ? { + line1: this._company.cw_Data.company.addressLine1, + line2: this._company.cw_Data.company.addressLine2, + city: this._company.cw_Data.company.city, + state: this._company.cw_Data.company.state, + zip: this._company.cw_Data.company.zip, + country: this._company.cw_Data.company.country?.name ?? null, + } + : null; + const expectedSalesTax = getExpectedSalesTaxRate( + siteAddress ?? companyAddress, + ); + return { id: this.id, cwOpportunityId: this.cwOpportunityId, @@ -1597,6 +1628,7 @@ export class OpportunityController { : null, customerPO: this.customerPO, totalSalesTax: this.totalSalesTax, + expectedSalesTax, probability: this.probability, location: this.locationCwId ? { id: this.locationCwId, name: this.locationName } diff --git a/src/index.ts b/src/index.ts index 4e9f9ea..f9aba32 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventor import { listenInventoryAdjustments } from "./modules/cw-utils/procurement/listenInventoryAdjustments"; import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities"; import { refreshOpportunityCache } from "./modules/cache/opportunityCache"; +import { refreshSalesOpportunityMetricsCache } from "./modules/cache/salesOpportunityMetricsCache"; import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers"; import { refreshCwMembers } from "./modules/cw-utils/members/refreshCwMembers"; import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields"; @@ -178,6 +179,22 @@ setInterval( 5 * 60 * 1000, ); +// Refresh sales opportunity metrics cache for active CW members every 5 minutes +await safeStartup( + "refreshSalesOpportunityMetricsCache", + () => refreshSalesOpportunityMetricsCache({ forceColdLoad: true }), +); +setInterval( + () => { + return refreshSalesOpportunityMetricsCache().catch((err) => + console.error( + `[interval] refreshSalesOpportunityMetricsCache failed: ${briefErr(err)}`, + ), + ); + }, + 5 * 60 * 1000, +); + // Refresh CW identifiers for all users every 30 minutes await safeStartup("refreshCwIdentifiers", refreshCwIdentifiers); setInterval( diff --git a/src/managers/opportunities.ts b/src/managers/opportunities.ts index 7d38ca3..4e41c5f 100644 --- a/src/managers/opportunities.ts +++ b/src/managers/opportunities.ts @@ -351,7 +351,7 @@ export const opportunities = { const skip = (Math.max(page, 1) - 1) * rpp; const items = await prisma.opportunity.findMany({ - where: opts?.includeClosed ? undefined : { closedFlag: false }, + where: opts?.includeClosed ? undefined : { closedDate: null }, include: { company: true }, skip, take: rpp, @@ -401,7 +401,7 @@ export const opportunities = { const items = await prisma.opportunity.findMany({ where: { - ...(opts?.includeClosed ? {} : { closedFlag: false }), + ...(opts?.includeClosed ? {} : { closedDate: null }), OR: [ { name: { contains: query, mode: "insensitive" } }, { companyName: { contains: query, mode: "insensitive" } }, @@ -445,7 +445,7 @@ export const opportunities = { */ async count(opts?: { openOnly?: boolean }): Promise { return prisma.opportunity.count({ - where: opts?.openOnly ? { closedFlag: false } : undefined, + where: opts?.openOnly ? { closedDate: null } : undefined, }); }, @@ -469,7 +469,7 @@ export const opportunities = { return prisma.opportunity.count({ where: { - ...(opts?.includeClosed ? {} : { closedFlag: false }), + ...(opts?.includeClosed ? {} : { closedDate: null }), OR: [ { name: { contains: query, mode: "insensitive" } }, { companyName: { contains: query, mode: "insensitive" } }, @@ -504,7 +504,7 @@ export const opportunities = { const items = await prisma.opportunity.findMany({ where: { companyId, - ...(opts?.includeClosed ? {} : { closedFlag: false }), + ...(opts?.includeClosed ? {} : { closedDate: null }), }, include: { company: true }, orderBy: { expectedCloseDate: "asc" }, @@ -526,6 +526,68 @@ export const opportunities = { ); }, + /** + * Fetch Opportunities by User + * + * Returns all opportunities where the given user (by internal User ID) is + * assigned as the primary or secondary sales rep. Resolves the user's + * ConnectWise member identifier from the DB, then queries opportunities by + * that identifier. + * + * Uses the **cache-only** strategy (same as `fetchPages`). + * + * @param userId - Internal User `id` (cuid) + * @param opts - Optional filters + * @returns {Promise} + */ + async fetchByUser( + userId: string, + opts?: { includeClosed?: boolean }, + ): Promise { + const user = await prisma.user.findFirst({ + where: { id: userId }, + select: { cwIdentifier: true }, + }); + + if (!user) { + throw new GenericError({ + message: "User not found", + name: "UserNotFound", + cause: `No user exists with id '${userId}'`, + status: 404, + }); + } + + if (!user.cwIdentifier) return []; + + const items = await prisma.opportunity.findMany({ + where: { + OR: [ + { primarySalesRepIdentifier: user.cwIdentifier }, + { secondarySalesRepIdentifier: user.cwIdentifier }, + ], + ...(opts?.includeClosed ? {} : { closedDate: null }), + }, + include: { company: true }, + orderBy: { createdAt: "desc" }, + }); + + return Promise.all( + items.map(async (item) => + new OpportunityController(item, { + company: item.company + ? await buildCompanyController(item.company, { + strategy: "cache-only", + }) + : undefined, + activities: await buildActivities(item.cwOpportunityId, { + strategy: "cache-only", + }), + }), + ), + ); + }, + /** * Delete Opportunity * diff --git a/src/modules/cache/salesOpportunityMetricsCache.ts b/src/modules/cache/salesOpportunityMetricsCache.ts new file mode 100644 index 0000000..b425e55 --- /dev/null +++ b/src/modules/cache/salesOpportunityMetricsCache.ts @@ -0,0 +1,900 @@ +import { prisma, redis } from "../../constants"; +import { getCachedOppCwData, getCachedProducts } from "./opportunityCache"; +import { OpportunityStatus } from "../../workflows/wf.opportunity"; +import { events } from "../globalEvents"; +import { opportunities } from "../../managers/opportunities"; +import { normalizeProbabilityRatio } from "../sales-utils/normalizeProbability"; + +const METRICS_CACHE_TTL_MS = 10 * 60 * 1000; +const ALL_MEMBERS_KEY = "sales:metrics:members:all"; +const MEMBER_KEY_PREFIX = "sales:metrics:member:"; +const OPP_REVENUE_KEY_PREFIX = "sales:metrics:oppRevenue:"; +const PRODUCT_FETCH_CONCURRENCY = 6; +const PRODUCT_LOOKUP_TIMEOUT_MS = 35_000; +const LOG_PREFIX = "[cache:salesMetrics]"; + +const log = (message: string) => { + const ts = new Date().toISOString(); + console.log(`${LOG_PREFIX} ${ts} ${message}`); +}; + +let salesMetricsRefreshInFlight: Promise | null = null; + +const memberKey = (identifier: string) => + `${MEMBER_KEY_PREFIX}${identifier.toLowerCase()}`; +const oppRevenueKey = (cwOpportunityId: number) => + `${OPP_REVENUE_KEY_PREFIX}${cwOpportunityId}`; + +const deleteKeysByPrefix = async (prefix: string) => { + const keys = await redis.keys(`${prefix}*`); + if (keys.length === 0) return 0; + + await redis.del(...keys); + return keys.length; +}; + +export interface OpportunityBreakdownEntry { + id: string; + cwId: number; + name: string; + revenue: number; + taxableRevenue: number; + nonTaxableRevenue: number; + /** Probability as a 0–100 percent value */ + probability: number; + weightedRevenue: number; + closedDate: string | null; +} + +export interface MemberSalesMetrics { + memberIdentifier: string; + memberName: string; + generatedAt: string; + pipelineRevenue: number; + closedWonRevenueMtd: number; + closedWonRevenueYtd: number; + winCount: { mtd: number; ytd: number }; + lossCount: { mtd: number; ytd: number }; + avgDaysToClose: number; + openOpportunityCount: number; + wonOpportunityCount: { mtd: number; ytd: number }; + lostOpportunityCount: { mtd: number; ytd: number }; + closedOpportunityCount: { mtd: number; ytd: number }; + weightedPipelineRevenue: number; + taxablePipelineRevenue: number; + nonTaxablePipelineRevenue: number; + avgOpenDealSize: number; + avgWonDealSize: { mtd: number; ytd: number }; + winRate: { mtd: number; ytd: number }; + lossRate: { mtd: number; ytd: number }; + assignedOpportunityCount: number; + cacheHitCount: number; + cacheMissCount: number; + cacheHitRate: number; + opportunityBreakdown: { + pipeline: OpportunityBreakdownEntry[]; + closedWonMtd: OpportunityBreakdownEntry[]; + closedWonYtd: OpportunityBreakdownEntry[]; + closedLostMtd: OpportunityBreakdownEntry[]; + closedLostYtd: OpportunityBreakdownEntry[]; + }; +} + +export interface SalesMetricsCacheEnvelope { + generatedAt: string; + activeMemberCount: number; + memberIdentifiers: string[]; + members: Record; +} + +interface OpportunityRevenue { + totalRevenue: number; + taxableRevenue: number; + nonTaxableRevenue: number; + cacheHit: boolean; +} + +interface CachedOpportunityRevenue { + totalRevenue: number; + taxableRevenue: number; + nonTaxableRevenue: number; +} + +interface OpportunityRow { + id: string; + cwOpportunityId: number; + name: string; + primarySalesRepIdentifier: string | null; + secondarySalesRepIdentifier: string | null; + statusCwId: number | null; + statusName: string | null; + closedFlag: boolean; + dateBecameLead: Date | null; + closedDate: Date | null; + probability: number; +} + +interface RefreshSalesOpportunityMetricsCacheOptions { + forceColdLoad?: boolean; +} + +const roundCurrency = (value: number) => Math.round(value * 100) / 100; + +const daysBetween = (start: Date, end: Date): number => { + const msPerDay = 1000 * 60 * 60 * 24; + return Math.max(0, (end.getTime() - start.getTime()) / msPerDay); +}; + +const startOfMonthUtc = (input: Date): Date => + new Date(Date.UTC(input.getUTCFullYear(), input.getUTCMonth(), 1, 0, 0, 0)); + +const startOfYearUtc = (input: Date): Date => + new Date(Date.UTC(input.getUTCFullYear(), 0, 1, 0, 0, 0)); + +const toFinite = (value: unknown): number => { + const n = Number(value); + if (!Number.isFinite(n)) return 0; + return n; +}; + +const isWon = (opp: { + statusCwId: number | null; + statusName: string | null; + closedFlag: boolean; +}) => { + if (opp.statusCwId === OpportunityStatus.Won) return true; + if (opp.statusName?.toLowerCase().includes("won")) return true; + if (opp.closedFlag && opp.statusName?.toLowerCase().includes("won")) + return true; + return false; +}; + +const isLost = (opp: { + statusCwId: number | null; + statusName: string | null; + closedFlag: boolean; +}) => { + if (opp.statusCwId === OpportunityStatus.Lost) return true; + if (opp.statusName?.toLowerCase().includes("lost")) return true; + if (opp.closedFlag && opp.statusName?.toLowerCase().includes("lost")) + return true; + return false; +}; + +const isClosedOpportunity = (opp: { + statusCwId: number | null; + statusName: string | null; + closedFlag: boolean; +}) => { + if (opp.closedFlag) return true; + if (isWon(opp)) return true; + if (isLost(opp)) return true; + return false; +}; + +const buildCancellationMap = (procProducts: any[]) => { + const map = new Map(); + + for (const pp of procProducts) { + const rawForecastDetailId = pp?.forecastDetailId; + const forecastDetailId = + typeof rawForecastDetailId === "number" + ? rawForecastDetailId + : Number(rawForecastDetailId); + + if (Number.isFinite(forecastDetailId) && forecastDetailId > 0) { + map.set(forecastDetailId, pp); + } + } + + return map; +}; + +const computeRevenueFromProductsBlob = ( + blob: any, +): Omit => { + const forecastItems = Array.isArray(blob?.forecast?.forecastItems) + ? blob.forecast.forecastItems + : []; + const procProducts = Array.isArray(blob?.procProducts) + ? blob.procProducts + : []; + + const cancellationMap = buildCancellationMap(procProducts); + + let totalRevenue = 0; + let taxableRevenue = 0; + + for (const item of forecastItems) { + if (!cancellationMap.has(item?.id)) continue; + if (!item?.includeFlag) continue; + + const quantity = Math.max(0, toFinite(item?.quantity)); + const revenue = toFinite(item?.revenue); + + const cancellation = cancellationMap.get(item.id); + const cancelledFlag = Boolean(cancellation?.cancelledFlag); + const quantityCancelled = Math.max( + 0, + toFinite(cancellation?.quantityCancelled), + ); + + if (cancelledFlag && quantity > 0 && quantityCancelled >= quantity) + continue; + + const ratio = + quantity > 0 ? Math.max(0, (quantity - quantityCancelled) / quantity) : 1; + const effectiveRevenue = revenue * ratio; + + totalRevenue += effectiveRevenue; + if (item?.taxableFlag) taxableRevenue += effectiveRevenue; + } + + const nonTaxableRevenue = totalRevenue - taxableRevenue; + + return { + totalRevenue: roundCurrency(totalRevenue), + taxableRevenue: roundCurrency(taxableRevenue), + nonTaxableRevenue: roundCurrency(nonTaxableRevenue), + }; +}; + +const computeRevenueFromControllers = ( + products: Array<{ + includeFlag: boolean; + taxableFlag: boolean; + cancellationType: "full" | "partial" | null; + effectiveRevenue: number; + }>, +): Omit => { + let totalRevenue = 0; + let taxableRevenue = 0; + + for (const item of products) { + if (!item.includeFlag) continue; + if (item.cancellationType === "full") continue; + + const effectiveRevenue = Math.max(0, toFinite(item.effectiveRevenue)); + totalRevenue += effectiveRevenue; + if (item.taxableFlag) taxableRevenue += effectiveRevenue; + } + + const nonTaxableRevenue = totalRevenue - taxableRevenue; + + return { + totalRevenue: roundCurrency(totalRevenue), + taxableRevenue: roundCurrency(taxableRevenue), + nonTaxableRevenue: roundCurrency(nonTaxableRevenue), + }; +}; + +const readCachedOpportunityRevenue = async ( + cwOpportunityId: number, +): Promise => { + const raw = await redis.get(oppRevenueKey(cwOpportunityId)); + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as CachedOpportunityRevenue; + return { + totalRevenue: toFinite(parsed.totalRevenue), + taxableRevenue: toFinite(parsed.taxableRevenue), + nonTaxableRevenue: toFinite(parsed.nonTaxableRevenue), + }; + } catch { + return null; + } +}; + +const writeCachedOpportunityRevenue = async ( + cwOpportunityId: number, + revenue: Omit, +) => { + await redis.set( + oppRevenueKey(cwOpportunityId), + JSON.stringify(revenue), + "PX", + METRICS_CACHE_TTL_MS, + ); +}; + +const resolveProbabilityRatio = async (opp: { + cwOpportunityId: number; + probability: number; +}): Promise => { + const fromDb = normalizeProbabilityRatio(opp.probability); + if (fromDb > 0) return fromDb; + + const cachedCwOpp = await getCachedOppCwData(opp.cwOpportunityId); + if (!cachedCwOpp) return 0; + + const rawProbability = + cachedCwOpp?.probability?.name ?? cachedCwOpp?.probability ?? 0; + return normalizeProbabilityRatio(rawProbability); +}; + +const getOpportunityRevenueCacheFirst = async ( + cwOpportunityId: number, + opts?: RefreshSalesOpportunityMetricsCacheOptions, +): Promise => { + if (!opts?.forceColdLoad) { + const cachedRevenue = await readCachedOpportunityRevenue(cwOpportunityId); + if (cachedRevenue) { + return { + ...cachedRevenue, + cacheHit: true, + }; + } + } + + if (!opts?.forceColdLoad) { + const cachedProducts = await getCachedProducts(cwOpportunityId); + if (cachedProducts) { + const computed = computeRevenueFromProductsBlob(cachedProducts); + await writeCachedOpportunityRevenue(cwOpportunityId, computed); + return { + ...computed, + cacheHit: true, + }; + } + } + + try { + const opportunity = await opportunities.fetchRecord(cwOpportunityId); + const products = await opportunity.fetchProducts({ + fresh: opts?.forceColdLoad, + }); + const computed = computeRevenueFromControllers(products); + await writeCachedOpportunityRevenue(cwOpportunityId, computed); + + return { + ...computed, + cacheHit: false, + }; + } catch { + return { + totalRevenue: 0, + taxableRevenue: 0, + nonTaxableRevenue: 0, + cacheHit: false, + }; + } +}; + +const withTimeout = async ( + promise: Promise, + timeoutMs: number, +): Promise => { + return Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => reject(new Error("Timeout")), timeoutMs); + }), + ]); +}; + +async function mapWithConcurrency( + items: T[], + concurrency: number, + mapper: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let index = 0; + + const worker = async () => { + while (true) { + const current = index; + index += 1; + if (current >= items.length) return; + results[current] = await mapper(items[current]!); + } + }; + + const workers = Array.from( + { length: Math.min(concurrency, items.length) }, + () => worker(), + ); + await Promise.all(workers); + return results; +} + +const buildEmptyMetrics = ( + memberIdentifier: string, + memberName: string, + generatedAt: string, +): MemberSalesMetrics => ({ + memberIdentifier, + memberName, + generatedAt, + pipelineRevenue: 0, + closedWonRevenueMtd: 0, + closedWonRevenueYtd: 0, + winCount: { mtd: 0, ytd: 0 }, + lossCount: { mtd: 0, ytd: 0 }, + avgDaysToClose: 0, + openOpportunityCount: 0, + wonOpportunityCount: { mtd: 0, ytd: 0 }, + lostOpportunityCount: { mtd: 0, ytd: 0 }, + closedOpportunityCount: { mtd: 0, ytd: 0 }, + weightedPipelineRevenue: 0, + taxablePipelineRevenue: 0, + nonTaxablePipelineRevenue: 0, + avgOpenDealSize: 0, + avgWonDealSize: { mtd: 0, ytd: 0 }, + winRate: { mtd: 0, ytd: 0 }, + lossRate: { mtd: 0, ytd: 0 }, + assignedOpportunityCount: 0, + cacheHitCount: 0, + cacheMissCount: 0, + cacheHitRate: 0, + opportunityBreakdown: { + pipeline: [], + closedWonMtd: [], + closedWonYtd: [], + closedLostMtd: [], + closedLostYtd: [], + }, +}); + +export async function refreshSalesOpportunityMetricsCache( + opts?: RefreshSalesOpportunityMetricsCacheOptions, +): Promise { + if (salesMetricsRefreshInFlight) { + log( + "refresh requested while previous run is still in-flight; reusing existing run", + ); + return salesMetricsRefreshInFlight; + } + + salesMetricsRefreshInFlight = (async () => { + const startedAt = Date.now(); + const forceColdLoad = opts?.forceColdLoad === true; + log(`refresh started${forceColdLoad ? " | mode=cold" : " | mode=warm"}`); + + if (forceColdLoad) { + const [deletedMemberKeys, deletedRevenueKeys] = await Promise.all([ + deleteKeysByPrefix(MEMBER_KEY_PREFIX), + deleteKeysByPrefix(OPP_REVENUE_KEY_PREFIX), + redis.del(ALL_MEMBERS_KEY), + ]); + + log( + `cold-load reset completed: memberKeysCleared=${deletedMemberKeys} oppRevenueKeysCleared=${deletedRevenueKeys}`, + ); + } + + const now = new Date(); + const generatedAt = now.toISOString(); + const monthStart = startOfMonthUtc(now); + const yearStart = startOfYearUtc(now); + + try { + const activeMembers = await prisma.cwMember.findMany({ + where: { inactiveFlag: false }, + select: { + identifier: true, + firstName: true, + lastName: true, + }, + }); + + const memberIdentifiers = activeMembers.map( + (member) => member.identifier, + ); + log(`members fetched: activeMembers=${memberIdentifiers.length}`); + + const opportunityRows: OpportunityRow[] = + await prisma.opportunity.findMany({ + where: { + AND: [ + { + OR: [ + { primarySalesRepIdentifier: { in: memberIdentifiers } }, + { secondarySalesRepIdentifier: { in: memberIdentifiers } }, + ], + }, + { dateBecameLead: { gte: yearStart } }, + { + OR: [{ closedFlag: false }, { closedDate: { gte: yearStart } }], + }, + ], + }, + select: { + id: true, + cwOpportunityId: true, + name: true, + primarySalesRepIdentifier: true, + secondarySalesRepIdentifier: true, + statusCwId: true, + statusName: true, + closedFlag: true, + dateBecameLead: true, + closedDate: true, + probability: true, + }, + }); + log( + `opportunities fetched: assignedOpportunityRows=${opportunityRows.length}`, + ); + + events.emit("cache:salesMetrics:refresh:started", { + activeMemberCount: memberIdentifiers.length, + opportunityCount: opportunityRows.length, + }); + + if (memberIdentifiers.length === 0) { + const emptyEnvelope: SalesMetricsCacheEnvelope = { + generatedAt, + activeMemberCount: 0, + memberIdentifiers: [], + members: {}, + }; + await redis.set( + ALL_MEMBERS_KEY, + JSON.stringify(emptyEnvelope), + "PX", + METRICS_CACHE_TTL_MS, + ); + + events.emit("cache:salesMetrics:refresh:completed", { + activeMemberCount: 0, + opportunityCount: 0, + memberMetricsWritten: 0, + cacheHitCount: 0, + cacheMissCount: 0, + durationMs: Date.now() - startedAt, + }); + log("no active members found; wrote empty cache envelope"); + return; + } + + const revenuePhaseStartedAt = Date.now(); + let revenueLookupProcessed = 0; + let revenueLookupTimeouts = 0; + let revenueLookupFailures = 0; + let revenueLookupCacheHits = 0; + let revenueLookupCacheMisses = 0; + + log( + `revenue lookup phase started: concurrency=${PRODUCT_FETCH_CONCURRENCY} timeoutMs=${PRODUCT_LOOKUP_TIMEOUT_MS}`, + ); + + const revenueRows = await mapWithConcurrency( + opportunityRows, + PRODUCT_FETCH_CONCURRENCY, + async (opp) => { + const [revenue, probabilityRatio] = await Promise.all([ + withTimeout( + getOpportunityRevenueCacheFirst(opp.cwOpportunityId, { + forceColdLoad, + }), + PRODUCT_LOOKUP_TIMEOUT_MS, + ).catch((err: any) => { + if (err?.message === "Timeout") { + revenueLookupTimeouts += 1; + } + if (err?.message !== "Timeout") { + revenueLookupFailures += 1; + } + + return { + totalRevenue: 0, + taxableRevenue: 0, + nonTaxableRevenue: 0, + cacheHit: false, + }; + }), + resolveProbabilityRatio(opp), + ]); + + revenueLookupProcessed += 1; + if (revenue.cacheHit) revenueLookupCacheHits += 1; + if (!revenue.cacheHit) revenueLookupCacheMisses += 1; + + if (revenueLookupProcessed % 100 === 0) { + log( + `revenue lookup progress: processed=${revenueLookupProcessed}/${opportunityRows.length} cacheHits=${revenueLookupCacheHits} cacheMisses=${revenueLookupCacheMisses} timeouts=${revenueLookupTimeouts} failures=${revenueLookupFailures}`, + ); + } + + return { oppId: opp.id, revenue, probabilityRatio }; + }, + ); + + log( + `revenue lookup phase completed in ${Date.now() - revenuePhaseStartedAt}ms: processed=${revenueLookupProcessed}/${opportunityRows.length} cacheHits=${revenueLookupCacheHits} cacheMisses=${revenueLookupCacheMisses} timeouts=${revenueLookupTimeouts} failures=${revenueLookupFailures}`, + ); + + const revenueByOppId = new Map( + revenueRows.map((row) => [row.oppId, row.revenue]), + ); + const probabilityByOppId = new Map( + revenueRows.map((row) => [row.oppId, row.probabilityRatio]), + ); + + const opportunitiesByMember = new Map(); + for (const identifier of memberIdentifiers) { + opportunitiesByMember.set(identifier, []); + } + + for (const opp of opportunityRows) { + const assigned = new Set(); + if (opp.primarySalesRepIdentifier) + assigned.add(opp.primarySalesRepIdentifier); + if (opp.secondarySalesRepIdentifier) + assigned.add(opp.secondarySalesRepIdentifier); + + for (const identifier of assigned) { + const bucket = opportunitiesByMember.get(identifier); + if (!bucket) continue; + bucket.push(opp); + } + } + + const members: Record = {}; + log("member aggregation phase started"); + + for (const member of activeMembers) { + const identifier = member.identifier; + const assigned = opportunitiesByMember.get(identifier) ?? []; + const metric = buildEmptyMetrics( + identifier, + `${member.firstName} ${member.lastName}`.trim() || identifier, + generatedAt, + ); + + let wonDaysSumYtd = 0; + + for (const opp of assigned) { + const revenue = revenueByOppId.get(opp.id) ?? { + totalRevenue: 0, + taxableRevenue: 0, + nonTaxableRevenue: 0, + cacheHit: false, + }; + + metric.cacheHitCount += revenue.cacheHit ? 1 : 0; + metric.cacheMissCount += revenue.cacheHit ? 0 : 1; + + const won = isWon(opp); + const lost = isLost(opp); + const closed = isClosedOpportunity(opp); + const probabilityRatio = Math.max( + 0, + Math.min(1, toFinite(probabilityByOppId.get(opp.id))), + ); + + const breakdownEntry: OpportunityBreakdownEntry = { + id: opp.id, + cwId: opp.cwOpportunityId, + name: opp.name, + revenue: revenue.totalRevenue, + taxableRevenue: revenue.taxableRevenue, + nonTaxableRevenue: revenue.nonTaxableRevenue, + probability: roundCurrency(probabilityRatio * 100), + weightedRevenue: roundCurrency( + revenue.totalRevenue * probabilityRatio, + ), + closedDate: opp.closedDate?.toISOString() ?? null, + }; + + if (!closed) { + metric.openOpportunityCount += 1; + metric.pipelineRevenue += revenue.totalRevenue; + metric.taxablePipelineRevenue += revenue.taxableRevenue; + metric.nonTaxablePipelineRevenue += revenue.nonTaxableRevenue; + metric.weightedPipelineRevenue += + revenue.totalRevenue * probabilityRatio; + metric.opportunityBreakdown.pipeline.push(breakdownEntry); + } + + const closedDate = opp.closedDate; + if (!closedDate) continue; + + const isMtd = closedDate >= monthStart; + const isYtd = closedDate >= yearStart; + + if (won) { + if (isMtd) { + metric.winCount.mtd += 1; + metric.wonOpportunityCount.mtd += 1; + metric.closedOpportunityCount.mtd += 1; + metric.closedWonRevenueMtd += revenue.totalRevenue; + metric.opportunityBreakdown.closedWonMtd.push(breakdownEntry); + } + + if (isYtd) { + metric.winCount.ytd += 1; + metric.wonOpportunityCount.ytd += 1; + metric.closedOpportunityCount.ytd += 1; + metric.closedWonRevenueYtd += revenue.totalRevenue; + wonDaysSumYtd += daysBetween( + opp.dateBecameLead ?? closedDate, + closedDate, + ); + metric.opportunityBreakdown.closedWonYtd.push(breakdownEntry); + } + } + + if (!lost) continue; + + if (isMtd) { + metric.lossCount.mtd += 1; + metric.lostOpportunityCount.mtd += 1; + metric.closedOpportunityCount.mtd += 1; + metric.opportunityBreakdown.closedLostMtd.push(breakdownEntry); + } + + if (!isYtd) continue; + + metric.lossCount.ytd += 1; + metric.lostOpportunityCount.ytd += 1; + metric.closedOpportunityCount.ytd += 1; + metric.opportunityBreakdown.closedLostYtd.push(breakdownEntry); + } + + metric.assignedOpportunityCount = assigned.length; + + metric.avgDaysToClose = + metric.winCount.ytd > 0 ? wonDaysSumYtd / metric.winCount.ytd : 0; + + metric.avgOpenDealSize = + metric.openOpportunityCount > 0 + ? metric.pipelineRevenue / metric.openOpportunityCount + : 0; + + metric.avgWonDealSize.mtd = + metric.winCount.mtd > 0 + ? metric.closedWonRevenueMtd / metric.winCount.mtd + : 0; + + metric.avgWonDealSize.ytd = + metric.winCount.ytd > 0 + ? metric.closedWonRevenueYtd / metric.winCount.ytd + : 0; + + const closedMtd = metric.winCount.mtd + metric.lossCount.mtd; + const closedYtd = metric.winCount.ytd + metric.lossCount.ytd; + + metric.winRate.mtd = + closedMtd > 0 ? metric.winCount.mtd / closedMtd : 0; + metric.winRate.ytd = + closedYtd > 0 ? metric.winCount.ytd / closedYtd : 0; + metric.lossRate.mtd = + closedMtd > 0 ? metric.lossCount.mtd / closedMtd : 0; + metric.lossRate.ytd = + closedYtd > 0 ? metric.lossCount.ytd / closedYtd : 0; + + const totalLookups = metric.cacheHitCount + metric.cacheMissCount; + metric.cacheHitRate = + totalLookups > 0 ? metric.cacheHitCount / totalLookups : 0; + + metric.pipelineRevenue = roundCurrency(metric.pipelineRevenue); + metric.closedWonRevenueMtd = roundCurrency(metric.closedWonRevenueMtd); + metric.closedWonRevenueYtd = roundCurrency(metric.closedWonRevenueYtd); + metric.weightedPipelineRevenue = roundCurrency( + metric.weightedPipelineRevenue, + ); + metric.taxablePipelineRevenue = roundCurrency( + metric.taxablePipelineRevenue, + ); + metric.nonTaxablePipelineRevenue = roundCurrency( + metric.nonTaxablePipelineRevenue, + ); + metric.avgDaysToClose = roundCurrency(metric.avgDaysToClose); + metric.avgOpenDealSize = roundCurrency(metric.avgOpenDealSize); + metric.avgWonDealSize.mtd = roundCurrency(metric.avgWonDealSize.mtd); + metric.avgWonDealSize.ytd = roundCurrency(metric.avgWonDealSize.ytd); + metric.winRate.mtd = roundCurrency(metric.winRate.mtd); + metric.winRate.ytd = roundCurrency(metric.winRate.ytd); + metric.lossRate.mtd = roundCurrency(metric.lossRate.mtd); + metric.lossRate.ytd = roundCurrency(metric.lossRate.ytd); + metric.cacheHitRate = roundCurrency(metric.cacheHitRate); + + members[identifier] = metric; + + if (Object.keys(members).length % 25 === 0) { + log( + `member aggregation progress: aggregated=${Object.keys(members).length}/${activeMembers.length}`, + ); + } + } + + log( + `member aggregation completed: totalMembers=${Object.keys(members).length}`, + ); + + const envelope: SalesMetricsCacheEnvelope = { + generatedAt, + activeMemberCount: memberIdentifiers.length, + memberIdentifiers, + members, + }; + + const pipeline = redis.pipeline(); + log("redis write phase started"); + pipeline.set( + ALL_MEMBERS_KEY, + JSON.stringify(envelope), + "PX", + METRICS_CACHE_TTL_MS, + ); + + for (const identifier of Object.keys(members)) { + pipeline.set( + memberKey(identifier), + JSON.stringify(members[identifier]), + "PX", + METRICS_CACHE_TTL_MS, + ); + } + + await pipeline.exec(); + log("redis write phase completed"); + + const cacheHitCount = Object.values(members).reduce( + (sum, metric) => sum + metric.cacheHitCount, + 0, + ); + const cacheMissCount = Object.values(members).reduce( + (sum, metric) => sum + metric.cacheMissCount, + 0, + ); + + events.emit("cache:salesMetrics:refresh:completed", { + activeMemberCount: memberIdentifiers.length, + opportunityCount: opportunityRows.length, + memberMetricsWritten: Object.keys(members).length, + cacheHitCount, + cacheMissCount, + durationMs: Date.now() - startedAt, + }); + + log( + `completed in ${Date.now() - startedAt}ms | activeMembers=${memberIdentifiers.length} opportunities=${opportunityRows.length} memberMetrics=${Object.keys(members).length} cacheHits=${cacheHitCount} cacheMisses=${cacheMissCount}`, + ); + } catch (error) { + log(`refresh failed in ${Date.now() - startedAt}ms: ${String(error)}`); + events.emit("cache:salesMetrics:refresh:error", { + error, + durationMs: Date.now() - startedAt, + }); + throw error; + } + })().finally(() => { + salesMetricsRefreshInFlight = null; + }); + + return salesMetricsRefreshInFlight; +} + +export async function getSalesOpportunityMetricsAll(): Promise { + const raw = await redis.get(ALL_MEMBERS_KEY); + if (!raw) return null; + + try { + return JSON.parse(raw) as SalesMetricsCacheEnvelope; + } catch { + return null; + } +} + +export async function getSalesOpportunityMetricsForMember( + identifier: string, +): Promise { + const normalized = identifier.trim().toLowerCase(); + if (!normalized) return null; + + const raw = await redis.get(memberKey(normalized)); + if (raw) { + try { + return JSON.parse(raw) as MemberSalesMetrics; + } catch { + return null; + } + } + + const all = await getSalesOpportunityMetricsAll(); + if (!all) return null; + return all.members[normalized] ?? null; +} diff --git a/src/modules/cw-utils/opportunities/processOpportunityResponse.ts b/src/modules/cw-utils/opportunities/processOpportunityResponse.ts index c0b51b8..d03b563 100644 --- a/src/modules/cw-utils/opportunities/processOpportunityResponse.ts +++ b/src/modules/cw-utils/opportunities/processOpportunityResponse.ts @@ -1,4 +1,5 @@ import { CWOpportunity } from "./opportunity.types"; +import { normalizeProbabilityPercent } from "../../sales-utils/normalizeProbability"; export type ProcessedOpportunity = ReturnType< typeof processOpportunityResponse @@ -14,7 +15,7 @@ export const processOpportunityResponse = (opportunity: CWOpportunity) => ({ expectedCloseDate: opportunity.expectedCloseDate, closedDate: opportunity.closedDate, closedFlag: opportunity.closedFlag, - probability: Number(opportunity.probability?.name) || 0, + probability: normalizeProbabilityPercent(opportunity.probability?.name), type: opportunity.type ? { id: opportunity.type.id, name: opportunity.type.name } : null, diff --git a/src/modules/cw-utils/opportunities/refreshOpportunities.ts b/src/modules/cw-utils/opportunities/refreshOpportunities.ts index 655b568..962f0fd 100644 --- a/src/modules/cw-utils/opportunities/refreshOpportunities.ts +++ b/src/modules/cw-utils/opportunities/refreshOpportunities.ts @@ -22,11 +22,14 @@ export const refreshOpportunities = async () => { // 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated const dbItems = await prisma.opportunity.findMany({ - select: { id: true, cwOpportunityId: true, cwLastUpdated: true }, + select: { + id: true, + cwOpportunityId: true, + cwLastUpdated: true, + cwDateEntered: true, + }, }); - const dbMap = new Map( - dbItems.map((item) => [item.cwOpportunityId, item.cwLastUpdated]), - ); + const dbMap = new Map(dbItems.map((item) => [item.cwOpportunityId, item])); // 3. Determine stale / new IDs const staleIds: number[] = []; @@ -35,9 +38,15 @@ export const refreshOpportunities = async () => { const cwLastUpdated = summary._info?.lastUpdated ? new Date(summary._info.lastUpdated) : null; - const dbLastUpdated = dbMap.get(cwId) ?? null; + const dbItem = dbMap.get(cwId) ?? null; + const dbLastUpdated = dbItem?.cwLastUpdated ?? null; - if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) { + // Treat as stale if never synced, CW has newer data, or cwDateEntered is missing (backfill) + if ( + !dbLastUpdated || + (cwLastUpdated && cwLastUpdated > dbLastUpdated) || + !dbItem?.cwDateEntered + ) { staleIds.push(cwId); } } diff --git a/src/modules/globalEvents.ts b/src/modules/globalEvents.ts index e07f94a..e7d3e36 100644 --- a/src/modules/globalEvents.ts +++ b/src/modules/globalEvents.ts @@ -200,6 +200,24 @@ interface EventTypes { }) => void; "cache:opportunities:refresh:error": (data: { error: unknown }) => void; + // Sales Metrics Cache Events + "cache:salesMetrics:refresh:started": (data: { + activeMemberCount: number; + opportunityCount: number; + }) => void; + "cache:salesMetrics:refresh:completed": (data: { + activeMemberCount: number; + opportunityCount: number; + memberMetricsWritten: number; + cacheHitCount: number; + cacheMissCount: number; + durationMs: number; + }) => void; + "cache:salesMetrics:refresh:error": (data: { + error: unknown; + durationMs: number; + }) => void; + // ConnectWise User Defined Fields Events "cw:udf:refresh:started": () => void; "cw:udf:refresh:completed": (data: { count: number }) => void; diff --git a/src/modules/pdf-utils/generateQuote.ts b/src/modules/pdf-utils/generateQuote.ts index 50b5302..6d0b0fe 100644 --- a/src/modules/pdf-utils/generateQuote.ts +++ b/src/modules/pdf-utils/generateQuote.ts @@ -54,6 +54,7 @@ export interface QuoteData { contact: CustomerContact; quote: QuoteDetails; lineItems: QuoteLineItem[]; + taxableSubtotal?: number; tax: TaxConfig; salesRep?: SalesRepInfo; quoteNarrative?: string; @@ -158,7 +159,8 @@ export async function generateQuote( (sum, item) => sum + item.qty * item.unitPrice, 0, ); - const taxAmount = subTotal * data.tax.rate; + const taxableSubTotal = Math.max(0, data.taxableSubtotal ?? subTotal); + const taxAmount = taxableSubTotal * data.tax.rate; const total = subTotal + taxAmount; const logoDataUrl = loadLogoDataUrl(logoPath); diff --git a/src/modules/sales-utils/expectedSalesTax.ts b/src/modules/sales-utils/expectedSalesTax.ts new file mode 100644 index 0000000..b3da9c9 --- /dev/null +++ b/src/modules/sales-utils/expectedSalesTax.ts @@ -0,0 +1,98 @@ +import { readFileSync } from "fs"; + +export interface SalesTaxAddressInput { + line1?: string | null; + line2?: string | null; + city?: string | null; + state?: string | null; + zip?: string | null; + country?: string | null; +} + +interface LocalJurisdiction { + city: string; + local_rate: number; + combined_rate: number; +} + +interface StateTaxRecord { + state: string; + abbreviation: string; + state_rate: number; + avg_local_rate: number; + avg_combined_rate: number; + has_local_tax: boolean; + local_jurisdictions?: LocalJurisdiction[]; +} + +const taxDataPath = new URL("./salesTaxRates.json", import.meta.url); + +const parseTaxData = (): StateTaxRecord[] => { + try { + const raw = readFileSync(taxDataPath, "utf-8"); + const parsed = JSON.parse(raw) as StateTaxRecord[]; + + if (!Array.isArray(parsed)) return []; + return parsed; + } catch { + return []; + } +}; + +const SALES_TAX_DATA = parseTaxData(); + +const normalizeToken = (value: string | null | undefined): string | null => { + if (!value) return null; + + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + + if (!normalized) return null; + return normalized; +}; + +const normalizeState = (state: string | null | undefined): string | null => { + const normalized = normalizeToken(state); + if (!normalized) return null; + + const directCode = normalized.toUpperCase(); + if (directCode.length === 2) return directCode; + + const match = SALES_TAX_DATA.find( + (record) => normalizeToken(record.state) === normalized, + ); + if (!match) return null; + + return match.abbreviation.toUpperCase(); +}; + +/** + * Compute expected sales tax rate for an address. + * Returns a decimal tax rate (e.g. 0.06 for 6%). + */ +export const getExpectedSalesTaxRate = ( + address: SalesTaxAddressInput | null | undefined, +): number => { + const state = normalizeState(address?.state); + if (!state) return 0; + + // Business rule: Tennessee remains explicitly hard-coded. + if (state === "TN") return 0.0975; + + const stateRecord = SALES_TAX_DATA.find( + (record) => record.abbreviation.toUpperCase() === state, + ); + if (!stateRecord) return 0; + + const city = normalizeToken(address?.city); + const cityMatch = stateRecord.local_jurisdictions?.find( + (jurisdiction) => normalizeToken(jurisdiction.city) === city, + ); + if (cityMatch) return cityMatch.combined_rate; + + return stateRecord.avg_combined_rate; +}; diff --git a/src/modules/sales-utils/normalizeProbability.ts b/src/modules/sales-utils/normalizeProbability.ts new file mode 100644 index 0000000..55c7f13 --- /dev/null +++ b/src/modules/sales-utils/normalizeProbability.ts @@ -0,0 +1,24 @@ +const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)); + +/** + * Normalize a probability-like input to a percent scale (0..100). + * Accepts values like "70", "70%", 70, or 0.7. + */ +export const normalizeProbabilityPercent = (value: unknown): number => { + const raw = + typeof value === "string" + ? Number.parseFloat(value.replace(/%/g, "").trim()) + : Number(value); + + if (!Number.isFinite(raw)) return 0; + + const scaled = raw <= 1 ? raw * 100 : raw; + return clamp(scaled, 0, 100); +}; + +/** + * Normalize a probability-like input to a ratio scale (0..1). + */ +export const normalizeProbabilityRatio = (value: unknown): number => + normalizeProbabilityPercent(value) / 100; diff --git a/src/modules/sales-utils/salesTaxRates.json b/src/modules/sales-utils/salesTaxRates.json new file mode 100644 index 0000000..274ad25 --- /dev/null +++ b/src/modules/sales-utils/salesTaxRates.json @@ -0,0 +1,1501 @@ +[ + { + "state": "Alabama", + "abbreviation": "AL", + "state_rate": 0.04, + "avg_local_rate": 0.0543, + "avg_combined_rate": 0.0943, + "local_rate_range": { + "min": 0.0, + "max": 0.07 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Birmingham", + "local_rate": 0.04, + "combined_rate": 0.08 + }, + { + "city": "Huntsville", + "local_rate": 0.045, + "combined_rate": 0.085 + }, + { + "city": "Mobile", + "local_rate": 0.05, + "combined_rate": 0.09 + }, + { + "city": "Montgomery", + "local_rate": 0.04, + "combined_rate": 0.08 + }, + { + "city": "Tuscaloosa", + "local_rate": 0.07, + "combined_rate": 0.11 + } + ] + }, + { + "state": "Alaska", + "abbreviation": "AK", + "state_rate": 0.0, + "avg_local_rate": 0.0182, + "avg_combined_rate": 0.0182, + "local_rate_range": { + "min": 0.0, + "max": 0.075 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Anchorage", + "local_rate": 0.0, + "combined_rate": 0.0 + }, + { + "city": "Fairbanks", + "local_rate": 0.0, + "combined_rate": 0.0 + }, + { + "city": "Juneau", + "local_rate": 0.05, + "combined_rate": 0.05 + }, + { + "city": "Kodiak", + "local_rate": 0.06, + "combined_rate": 0.06 + }, + { + "city": "Sitka", + "local_rate": 0.06, + "combined_rate": 0.06 + } + ] + }, + { + "state": "Arizona", + "abbreviation": "AZ", + "state_rate": 0.056, + "avg_local_rate": 0.0281, + "avg_combined_rate": 0.0841, + "local_rate_range": { + "min": 0.0, + "max": 0.056 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Mesa", + "local_rate": 0.027, + "combined_rate": 0.083 + }, + { + "city": "Phoenix", + "local_rate": 0.035, + "combined_rate": 0.091 + }, + { + "city": "Scottsdale", + "local_rate": 0.0175, + "combined_rate": 0.0775 + }, + { + "city": "Tempe", + "local_rate": 0.0295, + "combined_rate": 0.0855 + }, + { + "city": "Tucson", + "local_rate": 0.031, + "combined_rate": 0.087 + } + ] + }, + { + "state": "Arkansas", + "abbreviation": "AR", + "state_rate": 0.065, + "avg_local_rate": 0.0293, + "avg_combined_rate": 0.0943, + "local_rate_range": { + "min": 0.0, + "max": 0.05 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Fayetteville", + "local_rate": 0.02, + "combined_rate": 0.085 + }, + { + "city": "Fort Smith", + "local_rate": 0.02, + "combined_rate": 0.085 + }, + { + "city": "Jonesboro", + "local_rate": 0.02, + "combined_rate": 0.085 + }, + { + "city": "Little Rock", + "local_rate": 0.015, + "combined_rate": 0.08 + }, + { + "city": "Gould", + "local_rate": 0.05, + "combined_rate": 0.115 + } + ] + }, + { + "state": "California", + "abbreviation": "CA", + "state_rate": 0.0725, + "avg_local_rate": 0.0157, + "avg_combined_rate": 0.0882, + "local_rate_range": { + "min": 0.0015, + "max": 0.03 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Bakersfield", + "local_rate": 0.01, + "combined_rate": 0.0825 + }, + { + "city": "Fresno", + "local_rate": 0.00725, + "combined_rate": 0.07975 + }, + { + "city": "Long Beach", + "local_rate": 0.03, + "combined_rate": 0.1025 + }, + { + "city": "Los Angeles", + "local_rate": 0.0225, + "combined_rate": 0.095 + }, + { + "city": "Oakland", + "local_rate": 0.0475, + "combined_rate": 0.12 + }, + { + "city": "Sacramento", + "local_rate": 0.015, + "combined_rate": 0.0875 + }, + { + "city": "San Diego", + "local_rate": 0.005, + "combined_rate": 0.0775 + }, + { + "city": "San Francisco", + "local_rate": 0.01, + "combined_rate": 0.0825 + }, + { + "city": "San Jose", + "local_rate": 0.00125, + "combined_rate": 0.07375 + } + ] + }, + { + "state": "Colorado", + "abbreviation": "CO", + "state_rate": 0.029, + "avg_local_rate": 0.0487, + "avg_combined_rate": 0.0777, + "local_rate_range": { + "min": 0.0, + "max": 0.083 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Aurora", + "local_rate": 0.0415, + "combined_rate": 0.0715 + }, + { + "city": "Colorado Springs", + "local_rate": 0.043, + "combined_rate": 0.072 + }, + { + "city": "Denver", + "local_rate": 0.0481, + "combined_rate": 0.0771 + }, + { + "city": "Fort Collins", + "local_rate": 0.0355, + "combined_rate": 0.0655 + }, + { + "city": "Pueblo", + "local_rate": 0.038, + "combined_rate": 0.068 + } + ] + }, + { + "state": "Connecticut", + "abbreviation": "CT", + "state_rate": 0.0635, + "avg_local_rate": 0.0, + "avg_combined_rate": 0.0635, + "local_rate_range": null, + "has_local_tax": false + }, + { + "state": "Delaware", + "abbreviation": "DE", + "state_rate": 0.0, + "avg_local_rate": 0.0, + "avg_combined_rate": 0.0, + "local_rate_range": null, + "has_local_tax": false + }, + { + "state": "Florida", + "abbreviation": "FL", + "state_rate": 0.06, + "avg_local_rate": 0.0095, + "avg_combined_rate": 0.0695, + "local_rate_range": { + "min": 0.0, + "max": 0.02 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Jacksonville", + "local_rate": 0.015, + "combined_rate": 0.075 + }, + { + "city": "Miami", + "local_rate": 0.01, + "combined_rate": 0.07 + }, + { + "city": "Orlando", + "local_rate": 0.005, + "combined_rate": 0.065 + }, + { + "city": "St. Petersburg", + "local_rate": 0.005, + "combined_rate": 0.065 + }, + { + "city": "Tampa", + "local_rate": 0.015, + "combined_rate": 0.075 + } + ] + }, + { + "state": "Georgia", + "abbreviation": "GA", + "state_rate": 0.04, + "avg_local_rate": 0.0342, + "avg_combined_rate": 0.0742, + "local_rate_range": { + "min": 0.0, + "max": 0.05 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Atlanta", + "local_rate": 0.049, + "combined_rate": 0.089 + }, + { + "city": "Augusta", + "local_rate": 0.04, + "combined_rate": 0.08 + }, + { + "city": "Columbus", + "local_rate": 0.03, + "combined_rate": 0.07 + }, + { + "city": "Macon", + "local_rate": 0.04, + "combined_rate": 0.08 + }, + { + "city": "Savannah", + "local_rate": 0.04, + "combined_rate": 0.08 + } + ] + }, + { + "state": "Hawaii", + "abbreviation": "HI", + "state_rate": 0.04, + "avg_local_rate": 0.005, + "avg_combined_rate": 0.045, + "local_rate_range": { + "min": 0.0, + "max": 0.005 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Honolulu", + "local_rate": 0.005, + "combined_rate": 0.045 + }, + { + "city": "Hilo", + "local_rate": 0.0, + "combined_rate": 0.04 + }, + { + "city": "Kailua", + "local_rate": 0.005, + "combined_rate": 0.045 + } + ] + }, + { + "state": "Idaho", + "abbreviation": "ID", + "state_rate": 0.06, + "avg_local_rate": 0.0003, + "avg_combined_rate": 0.0603, + "local_rate_range": { + "min": 0.0, + "max": 0.03 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Boise", + "local_rate": 0.0, + "combined_rate": 0.06 + }, + { + "city": "Idaho Falls", + "local_rate": 0.0, + "combined_rate": 0.06 + }, + { + "city": "Nampa", + "local_rate": 0.0, + "combined_rate": 0.06 + }, + { + "city": "Sun Valley", + "local_rate": 0.03, + "combined_rate": 0.09 + } + ] + }, + { + "state": "Illinois", + "abbreviation": "IL", + "state_rate": 0.0625, + "avg_local_rate": 0.0249, + "avg_combined_rate": 0.0874, + "local_rate_range": { + "min": 0.0, + "max": 0.0475 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Aurora", + "local_rate": 0.0125, + "combined_rate": 0.075 + }, + { + "city": "Chicago", + "local_rate": 0.04, + "combined_rate": 0.1025 + }, + { + "city": "Naperville", + "local_rate": 0.0075, + "combined_rate": 0.07 + }, + { + "city": "Rockford", + "local_rate": 0.0125, + "combined_rate": 0.075 + }, + { + "city": "Springfield", + "local_rate": 0.0125, + "combined_rate": 0.075 + } + ] + }, + { + "state": "Indiana", + "abbreviation": "IN", + "state_rate": 0.07, + "avg_local_rate": 0.0, + "avg_combined_rate": 0.07, + "local_rate_range": null, + "has_local_tax": false + }, + { + "state": "Iowa", + "abbreviation": "IA", + "state_rate": 0.06, + "avg_local_rate": 0.0094, + "avg_combined_rate": 0.0694, + "local_rate_range": { + "min": 0.0, + "max": 0.02 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Cedar Rapids", + "local_rate": 0.01, + "combined_rate": 0.07 + }, + { + "city": "Davenport", + "local_rate": 0.01, + "combined_rate": 0.07 + }, + { + "city": "Des Moines", + "local_rate": 0.01, + "combined_rate": 0.07 + }, + { + "city": "Iowa City", + "local_rate": 0.01, + "combined_rate": 0.07 + }, + { + "city": "Sioux City", + "local_rate": 0.01, + "combined_rate": 0.07 + } + ] + }, + { + "state": "Kansas", + "abbreviation": "KS", + "state_rate": 0.065, + "avg_local_rate": 0.0217, + "avg_combined_rate": 0.0867, + "local_rate_range": { + "min": 0.0, + "max": 0.041 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Kansas City", + "local_rate": 0.0125, + "combined_rate": 0.0775 + }, + { + "city": "Lawrence", + "local_rate": 0.013, + "combined_rate": 0.078 + }, + { + "city": "Overland Park", + "local_rate": 0.011, + "combined_rate": 0.076 + }, + { + "city": "Topeka", + "local_rate": 0.015, + "combined_rate": 0.08 + }, + { + "city": "Wichita", + "local_rate": 0.01, + "combined_rate": 0.075 + } + ] + }, + { + "state": "Kentucky", + "abbreviation": "KY", + "state_rate": 0.06, + "avg_local_rate": 0.0, + "avg_combined_rate": 0.06, + "local_rate_range": null, + "has_local_tax": false + }, + { + "state": "Louisiana", + "abbreviation": "LA", + "state_rate": 0.0445, + "avg_local_rate": 0.051, + "avg_combined_rate": 0.0955, + "local_rate_range": { + "min": 0.0, + "max": 0.07 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Baton Rouge", + "local_rate": 0.0495, + "combined_rate": 0.094 + }, + { + "city": "Lafayette", + "local_rate": 0.05, + "combined_rate": 0.0945 + }, + { + "city": "New Orleans", + "local_rate": 0.055, + "combined_rate": 0.0995 + }, + { + "city": "Shreveport", + "local_rate": 0.0445, + "combined_rate": 0.089 + } + ] + }, + { + "state": "Maine", + "abbreviation": "ME", + "state_rate": 0.055, + "avg_local_rate": 0.0, + "avg_combined_rate": 0.055, + "local_rate_range": null, + "has_local_tax": false + }, + { + "state": "Maryland", + "abbreviation": "MD", + "state_rate": 0.06, + "avg_local_rate": 0.0, + "avg_combined_rate": 0.06, + "local_rate_range": null, + "has_local_tax": false + }, + { + "state": "Massachusetts", + "abbreviation": "MA", + "state_rate": 0.0625, + "avg_local_rate": 0.0, + "avg_combined_rate": 0.0625, + "local_rate_range": null, + "has_local_tax": false + }, + { + "state": "Michigan", + "abbreviation": "MI", + "state_rate": 0.06, + "avg_local_rate": 0.0, + "avg_combined_rate": 0.06, + "local_rate_range": null, + "has_local_tax": false + }, + { + "state": "Minnesota", + "abbreviation": "MN", + "state_rate": 0.06875, + "avg_local_rate": 0.0059, + "avg_combined_rate": 0.0746, + "local_rate_range": { + "min": 0.0, + "max": 0.015 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Duluth", + "local_rate": 0.01, + "combined_rate": 0.07875 + }, + { + "city": "Minneapolis", + "local_rate": 0.0065, + "combined_rate": 0.075 + }, + { + "city": "Rochester", + "local_rate": 0.005, + "combined_rate": 0.07375 + }, + { + "city": "Saint Paul", + "local_rate": 0.01, + "combined_rate": 0.07875 + } + ] + }, + { + "state": "Mississippi", + "abbreviation": "MS", + "state_rate": 0.07, + "avg_local_rate": 0.0006, + "avg_combined_rate": 0.0706, + "local_rate_range": { + "min": 0.0, + "max": 0.01 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Biloxi", + "local_rate": 0.0, + "combined_rate": 0.07 + }, + { + "city": "Gulfport", + "local_rate": 0.0, + "combined_rate": 0.07 + }, + { + "city": "Hattiesburg", + "local_rate": 0.0, + "combined_rate": 0.07 + }, + { + "city": "Jackson", + "local_rate": 0.01, + "combined_rate": 0.08 + } + ] + }, + { + "state": "Missouri", + "abbreviation": "MO", + "state_rate": 0.04225, + "avg_local_rate": 0.0419, + "avg_combined_rate": 0.0841, + "local_rate_range": { + "min": 0.0, + "max": 0.05875 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Columbia", + "local_rate": 0.0225, + "combined_rate": 0.0648 + }, + { + "city": "Kansas City", + "local_rate": 0.015, + "combined_rate": 0.0573 + }, + { + "city": "Springfield", + "local_rate": 0.0225, + "combined_rate": 0.0648 + }, + { + "city": "St. Louis", + "local_rate": 0.04538, + "combined_rate": 0.08763 + } + ] + }, + { + "state": "Montana", + "abbreviation": "MT", + "state_rate": 0.0, + "avg_local_rate": 0.0, + "avg_combined_rate": 0.0, + "local_rate_range": null, + "has_local_tax": false + }, + { + "state": "Nebraska", + "abbreviation": "NE", + "state_rate": 0.055, + "avg_local_rate": 0.0139, + "avg_combined_rate": 0.0689, + "local_rate_range": { + "min": 0.0, + "max": 0.02 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Lincoln", + "local_rate": 0.0175, + "combined_rate": 0.0725 + }, + { + "city": "Omaha", + "local_rate": 0.015, + "combined_rate": 0.07 + } + ] + }, + { + "state": "Nevada", + "abbreviation": "NV", + "state_rate": 0.0685, + "avg_local_rate": 0.0138, + "avg_combined_rate": 0.0823, + "local_rate_range": { + "min": 0.0, + "max": 0.03665 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Henderson", + "local_rate": 0.01525, + "combined_rate": 0.08375 + }, + { + "city": "Las Vegas", + "local_rate": 0.01525, + "combined_rate": 0.08375 + }, + { + "city": "Reno", + "local_rate": 0.01025, + "combined_rate": 0.07875 + } + ] + }, + { + "state": "New Hampshire", + "abbreviation": "NH", + "state_rate": 0.0, + "avg_local_rate": 0.0, + "avg_combined_rate": 0.0, + "local_rate_range": null, + "has_local_tax": false + }, + { + "state": "New Jersey", + "abbreviation": "NJ", + "state_rate": 0.06625, + "avg_local_rate": -0.0003, + "avg_combined_rate": 0.066, + "local_rate_range": { + "min": -0.03125, + "max": 0.0 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Jersey City", + "local_rate": 0.0, + "combined_rate": 0.06625 + }, + { + "city": "Newark", + "local_rate": 0.0, + "combined_rate": 0.06625 + }, + { + "city": "Salem County", + "local_rate": -0.03125, + "combined_rate": 0.035 + } + ] + }, + { + "state": "New Mexico", + "abbreviation": "NM", + "state_rate": 0.04875, + "avg_local_rate": 0.0269, + "avg_combined_rate": 0.0757, + "local_rate_range": { + "min": 0.0, + "max": 0.04563 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Albuquerque", + "local_rate": 0.0275, + "combined_rate": 0.0763 + }, + { + "city": "Las Cruces", + "local_rate": 0.03188, + "combined_rate": 0.08063 + }, + { + "city": "Rio Rancho", + "local_rate": 0.02813, + "combined_rate": 0.07688 + }, + { + "city": "Santa Fe", + "local_rate": 0.03375, + "combined_rate": 0.0825 + } + ] + }, + { + "state": "New York", + "abbreviation": "NY", + "state_rate": 0.04, + "avg_local_rate": 0.0452, + "avg_combined_rate": 0.0852, + "local_rate_range": { + "min": 0.0, + "max": 0.04875 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Buffalo", + "local_rate": 0.04875, + "combined_rate": 0.08875 + }, + { + "city": "New York City", + "local_rate": 0.04875, + "combined_rate": 0.08875 + }, + { + "city": "Rochester", + "local_rate": 0.04, + "combined_rate": 0.08 + }, + { + "city": "Syracuse", + "local_rate": 0.04, + "combined_rate": 0.08 + }, + { + "city": "Yonkers", + "local_rate": 0.04875, + "combined_rate": 0.08875 + } + ] + }, + { + "state": "North Carolina", + "abbreviation": "NC", + "state_rate": 0.0475, + "avg_local_rate": 0.0225, + "avg_combined_rate": 0.07, + "local_rate_range": { + "min": 0.0, + "max": 0.0275 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Charlotte", + "local_rate": 0.02, + "combined_rate": 0.0675 + }, + { + "city": "Durham", + "local_rate": 0.02, + "combined_rate": 0.0675 + }, + { + "city": "Greensboro", + "local_rate": 0.02, + "combined_rate": 0.0675 + }, + { + "city": "Raleigh", + "local_rate": 0.02, + "combined_rate": 0.0675 + }, + { + "city": "Winston-Salem", + "local_rate": 0.0225, + "combined_rate": 0.07 + } + ] + }, + { + "state": "North Dakota", + "abbreviation": "ND", + "state_rate": 0.05, + "avg_local_rate": 0.0196, + "avg_combined_rate": 0.0696, + "local_rate_range": { + "min": 0.0, + "max": 0.035 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Bismarck", + "local_rate": 0.01, + "combined_rate": 0.06 + }, + { + "city": "Fargo", + "local_rate": 0.02, + "combined_rate": 0.07 + }, + { + "city": "Grand Forks", + "local_rate": 0.02, + "combined_rate": 0.07 + }, + { + "city": "Minot", + "local_rate": 0.015, + "combined_rate": 0.065 + } + ] + }, + { + "state": "Ohio", + "abbreviation": "OH", + "state_rate": 0.0575, + "avg_local_rate": 0.0148, + "avg_combined_rate": 0.0723, + "local_rate_range": { + "min": 0.0, + "max": 0.0225 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Cincinnati", + "local_rate": 0.01475, + "combined_rate": 0.07225 + }, + { + "city": "Cleveland", + "local_rate": 0.0125, + "combined_rate": 0.07 + }, + { + "city": "Columbus", + "local_rate": 0.01, + "combined_rate": 0.0675 + }, + { + "city": "Dayton", + "local_rate": 0.015, + "combined_rate": 0.0725 + }, + { + "city": "Toledo", + "local_rate": 0.01, + "combined_rate": 0.0675 + } + ] + }, + { + "state": "Oklahoma", + "abbreviation": "OK", + "state_rate": 0.045, + "avg_local_rate": 0.0451, + "avg_combined_rate": 0.0901, + "local_rate_range": { + "min": 0.0, + "max": 0.07 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Broken Arrow", + "local_rate": 0.0317, + "combined_rate": 0.0767 + }, + { + "city": "Edmond", + "local_rate": 0.0325, + "combined_rate": 0.0775 + }, + { + "city": "Norman", + "local_rate": 0.04, + "combined_rate": 0.085 + }, + { + "city": "Oklahoma City", + "local_rate": 0.04125, + "combined_rate": 0.0863 + }, + { + "city": "Tulsa", + "local_rate": 0.04017, + "combined_rate": 0.0852 + } + ] + }, + { + "state": "Oregon", + "abbreviation": "OR", + "state_rate": 0.0, + "avg_local_rate": 0.0, + "avg_combined_rate": 0.0, + "local_rate_range": null, + "has_local_tax": false + }, + { + "state": "Pennsylvania", + "abbreviation": "PA", + "state_rate": 0.06, + "avg_local_rate": 0.0034, + "avg_combined_rate": 0.0634, + "local_rate_range": { + "min": 0.0, + "max": 0.02 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Allentown", + "local_rate": 0.02, + "combined_rate": 0.08 + }, + { + "city": "Erie", + "local_rate": 0.02, + "combined_rate": 0.08 + }, + { + "city": "Philadelphia", + "local_rate": 0.02, + "combined_rate": 0.08 + }, + { + "city": "Pittsburgh", + "local_rate": 0.02, + "combined_rate": 0.08 + } + ] + }, + { + "state": "Rhode Island", + "abbreviation": "RI", + "state_rate": 0.07, + "avg_local_rate": 0.0, + "avg_combined_rate": 0.07, + "local_rate_range": null, + "has_local_tax": false + }, + { + "state": "South Carolina", + "abbreviation": "SC", + "state_rate": 0.06, + "avg_local_rate": 0.015, + "avg_combined_rate": 0.075, + "local_rate_range": { + "min": 0.0, + "max": 0.03 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Charleston", + "local_rate": 0.01, + "combined_rate": 0.07 + }, + { + "city": "Columbia", + "local_rate": 0.02, + "combined_rate": 0.08 + }, + { + "city": "Greenville", + "local_rate": 0.01, + "combined_rate": 0.07 + }, + { + "city": "Myrtle Beach", + "local_rate": 0.015, + "combined_rate": 0.075 + } + ] + }, + { + "state": "South Dakota", + "abbreviation": "SD", + "state_rate": 0.042, + "avg_local_rate": 0.0191, + "avg_combined_rate": 0.0611, + "local_rate_range": { + "min": 0.0, + "max": 0.02 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Aberdeen", + "local_rate": 0.02, + "combined_rate": 0.062 + }, + { + "city": "Rapid City", + "local_rate": 0.02, + "combined_rate": 0.062 + }, + { + "city": "Sioux Falls", + "local_rate": 0.02, + "combined_rate": 0.062 + } + ] + }, + { + "state": "Tennessee", + "abbreviation": "TN", + "state_rate": 0.07, + "avg_local_rate": 0.0255, + "avg_combined_rate": 0.0955, + "local_rate_range": { + "min": 0.0, + "max": 0.03 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Chattanooga", + "local_rate": 0.025, + "combined_rate": 0.095 + }, + { + "city": "Clarksville", + "local_rate": 0.025, + "combined_rate": 0.095 + }, + { + "city": "Knoxville", + "local_rate": 0.0225, + "combined_rate": 0.0925 + }, + { + "city": "Memphis", + "local_rate": 0.0225, + "combined_rate": 0.0925 + }, + { + "city": "Nashville", + "local_rate": 0.0275, + "combined_rate": 0.0975 + } + ] + }, + { + "state": "Texas", + "abbreviation": "TX", + "state_rate": 0.0625, + "avg_local_rate": 0.0195, + "avg_combined_rate": 0.082, + "local_rate_range": { + "min": 0.00125, + "max": 0.02 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Arlington", + "local_rate": 0.02, + "combined_rate": 0.0825 + }, + { + "city": "Austin", + "local_rate": 0.02, + "combined_rate": 0.0825 + }, + { + "city": "Dallas", + "local_rate": 0.02, + "combined_rate": 0.0825 + }, + { + "city": "El Paso", + "local_rate": 0.02, + "combined_rate": 0.0825 + }, + { + "city": "Fort Worth", + "local_rate": 0.02, + "combined_rate": 0.0825 + }, + { + "city": "Houston", + "local_rate": 0.02, + "combined_rate": 0.0825 + }, + { + "city": "San Antonio", + "local_rate": 0.0125, + "combined_rate": 0.075 + } + ] + }, + { + "state": "Utah", + "abbreviation": "UT", + "state_rate": 0.061, + "avg_local_rate": 0.0122, + "avg_combined_rate": 0.0732, + "local_rate_range": { + "min": 0.0, + "max": 0.04 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Ogden", + "local_rate": 0.01, + "combined_rate": 0.071 + }, + { + "city": "Provo", + "local_rate": 0.01, + "combined_rate": 0.071 + }, + { + "city": "Salt Lake City", + "local_rate": 0.03, + "combined_rate": 0.091 + }, + { + "city": "St. George", + "local_rate": 0.01, + "combined_rate": 0.071 + } + ] + }, + { + "state": "Vermont", + "abbreviation": "VT", + "state_rate": 0.06, + "avg_local_rate": 0.0037, + "avg_combined_rate": 0.0637, + "local_rate_range": { + "min": 0.0, + "max": 0.01 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Burlington", + "local_rate": 0.01, + "combined_rate": 0.07 + }, + { + "city": "Montpelier", + "local_rate": 0.0, + "combined_rate": 0.06 + }, + { + "city": "Rutland", + "local_rate": 0.0, + "combined_rate": 0.06 + } + ] + }, + { + "state": "Virginia", + "abbreviation": "VA", + "state_rate": 0.053, + "avg_local_rate": 0.0037, + "avg_combined_rate": 0.0567, + "local_rate_range": { + "min": 0.0, + "max": 0.027 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Arlington", + "local_rate": 0.0, + "combined_rate": 0.053 + }, + { + "city": "Chesapeake", + "local_rate": 0.0, + "combined_rate": 0.053 + }, + { + "city": "Norfolk", + "local_rate": 0.0, + "combined_rate": 0.053 + }, + { + "city": "Richmond", + "local_rate": 0.0, + "combined_rate": 0.053 + }, + { + "city": "Virginia Beach", + "local_rate": 0.01, + "combined_rate": 0.063 + } + ] + }, + { + "state": "Washington", + "abbreviation": "WA", + "state_rate": 0.065, + "avg_local_rate": 0.0288, + "avg_combined_rate": 0.0938, + "local_rate_range": { + "min": 0.0, + "max": 0.039 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Bellevue", + "local_rate": 0.036, + "combined_rate": 0.101 + }, + { + "city": "Seattle", + "local_rate": 0.0385, + "combined_rate": 0.1035 + }, + { + "city": "Spokane", + "local_rate": 0.026, + "combined_rate": 0.091 + }, + { + "city": "Tacoma", + "local_rate": 0.039, + "combined_rate": 0.104 + }, + { + "city": "Vancouver", + "local_rate": 0.026, + "combined_rate": 0.091 + } + ] + }, + { + "state": "West Virginia", + "abbreviation": "WV", + "state_rate": 0.06, + "avg_local_rate": 0.0057, + "avg_combined_rate": 0.0657, + "local_rate_range": { + "min": 0.0, + "max": 0.01 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Charleston", + "local_rate": 0.01, + "combined_rate": 0.07 + }, + { + "city": "Huntington", + "local_rate": 0.01, + "combined_rate": 0.07 + }, + { + "city": "Morgantown", + "local_rate": 0.01, + "combined_rate": 0.07 + }, + { + "city": "Parkersburg", + "local_rate": 0.01, + "combined_rate": 0.07 + } + ] + }, + { + "state": "Wisconsin", + "abbreviation": "WI", + "state_rate": 0.05, + "avg_local_rate": 0.0044, + "avg_combined_rate": 0.0544, + "local_rate_range": { + "min": 0.0, + "max": 0.006 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Green Bay", + "local_rate": 0.005, + "combined_rate": 0.055 + }, + { + "city": "Madison", + "local_rate": 0.005, + "combined_rate": 0.055 + }, + { + "city": "Milwaukee", + "local_rate": 0.006, + "combined_rate": 0.056 + }, + { + "city": "Racine", + "local_rate": 0.005, + "combined_rate": 0.055 + } + ] + }, + { + "state": "Wyoming", + "abbreviation": "WY", + "state_rate": 0.04, + "avg_local_rate": 0.0136, + "avg_combined_rate": 0.0536, + "local_rate_range": { + "min": 0.0, + "max": 0.02 + }, + "has_local_tax": true, + "local_jurisdictions": [ + { + "city": "Casper", + "local_rate": 0.02, + "combined_rate": 0.06 + }, + { + "city": "Cheyenne", + "local_rate": 0.02, + "combined_rate": 0.06 + }, + { + "city": "Laramie", + "local_rate": 0.02, + "combined_rate": 0.06 + }, + { + "city": "Rock Springs", + "local_rate": 0.02, + "combined_rate": 0.06 + } + ] + }, + { + "state": "District of Columbia", + "abbreviation": "DC", + "state_rate": 0.06, + "avg_local_rate": 0.0, + "avg_combined_rate": 0.06, + "local_rate_range": null, + "has_local_tax": false + } +] diff --git a/src/types/PermissionNodes.ts b/src/types/PermissionNodes.ts index adb6b3c..84863f4 100644 --- a/src/types/PermissionNodes.ts +++ b/src/types/PermissionNodes.ts @@ -423,6 +423,33 @@ export const PERMISSION_NODES = { "src/api/sales/fetchOpportunityTypes.ts", ], }, + { + node: "sales.opportunity.fetch.@me", + description: + "View the personal sales dashboard showing opportunities assigned to the current user (UI-only gate)", + usedIn: ["UI-only (client-side gate)"], + }, + { + node: "sales.opportunity.fetch.all", + description: + "View all opportunities across all users (All Opportunities tab and View All button in the sales dashboard UI)", + usedIn: ["UI-only (client-side gate)"], + dependencies: ["sales.opportunity.fetch.many"], + }, + { + node: "sales.opportunity.metrics.all", + description: + "Allow `scope=all` on sales opportunity metrics endpoint to read cached metrics for all active members", + usedIn: ["src/api/sales/opportunities/metrics.ts"], + dependencies: ["sales.opportunity.fetch.many"], + }, + { + node: "sales.opportunity.metrics.identifier.override", + description: + "Allow `identifier=` override on sales opportunity metrics endpoint for querying another member", + usedIn: ["src/api/sales/opportunities/metrics.ts"], + dependencies: ["sales.opportunity.fetch.many"], + }, { node: "sales.opportunity.refresh", description: "Refresh a single opportunity from ConnectWise", @@ -645,6 +672,12 @@ export const PERMISSION_NODES = { usedIn: ["src/api/sales/opportunities/[id]/workflow/dispatch.ts"], dependencies: ["sales.opportunity.fetch"], }, + { + node: "sales.isRepresentative", + description: + "Designates the user as a sales representative; used for reporting and filtering purposes.", + usedIn: [], + }, ], }, diff --git a/src/types/QuoteStatuses.ts b/src/types/QuoteStatuses.ts index 8a7e81b..4caf307 100644 --- a/src/types/QuoteStatuses.ts +++ b/src/types/QuoteStatuses.ts @@ -200,6 +200,27 @@ export const QUOTE_STATUSES: QuoteStatus[] = [ ], }, + // + // READY TO SEND + // + { + id: 63, + name: "Ready to Send", + wonFlag: false, + lostFlag: false, + closedFlag: false, + inactiveFlag: false, + defaultFlag: false, + enteredBy: "jroberts", + dateEntered: "2026-03-16T02:24:10Z", + _info: { + lastUpdated: "2026-03-16T02:24:10Z", + updatedBy: "jroberts", + }, + connectWiseId: "f10c4e36-df20-f111-b2ee-000c29c55070", + optimaEquivalency: [], + }, + // // PENDING SENT // diff --git a/src/workflows/wf.opportunity.ts b/src/workflows/wf.opportunity.ts index 03c89e8..0a30c63 100644 --- a/src/workflows/wf.opportunity.ts +++ b/src/workflows/wf.opportunity.ts @@ -30,7 +30,8 @@ * | QuoteSent | 43 | 03. Quote Sent | * | ConfirmedQuote | 57 | 04. Confirmed Quote | * | Active | 58 | 05. Active | - * | PendingSent | 60 | Pending Sent | + * | ReadyToSend | 63 | Ready to Send | + * | PendingSent | 60 | Pending Sent (Deprecated) | * | PendingRevision | 61 | Pending Revision | * | PendingWon | 49 | 91. Pending Won | * | Won | 29 | 95. Won | @@ -65,6 +66,7 @@ export const OpportunityStatus = { QuoteSent: 43, ConfirmedQuote: 57, Active: 58, + ReadyToSend: 63, PendingSent: 60, PendingRevision: 61, PendingWon: 49, @@ -180,19 +182,25 @@ const ALLOWED_TRANSITIONS: Record> = { [OpportunityStatus.PendingNew]: new Set([OpportunityStatus.New]), [OpportunityStatus.New]: new Set([ + OpportunityStatus.ReadyToSend, OpportunityStatus.InternalReview, OpportunityStatus.QuoteSent, OpportunityStatus.Canceled, ]), [OpportunityStatus.InternalReview]: new Set([ - OpportunityStatus.PendingSent, + OpportunityStatus.ReadyToSend, OpportunityStatus.PendingRevision, OpportunityStatus.QuoteSent, // reviewer manually sends OpportunityStatus.Canceled, ]), - [OpportunityStatus.PendingSent]: new Set([OpportunityStatus.QuoteSent]), + [OpportunityStatus.ReadyToSend]: new Set([OpportunityStatus.QuoteSent]), + + [OpportunityStatus.PendingSent]: new Set([ + OpportunityStatus.ReadyToSend, + OpportunityStatus.QuoteSent, + ]), [OpportunityStatus.PendingRevision]: new Set([OpportunityStatus.Active]), @@ -214,6 +222,7 @@ const ALLOWED_TRANSITIONS: Record> = { ]), [OpportunityStatus.Active]: new Set([ + OpportunityStatus.ReadyToSend, OpportunityStatus.QuoteSent, OpportunityStatus.InternalReview, OpportunityStatus.Canceled, @@ -275,7 +284,7 @@ export interface ReviewDecisionPayload extends BaseActionPayload { decision: "approve" | "reject" | "send" | "cancel"; } -/** Transition from PendingSent → QuoteSent. */ +/** Transition from ReadyToSend/PendingSent(deprecated) → QuoteSent. */ export interface SendQuotePayload extends BaseActionPayload { /** * If true, marks sent AND confirmed simultaneously. @@ -313,6 +322,9 @@ export interface SendQuotePayload extends BaseActionPayload { needsRevision?: boolean; } +/** Mark opportunity as ready to send quote from send-capable statuses. */ +export interface MarkReadyToSendPayload extends BaseActionPayload {} + /** Confirm receipt of a quote. */ export interface ConfirmQuotePayload extends BaseActionPayload {} @@ -351,6 +363,7 @@ export type WorkflowAction = | { action: "acceptNew"; payload: AcceptNewPayload } | { action: "requestReview"; payload: RequestReviewPayload } | { action: "reviewDecision"; payload: ReviewDecisionPayload } + | { action: "markReadyToSend"; payload: MarkReadyToSendPayload } | { action: "sendQuote"; payload: SendQuotePayload } | { action: "confirmQuote"; payload: ConfirmQuotePayload } | { action: "finalize"; payload: FinalizePayload } @@ -728,7 +741,7 @@ export async function transitionToInternalReview( } /** - * InternalReview → PendingSent | PendingRevision | QuoteSent | Canceled + * InternalReview → ReadyToSend | PendingRevision | QuoteSent | Canceled * * Reviewer makes a decision on an opportunity in InternalReview. */ @@ -753,9 +766,9 @@ export async function handleReviewDecision( const activities: ActivityController[] = []; switch (payload.decision) { - // ── Approve → PendingSent ────────────────────────────────────────── + // ── Approve → ReadyToSend ────────────────────────────────────────── case "approve": { - const targetStatus = OpportunityStatus.PendingSent; + const targetStatus = OpportunityStatus.ReadyToSend; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); @@ -901,7 +914,7 @@ export async function handleReviewDecision( } /** - * PendingSent → QuoteSent (and its compound transitions) + * ReadyToSend/PendingSent(deprecated) → QuoteSent (and its compound transitions) * * Also handles New → QuoteSent (direct send, skipping review) and * Active → QuoteSent (re-send after revision). @@ -1172,6 +1185,54 @@ export async function transitionToQuoteSent( return ok(currentStatus, targetStatus, activities); } +/** + * New/Active/PendingSent(deprecated) → ReadyToSend + * + * Allows staging an opportunity as ready without immediately sending. + */ +export async function transitionToReadyToSend( + opportunity: OpportunityController, + user: WorkflowUser, + payload: MarkReadyToSendPayload, +): Promise { + const currentStatus = opportunity.statusCwId; + if (currentStatus == null) return fail("Opportunity has no current status."); + + if (!hasPermission(user, WorkflowPermissions.SEND)) { + return fail( + `User lacks the "${WorkflowPermissions.SEND}" permission required to mark an opportunity ready to send.`, + currentStatus, + ); + } + + const targetStatus = OpportunityStatus.ReadyToSend; + const transErr = assertTransitionAllowed(currentStatus, targetStatus); + if (transErr) return fail(transErr, currentStatus); + + const activity = await createWorkflowActivity({ + name: `[Workflow] Marked ready to send — ${opportunity.name}`, + opportunityCwId: opportunity.cwOpportunityId, + companyCwId: opportunity.companyCwId, + assignToCwMemberId: user.cwMemberId, + notes: payload.note ?? "Marked ready to send.", + optimaType: OptimaType.OpportunityReview, + }); + + await syncOpportunityStatus({ + opportunityId: opportunity.cwOpportunityId, + statusCwId: targetStatus, + }); + + await handleTimeEntry( + activity.cwActivityId, + user.cwMemberId, + payload, + payload.note ?? "Marked ready to send.", + ); + + return ok(currentStatus, targetStatus, [activity]); +} + /** * QuoteSent → ConfirmedQuote * @@ -1754,6 +1815,10 @@ export async function processOpportunityAction( result = await handleReviewDecision(opportunity, user, payload); break; + case "markReadyToSend": + result = await transitionToReadyToSend(opportunity, user, payload); + break; + case "sendQuote": result = await transitionToQuoteSent(opportunity, user, payload); break;