Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dc3c7ce07 | |||
| e764932c39 | |||
| 33b34d08a7 | |||
| 5afda8cb34 | |||
| ee3e0a7377 | |||
| e294791858 | |||
| 97ac4a2173 | |||
| ad7507d133 | |||
| 15ef24eb3e | |||
| f53b390e18 | |||
| c0a4d4f919 | |||
| 0ce1eda606 | |||
| 6c310ed753 | |||
| 1907bb433b | |||
| 4efca6cc53 |
@@ -14,6 +14,8 @@ jobs:
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.6"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
@@ -14,6 +14,8 @@ jobs:
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.6"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
+1564
-16
File diff suppressed because it is too large
Load Diff
+36
@@ -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 |
|
||||
|
||||
+55
-22
@@ -23,13 +23,14 @@ The permission validator supports special tokens for flexible permission managem
|
||||
|
||||
### Company Permissions
|
||||
|
||||
| Permission Node | Description | Used In |
|
||||
| ------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `company.fetch` | Fetch a single company | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
|
||||
| `company.fetch.address` | View company address information (requires `company.fetch` as well) | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
|
||||
| `company.fetch.contacts` | View all company contacts (requires `company.fetch` as well) | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
|
||||
| `company.fetch.many` | Fetch multiple companies | [src/api/companies/fetchAll.ts](src/api/companies/fetchAll.ts) |
|
||||
| `company.fetch.configurations` | Fetch company configurations (requires `company.fetch` as well) | [src/api/companies/[id]/configurations.ts](src/api/companies/[id]/configurations.ts) |
|
||||
| Permission Node | Description | Used In |
|
||||
| ------------------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `company.fetch` | Fetch a single company | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
|
||||
| `company.fetch.address` | View company address information (requires `company.fetch` as well) | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
|
||||
| `company.fetch.contacts` | View all company contacts (requires `company.fetch` as well) | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
|
||||
| `company.fetch.many` | Fetch multiple companies | [src/api/companies/fetchAll.ts](src/api/companies/fetchAll.ts) |
|
||||
| `company.fetch.configurations` | Fetch company configurations (requires `company.fetch` as well) | [src/api/companies/[id]/configurations.ts](src/api/companies/[id]/configurations.ts) |
|
||||
| `company.fetch.sites` | Fetch company sites from ConnectWise (requires `company.fetch` as well) | [src/api/companies/[id]/sites.ts](src/api/companies/[id]/sites.ts) |
|
||||
|
||||
### Credential Permissions
|
||||
|
||||
@@ -124,29 +125,60 @@ Admin-specific UI permissions that control visibility and data loading for admin
|
||||
| `procurement.catalog.inventory.refresh` | Refresh on-hand inventory for a catalog item from ConnectWise | [src/api/procurement/[id]/refreshInventory.ts](src/api/procurement/[id]/refreshInventory.ts) | `procurement.catalog.fetch` |
|
||||
| `procurement.catalog.link` | Link or unlink catalog items to each other | [src/api/procurement/[id]/link.ts](src/api/procurement/[id]/link.ts), [src/api/procurement/[id]/unlink.ts](src/api/procurement/[id]/unlink.ts) | `procurement.catalog.fetch` |
|
||||
|
||||
### ConnectWise Callback Routes
|
||||
### ConnectWise Routes
|
||||
|
||||
`GET /v1/cw/members` requires only authentication (any logged-in user) and does **not** require a specific permission node.
|
||||
|
||||
`POST /v1/cw/callback/:secret/:resource` is intentionally unauthenticated for inbound ConnectWise callbacks and does **not** require a permission node.
|
||||
|
||||
| Permission Node | Description | Used In | Dependencies |
|
||||
| --------------- | ------------------------------------------------------------------------------- | ------------------------------------------------ | ------------ |
|
||||
| _None_ | Inbound callback route; secured operationally (network controls / source trust) | [src/api/cw/callback.ts](src/api/cw/callback.ts) | N/A |
|
||||
| Permission Node | Description | Used In | Dependencies |
|
||||
| --------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------ |
|
||||
| _None_ | Fetch CW members (auth only) | [src/api/cw/fetchMembers.ts](src/api/cw/fetchMembers.ts) | N/A |
|
||||
| _None_ | Inbound callback route; secured operationally (network controls / source trust) | [src/api/cw/callback.ts](src/api/cw/callback.ts) | N/A |
|
||||
|
||||
### Sales Permissions
|
||||
|
||||
Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (products, notes, contacts) are fetched live from CW.
|
||||
|
||||
| Permission Node | Description | Used In | Dependencies |
|
||||
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
|
||||
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/products.ts](src/api/sales/[id]/products.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/fetchNote.ts](src/api/sales/[id]/fetchNote.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | |
|
||||
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.ts) | |
|
||||
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/[id]/createNote.ts](src/api/sales/[id]/createNote.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/[id]/updateNote.ts](src/api/sales/[id]/updateNote.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<field>` permissions. | [src/api/sales/[id]/addProduct.ts](src/api/sales/[id]/addProduct.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.add.specialOrder` | Add one or more "SPECIAL ORDER" products via the dedicated special-order route. | [src/api/sales/[id]/addSpecialOrderProduct.ts](src/api/sales/[id]/addSpecialOrderProduct.ts) | `sales.opportunity.fetch` |
|
||||
**WebSocket note:** The `/secure` socket event chain `opp:live_quote_preview` and `opp:live_quote_preview:<id>:data` is gated by `sales.opportunity.fetch`.
|
||||
|
||||
| Permission Node | Description | Used In | Dependencies |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------ |
|
||||
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/opportunities/[id]/fetch.ts](src/api/sales/opportunities/[id]/fetch.ts), [src/api/sales/opportunities/[id]/products/fetchAll.ts](src/api/sales/opportunities/[id]/products/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetchAll.ts](src/api/sales/opportunities/[id]/notes/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetch.ts](src/api/sales/opportunities/[id]/notes/fetch.ts), [src/api/sales/opportunities/[id]/contacts/fetchAll.ts](src/api/sales/opportunities/[id]/contacts/fetchAll.ts), [src/api/sockets/events/liveQuotePreview.ts](src/api/sockets/events/liveQuotePreview.ts) | |
|
||||
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/opportunities/fetchAll.ts](src/api/sales/opportunities/fetchAll.ts), [src/api/sales/opportunities/count.ts](src/api/sales/opportunities/count.ts), [src/api/sales/opportunities/fetchTypes.ts](src/api/sales/opportunities/fetchTypes.ts) | |
|
||||
| `sales.opportunity.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=<cwIdentifier>` 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.<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)_ | |
|
||||
|
||||
<details>
|
||||
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
|
||||
@@ -348,6 +380,7 @@ All fetch and fetchAll routes gate response object keys using `processObjectValu
|
||||
| `obj.opportunity.site` | View site |
|
||||
| `obj.opportunity.customerPO` | View customer PO |
|
||||
| `obj.opportunity.totalSalesTax` | View total sales tax |
|
||||
| `obj.opportunity.probability` | View probability percentage |
|
||||
| `obj.opportunity.location` | View location |
|
||||
| `obj.opportunity.department` | View department |
|
||||
| `obj.opportunity.expectedCloseDate` | View expected close date |
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"keypair": "^1.0.4",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfmake": "^0.3.5",
|
||||
"prisma": "^7.3.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"zod": "^4.3.6",
|
||||
@@ -62,6 +64,10 @@
|
||||
|
||||
"@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="],
|
||||
|
||||
"@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="],
|
||||
|
||||
"@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="],
|
||||
|
||||
"@prisma/adapter-pg": ["@prisma/adapter-pg@7.3.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.3.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-iuYQMbIPO6i9O45Fv8TB7vWu00BXhCaNAShenqF7gLExGDbnGp5BfFB4yz1K59zQ59jF6tQ9YHrg0P6/J3OoLg=="],
|
||||
|
||||
"@prisma/client": ["@prisma/client@7.3.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.3.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-FXBIxirqQfdC6b6HnNgxGmU7ydCPEPk7maHMOduJJfnTP+MuOGa15X4omjR/zpPUUpm8ef/mEFQjJudOGkXFcQ=="],
|
||||
@@ -96,6 +102,8 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
||||
|
||||
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
|
||||
@@ -116,10 +124,14 @@
|
||||
|
||||
"axios": ["axios@1.13.3", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g=="],
|
||||
|
||||
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
|
||||
|
||||
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
|
||||
|
||||
"blakets": ["blakets@0.1.12", "", { "dependencies": { "@prokopschield/argv": "^0.1.0-2" }, "bin": { "blake": "lib/demo.js", "blake2b": "lib/cli.js", "blake2s": "lib/cli.js", "blakejs": "lib/demo.js", "blakets": "lib/demo.js" } }, "sha512-ReOnLTDRlbExlTXbJZoA2xkvhzauJ7ldpvhKnb1cUNw8gdAHWHWOWG8XMjwpxQmmEZCDAR7VZiM5BYTUSOLVrw=="],
|
||||
|
||||
"brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||
@@ -134,6 +146,8 @@
|
||||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
|
||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
@@ -148,6 +162,8 @@
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"cuid": ["cuid@3.0.0", "", {}, "sha512-WZYYkHdIDnaxdeP8Misq3Lah5vFjJwGuItJuV+tvMafosMzw0nF297T7mrm8IOWiPJkV6gc7sa8pzx27+w25Zg=="],
|
||||
@@ -164,6 +180,8 @@
|
||||
|
||||
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
||||
|
||||
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
@@ -190,8 +208,12 @@
|
||||
|
||||
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
@@ -236,6 +258,8 @@
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"jpeg-exif": ["jpeg-exif@1.1.4", "", {}, "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ=="],
|
||||
|
||||
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
@@ -246,6 +270,8 @@
|
||||
|
||||
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
|
||||
|
||||
"linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||
@@ -292,10 +318,18 @@
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="],
|
||||
|
||||
"pdfkit": ["pdfkit@0.17.2", "", { "dependencies": { "crypto-js": "^4.2.0", "fontkit": "^2.0.4", "jpeg-exif": "^1.1.4", "linebreak": "^1.1.0", "png-js": "^1.0.0" } }, "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw=="],
|
||||
|
||||
"pdfmake": ["pdfmake@0.3.5", "", { "dependencies": { "linebreak": "^1.1.0", "pdfkit": "^0.17.2", "xmldoc": "^2.0.3" } }, "sha512-DR7jRrK4lk7UiRT6pi+NeWhW1ToTsL2Y8CH+bFKNYz3M7agIVgeCtwARveEORhCAqoG3AUDrN318xU/lkOr1Bg=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"pg": ["pg@8.17.2", "", { "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw=="],
|
||||
@@ -316,6 +350,8 @@
|
||||
|
||||
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
||||
|
||||
"png-js": ["png-js@1.0.0", "", {}, "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="],
|
||||
|
||||
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
|
||||
|
||||
"postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
|
||||
@@ -350,12 +386,16 @@
|
||||
|
||||
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="],
|
||||
|
||||
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
|
||||
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
@@ -382,12 +422,20 @@
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="],
|
||||
|
||||
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
|
||||
|
||||
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
|
||||
@@ -398,6 +446,8 @@
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"xmldoc": ["xmldoc@2.0.3", "", { "dependencies": { "sax": "^1.4.3" } }, "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="],
|
||||
@@ -414,10 +464,18 @@
|
||||
|
||||
"@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="],
|
||||
|
||||
"@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="],
|
||||
|
||||
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||
|
||||
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a print-friendly PDF report from the latest test-webserver log file.
|
||||
|
||||
Usage:
|
||||
python3 generate_log_report.py [optional_log_file_path]
|
||||
|
||||
If no path is given, the script finds the latest test-webserver-*.jsonl
|
||||
file in ../cw-api-logs/.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
from datetime import datetime, timezone
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import LETTER
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate,
|
||||
Paragraph,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
PageBreak,
|
||||
HRFlowable,
|
||||
KeepTogether,
|
||||
)
|
||||
from reportlab.graphics.shapes import Drawing, String
|
||||
from reportlab.graphics.charts.piecharts import Pie
|
||||
from reportlab.graphics.charts.barcharts import VerticalBarChart
|
||||
|
||||
# ─── Print-friendly color palette ─────────────────────────────────────────────
|
||||
# Minimal ink: white backgrounds, thin borders, dark text, subtle accents
|
||||
HEADER_BG = colors.HexColor("#2c3e50") # Dark header (used sparingly)
|
||||
ACCENT = colors.HexColor("#2980b9") # Muted blue
|
||||
ACCENT_2 = colors.HexColor("#27ae60") # Muted green
|
||||
ACCENT_3 = colors.HexColor("#8e44ad") # Muted purple
|
||||
WHITE = colors.white
|
||||
GRAY_50 = colors.HexColor("#fafafa")
|
||||
GRAY_100 = colors.HexColor("#f5f5f5")
|
||||
GRAY_200 = colors.HexColor("#e0e0e0")
|
||||
GRAY_400 = colors.HexColor("#bdbdbd")
|
||||
GRAY_600 = colors.HexColor("#757575")
|
||||
GRAY_800 = colors.HexColor("#424242")
|
||||
GRAY_900 = colors.HexColor("#212121")
|
||||
|
||||
# Pie/chart fills — muted, distinguishable in B&W too
|
||||
PIE_COLORS = [
|
||||
colors.HexColor("#5b9bd5"), # steel blue
|
||||
colors.HexColor("#ed7d31"), # soft orange
|
||||
colors.HexColor("#a5a5a5"), # gray
|
||||
colors.HexColor("#ffc000"), # amber
|
||||
colors.HexColor("#70ad47"), # olive green
|
||||
colors.HexColor("#4472c4"), # darker blue
|
||||
colors.HexColor("#c55a11"), # brown
|
||||
colors.HexColor("#7030a0"), # purple
|
||||
]
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def find_latest_log(base_dir):
|
||||
pattern = os.path.join(base_dir, "test-webserver-*.jsonl")
|
||||
files = sorted(glob.glob(pattern))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No test-webserver log files found in {base_dir}")
|
||||
return files[-1]
|
||||
|
||||
|
||||
def parse_log(path):
|
||||
entries = []
|
||||
with open(path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return entries
|
||||
|
||||
|
||||
def fmt_ts(iso_str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
except Exception:
|
||||
return str(iso_str)
|
||||
|
||||
|
||||
def duration_str(seconds):
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes = seconds / 60
|
||||
if minutes < 60:
|
||||
return f"{minutes:.1f}m"
|
||||
hours = minutes / 60
|
||||
return f"{hours:.1f}h"
|
||||
|
||||
|
||||
def truncate(s, max_len=50):
|
||||
s = str(s)
|
||||
return s if len(s) <= max_len else s[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def resolve_actor(entry):
|
||||
"""
|
||||
Derive the actor exactly like testWebserver.ts does:
|
||||
entityUpdatedBy ?? query.params.memberId ?? summary.memberId ?? "-"
|
||||
The summary is already stored in request.summary in the log.
|
||||
"""
|
||||
req = entry.get("request", {})
|
||||
summary = req.get("summary") or {}
|
||||
query = req.get("query") or {}
|
||||
params = query.get("params") or {}
|
||||
|
||||
return str(
|
||||
summary.get("entityUpdatedBy")
|
||||
or params.get("memberId")
|
||||
or summary.get("memberId")
|
||||
or "-"
|
||||
)
|
||||
|
||||
|
||||
# ─── Analysis ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def analyze(entries):
|
||||
stats = {}
|
||||
|
||||
timestamps = []
|
||||
for e in entries:
|
||||
ts = e.get("timestamp")
|
||||
if ts:
|
||||
try:
|
||||
timestamps.append(datetime.fromisoformat(ts.replace("Z", "+00:00")))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
timestamps.sort()
|
||||
stats["total_entries"] = len(entries)
|
||||
stats["first_ts"] = timestamps[0] if timestamps else None
|
||||
stats["last_ts"] = timestamps[-1] if timestamps else None
|
||||
stats["duration_seconds"] = (
|
||||
(timestamps[-1] - timestamps[0]).total_seconds() if len(timestamps) >= 2 else 0
|
||||
)
|
||||
|
||||
# Global counters
|
||||
methods = Counter()
|
||||
paths = Counter()
|
||||
statuses = Counter()
|
||||
actions = Counter()
|
||||
types = Counter()
|
||||
actors = Counter()
|
||||
companies = Counter()
|
||||
entity_ids = set()
|
||||
stages = Counter()
|
||||
ratings = Counter()
|
||||
hourly_buckets = Counter()
|
||||
minute_buckets = Counter()
|
||||
|
||||
for e in entries:
|
||||
req = e.get("request", {})
|
||||
resp = e.get("response", {})
|
||||
bp = req.get("bodyParsed") or {}
|
||||
entity = req.get("entityParsed") or bp.get("Entity") or {}
|
||||
|
||||
methods[req.get("method", "?")] += 1
|
||||
raw_path = req.get("path", "?").split("?")[0]
|
||||
paths[raw_path] += 1
|
||||
statuses[resp.get("status", "?")] += 1
|
||||
actions[bp.get("Action", "?")] += 1
|
||||
types[bp.get("Type", "?")] += 1
|
||||
|
||||
actor = resolve_actor(e)
|
||||
actors[actor] += 1
|
||||
|
||||
if isinstance(entity, dict):
|
||||
cn = entity.get("CompanyName")
|
||||
if cn:
|
||||
companies[cn] += 1
|
||||
eid = entity.get("Id")
|
||||
if eid is not None:
|
||||
entity_ids.add(eid)
|
||||
stage = entity.get("StageName")
|
||||
if stage:
|
||||
stages[stage] += 1
|
||||
rating = entity.get("Rating")
|
||||
if rating:
|
||||
ratings[rating] += 1
|
||||
|
||||
ts_str = e.get("timestamp")
|
||||
if ts_str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
hourly_buckets[dt.strftime("%H:00")] += 1
|
||||
minute_buckets[dt.strftime("%H:%M")] += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Per-actor deep stats ──
|
||||
actor_details = defaultdict(lambda: {
|
||||
"count": 0,
|
||||
"actions": Counter(),
|
||||
"types": Counter(),
|
||||
"companies": Counter(),
|
||||
"entity_ids": set(),
|
||||
"stages": Counter(),
|
||||
"ratings": Counter(),
|
||||
"timestamps": [],
|
||||
"statuses": Counter(),
|
||||
"paths": Counter(),
|
||||
"member_ids": Counter(),
|
||||
"sales_reps": Counter(),
|
||||
"total_estimated": 0.0,
|
||||
})
|
||||
|
||||
for e in entries:
|
||||
req = e.get("request", {})
|
||||
resp = e.get("response", {})
|
||||
bp = req.get("bodyParsed") or {}
|
||||
entity = req.get("entityParsed") or bp.get("Entity") or {}
|
||||
summary = req.get("summary") or {}
|
||||
|
||||
actor = resolve_actor(e)
|
||||
ad = actor_details[actor]
|
||||
ad["count"] += 1
|
||||
ad["actions"][bp.get("Action", "?")] += 1
|
||||
ad["types"][bp.get("Type", "?")] += 1
|
||||
ad["statuses"][resp.get("status", "?")] += 1
|
||||
raw_path = req.get("path", "?").split("?")[0]
|
||||
ad["paths"][raw_path] += 1
|
||||
|
||||
# Track which MemberIds triggered callbacks for this actor
|
||||
mid = bp.get("MemberId")
|
||||
if mid:
|
||||
ad["member_ids"][mid] += 1
|
||||
|
||||
ts_str = e.get("timestamp")
|
||||
if ts_str:
|
||||
try:
|
||||
ad["timestamps"].append(datetime.fromisoformat(ts_str.replace("Z", "+00:00")))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if isinstance(entity, dict):
|
||||
cn = entity.get("CompanyName")
|
||||
if cn:
|
||||
ad["companies"][cn] += 1
|
||||
eid = entity.get("Id")
|
||||
if eid is not None:
|
||||
ad["entity_ids"].add(eid)
|
||||
stage = entity.get("StageName")
|
||||
if stage:
|
||||
ad["stages"][stage] += 1
|
||||
rating = entity.get("Rating")
|
||||
if rating:
|
||||
ad["ratings"][rating] += 1
|
||||
et = entity.get("EstimatedTotal")
|
||||
if et is not None:
|
||||
ad["total_estimated"] += float(et)
|
||||
pr = entity.get("PrimarySalesRep")
|
||||
if pr:
|
||||
ad["sales_reps"][pr] += 1
|
||||
|
||||
# Compute per-actor derived stats
|
||||
for aid, ad in actor_details.items():
|
||||
ad["timestamps"].sort()
|
||||
if len(ad["timestamps"]) >= 2:
|
||||
dur = (ad["timestamps"][-1] - ad["timestamps"][0]).total_seconds()
|
||||
ad["duration_seconds"] = dur
|
||||
ad["events_per_minute"] = ad["count"] / (dur / 60) if dur > 0 else ad["count"]
|
||||
else:
|
||||
ad["duration_seconds"] = 0
|
||||
ad["events_per_minute"] = ad["count"]
|
||||
ad["first_ts"] = ad["timestamps"][0] if ad["timestamps"] else None
|
||||
ad["last_ts"] = ad["timestamps"][-1] if ad["timestamps"] else None
|
||||
|
||||
stats["actor_details"] = dict(actor_details)
|
||||
stats["methods"] = methods
|
||||
stats["paths"] = paths
|
||||
stats["statuses"] = statuses
|
||||
stats["actions"] = actions
|
||||
stats["types"] = types
|
||||
stats["actors"] = actors
|
||||
stats["companies"] = companies
|
||||
stats["entity_ids"] = entity_ids
|
||||
stats["stages"] = stages
|
||||
stats["ratings"] = ratings
|
||||
stats["hourly_buckets"] = hourly_buckets
|
||||
stats["minute_buckets"] = minute_buckets
|
||||
|
||||
if stats["duration_seconds"] > 0:
|
||||
stats["events_per_minute"] = len(entries) / (stats["duration_seconds"] / 60)
|
||||
else:
|
||||
stats["events_per_minute"] = len(entries)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# ─── PDF building ─────────────────────────────────────────────────────────────
|
||||
|
||||
def build_styles():
|
||||
ss = getSampleStyleSheet()
|
||||
ss.add(ParagraphStyle(
|
||||
"ReportTitle", parent=ss["Title"], fontSize=24, textColor=WHITE,
|
||||
spaceAfter=4, fontName="Helvetica-Bold", alignment=TA_CENTER,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"ReportSubtitle", parent=ss["Normal"], fontSize=11, textColor=GRAY_400,
|
||||
spaceAfter=2, fontName="Helvetica", alignment=TA_CENTER,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"SectionHeader", parent=ss["Heading1"], fontSize=16, textColor=GRAY_900,
|
||||
spaceBefore=14, spaceAfter=6, fontName="Helvetica-Bold",
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"SubHeader", parent=ss["Heading2"], fontSize=12, textColor=GRAY_800,
|
||||
spaceBefore=10, spaceAfter=4, fontName="Helvetica-Bold",
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"BodyText2", parent=ss["Normal"], fontSize=9, textColor=GRAY_800,
|
||||
spaceAfter=3, fontName="Helvetica", leading=13,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"SmallGray", parent=ss["Normal"], fontSize=8, textColor=GRAY_600,
|
||||
spaceAfter=2, fontName="Helvetica",
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"KPIValue", parent=ss["Normal"], fontSize=20, textColor=GRAY_900,
|
||||
fontName="Helvetica-Bold", alignment=TA_CENTER, leading=24,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"KPILabel", parent=ss["Normal"], fontSize=8, textColor=GRAY_600,
|
||||
fontName="Helvetica", alignment=TA_CENTER, spaceAfter=2,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"BannerText", parent=ss["Normal"], fontSize=9, textColor=WHITE,
|
||||
fontName="Helvetica-Bold", spaceAfter=1,
|
||||
))
|
||||
return ss
|
||||
|
||||
|
||||
def make_header_banner(ss, log_path, stats):
|
||||
elements = []
|
||||
fname = os.path.basename(log_path)
|
||||
banner_data = [[
|
||||
Paragraph("Webhook Log Report", ss["ReportTitle"]),
|
||||
], [
|
||||
Paragraph(fname, ss["ReportSubtitle"]),
|
||||
], [
|
||||
Paragraph(
|
||||
f"Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
|
||||
ss["ReportSubtitle"],
|
||||
),
|
||||
]]
|
||||
banner = Table(banner_data, colWidths=[7.0 * inch])
|
||||
banner.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), HEADER_BG),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("TOPPADDING", (0, 0), (0, 0), 20),
|
||||
("BOTTOMPADDING", (0, -1), (-1, -1), 16),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 20),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 20),
|
||||
]))
|
||||
elements.append(banner)
|
||||
elements.append(Spacer(1, 14))
|
||||
return elements
|
||||
|
||||
|
||||
def make_kpi_card(label, value):
|
||||
ss = build_styles()
|
||||
card_data = [[
|
||||
Paragraph(str(value), ss["KPIValue"]),
|
||||
], [
|
||||
Paragraph(label, ss["KPILabel"]),
|
||||
]]
|
||||
card = Table(card_data, colWidths=[1.6 * inch], rowHeights=[28, 16])
|
||||
card.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), GRAY_100),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOX", (0, 0), (-1, -1), 0.5, GRAY_200),
|
||||
]))
|
||||
return card
|
||||
|
||||
|
||||
def make_kpi_row(cards_data):
|
||||
cards = [make_kpi_card(label, value) for label, value in cards_data]
|
||||
row = Table([cards], colWidths=[1.75 * inch] * len(cards))
|
||||
row.setStyle(TableStyle([
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
]))
|
||||
return row
|
||||
|
||||
|
||||
def make_table(title, counter, ss, max_rows=15):
|
||||
"""Generic table from a Counter — light styling for print."""
|
||||
elements = []
|
||||
elements.append(Paragraph(title, ss["SubHeader"]))
|
||||
|
||||
items = counter.most_common(max_rows)
|
||||
if not items:
|
||||
elements.append(Paragraph("<i>No data</i>", ss["BodyText2"]))
|
||||
return elements
|
||||
|
||||
total = sum(counter.values())
|
||||
header = ["Item", "Count", "%"]
|
||||
rows = [header]
|
||||
for name, count in items:
|
||||
pct = (count / total * 100) if total else 0
|
||||
rows.append([truncate(str(name), 45), f"{count:,}", f"{pct:.1f}%"])
|
||||
|
||||
t = Table(rows, colWidths=[3.6 * inch, 1.0 * inch, 0.8 * inch])
|
||||
t.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), GRAY_800),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 9),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 9),
|
||||
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
|
||||
("BOTTOMPADDING", (0, 0), (-1, 0), 6),
|
||||
("TOPPADDING", (0, 0), (-1, 0), 6),
|
||||
("GRID", (0, 0), (-1, -1), 0.4, GRAY_200),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, GRAY_50]),
|
||||
("ALIGN", (1, 0), (2, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
||||
("TOPPADDING", (0, 1), (-1, -1), 3),
|
||||
("BOTTOMPADDING", (0, 1), (-1, -1), 3),
|
||||
]))
|
||||
elements.append(t)
|
||||
return elements
|
||||
|
||||
|
||||
def make_pie_chart(title, counter, width=280, height=190):
|
||||
items = counter.most_common(8)
|
||||
if not items:
|
||||
return Spacer(1, 1)
|
||||
|
||||
d = Drawing(width, height)
|
||||
d.add(String(width / 2, height - 12, title,
|
||||
fontSize=10, fontName="Helvetica-Bold",
|
||||
fillColor=GRAY_900, textAnchor="middle"))
|
||||
|
||||
pie = Pie()
|
||||
pie.x = 50
|
||||
pie.y = 10
|
||||
pie.width = 110
|
||||
pie.height = 110
|
||||
pie.data = [v for _, v in items]
|
||||
pie.labels = [truncate(str(k), 18) for k, _ in items]
|
||||
pie.sideLabels = True
|
||||
pie.slices.strokeWidth = 0.5
|
||||
pie.slices.strokeColor = WHITE
|
||||
|
||||
for i in range(len(items)):
|
||||
pie.slices[i].fillColor = PIE_COLORS[i % len(PIE_COLORS)]
|
||||
pie.slices[i].fontName = "Helvetica"
|
||||
pie.slices[i].fontSize = 7
|
||||
pie.slices[i].labelRadius = 1.35
|
||||
|
||||
d.add(pie)
|
||||
return d
|
||||
|
||||
|
||||
def make_timeline_chart(minute_buckets, width=500, height=150):
|
||||
if not minute_buckets:
|
||||
return Spacer(1, 1)
|
||||
|
||||
sorted_keys = sorted(minute_buckets.keys())
|
||||
if len(sorted_keys) > 40:
|
||||
step = max(1, len(sorted_keys) // 40)
|
||||
sampled_keys = sorted_keys[::step]
|
||||
else:
|
||||
sampled_keys = sorted_keys
|
||||
|
||||
values = [minute_buckets[k] for k in sampled_keys]
|
||||
|
||||
d = Drawing(width, height)
|
||||
d.add(String(width / 2, height - 10, "Event Timeline (by minute)",
|
||||
fontSize=10, fontName="Helvetica-Bold",
|
||||
fillColor=GRAY_900, textAnchor="middle"))
|
||||
|
||||
chart = VerticalBarChart()
|
||||
chart.x = 50
|
||||
chart.y = 25
|
||||
chart.width = width - 80
|
||||
chart.height = height - 50
|
||||
chart.data = [values]
|
||||
chart.categoryAxis.categoryNames = sampled_keys
|
||||
chart.categoryAxis.labels.angle = 45
|
||||
chart.categoryAxis.labels.fontSize = 6
|
||||
chart.categoryAxis.labels.fontName = "Helvetica"
|
||||
chart.categoryAxis.labels.dy = -5
|
||||
chart.valueAxis.labels.fontSize = 7
|
||||
chart.valueAxis.labels.fontName = "Helvetica"
|
||||
chart.valueAxis.valueMin = 0
|
||||
chart.bars[0].fillColor = GRAY_600
|
||||
chart.bars[0].strokeColor = None
|
||||
chart.barWidth = max(2, int((width - 100) / len(values) * 0.7))
|
||||
|
||||
d.add(chart)
|
||||
return d
|
||||
|
||||
|
||||
def build_actor_activity_section(stats, ss):
|
||||
"""Per-actor deep-dive. Actor = entityUpdatedBy ?? query.memberId ?? summary.memberId."""
|
||||
elements = []
|
||||
elements.append(PageBreak())
|
||||
elements.append(Paragraph("Actor Activity Deep Dive", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=4))
|
||||
elements.append(Paragraph(
|
||||
'The <b>actor</b> is resolved as: <i>entityUpdatedBy → query.memberId → summary.memberId</i>. '
|
||||
'This is the person or system that caused the change in ConnectWise.',
|
||||
ss["SmallGray"],
|
||||
))
|
||||
elements.append(Spacer(1, 10))
|
||||
|
||||
actor_details = stats.get("actor_details", {})
|
||||
if not actor_details:
|
||||
elements.append(Paragraph("<i>No actor data available.</i>", ss["BodyText2"]))
|
||||
return elements
|
||||
|
||||
sorted_actors = sorted(actor_details.items(), key=lambda x: -x[1]["count"])
|
||||
|
||||
# Actor distribution pie chart
|
||||
actor_counter = Counter({aid: ad["count"] for aid, ad in sorted_actors})
|
||||
elements.append(make_pie_chart("Events by Actor", actor_counter, width=350, height=210))
|
||||
elements.append(Spacer(1, 10))
|
||||
|
||||
for idx, (aid, ad) in enumerate(sorted_actors):
|
||||
# Actor header — slim dark bar
|
||||
banner_data = [[
|
||||
Paragraph(
|
||||
f'<font size="12"><b>{aid}</b></font>'
|
||||
f' '
|
||||
f'<font size="9" color="#cccccc">{ad["count"]:,} events</font>',
|
||||
ss["BannerText"],
|
||||
),
|
||||
]]
|
||||
banner = Table(banner_data, colWidths=[7.0 * inch])
|
||||
banner.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), HEADER_BG),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 7),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 7),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 12),
|
||||
]))
|
||||
elements.append(banner)
|
||||
|
||||
# KPI row
|
||||
kpi = make_kpi_row([
|
||||
("Events", f"{ad['count']:,}"),
|
||||
("Entities", f"{len(ad['entity_ids']):,}"),
|
||||
("Companies", f"{len(ad['companies']):,}"),
|
||||
("Evts/Min", f"{ad['events_per_minute']:.1f}"),
|
||||
])
|
||||
elements.append(kpi)
|
||||
elements.append(Spacer(1, 4))
|
||||
|
||||
# Info grid
|
||||
first_str = ad["first_ts"].strftime("%Y-%m-%d %H:%M:%S") if ad["first_ts"] else "—"
|
||||
last_str = ad["last_ts"].strftime("%Y-%m-%d %H:%M:%S") if ad["last_ts"] else "—"
|
||||
dur = duration_str(ad["duration_seconds"])
|
||||
est = ad["total_estimated"]
|
||||
|
||||
mid_str = ", ".join(f"{k} ({v})" for k, v in ad["member_ids"].most_common(5)) if ad["member_ids"] else "—"
|
||||
sr_str = ", ".join(f"{k} ({v})" for k, v in ad["sales_reps"].most_common(5)) if ad["sales_reps"] else "—"
|
||||
|
||||
info_rows = [
|
||||
[
|
||||
Paragraph('<font color="#757575">First Event</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{first_str}</b>', ss["BodyText2"]),
|
||||
Paragraph('<font color="#757575">Last Event</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{last_str}</b>', ss["BodyText2"]),
|
||||
],
|
||||
[
|
||||
Paragraph('<font color="#757575">Active Duration</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{dur}</b>', ss["BodyText2"]),
|
||||
Paragraph('<font color="#757575">Total Est. Value</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>${est:,.2f}</b>', ss["BodyText2"]),
|
||||
],
|
||||
[
|
||||
Paragraph('<font color="#757575">Callback Members</font>', ss["BodyText2"]),
|
||||
Paragraph(f'{truncate(mid_str, 40)}', ss["BodyText2"]),
|
||||
Paragraph('<font color="#757575">Sales Reps</font>', ss["BodyText2"]),
|
||||
Paragraph(f'{truncate(sr_str, 40)}', ss["BodyText2"]),
|
||||
],
|
||||
]
|
||||
info_table = Table(info_rows, colWidths=[1.3 * inch, 2.1 * inch, 1.3 * inch, 2.1 * inch])
|
||||
info_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), GRAY_50),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("GRID", (0, 0), (-1, -1), 0.3, GRAY_200),
|
||||
]))
|
||||
elements.append(info_table)
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
# Breakdown tables
|
||||
if ad["types"]:
|
||||
elements.extend(make_table(f"{aid} — Entity Types", ad["types"], ss, max_rows=8))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
if ad["companies"]:
|
||||
elements.extend(make_table(f"{aid} — Companies", ad["companies"], ss, max_rows=10))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
if ad["stages"]:
|
||||
elements.extend(make_table(f"{aid} — Stages", ad["stages"], ss, max_rows=8))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
if ad["ratings"]:
|
||||
elements.extend(make_table(f"{aid} — Ratings", ad["ratings"], ss, max_rows=8))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
# Entity IDs
|
||||
if ad["entity_ids"]:
|
||||
id_list = sorted(ad["entity_ids"])
|
||||
id_str = ", ".join(str(i) for i in id_list[:30])
|
||||
if len(id_list) > 30:
|
||||
id_str += f" ... (+{len(id_list) - 30} more)"
|
||||
elements.append(Paragraph(f"{aid} — Entity IDs Touched", ss["SubHeader"]))
|
||||
elements.append(Paragraph(f'<font size="8">{id_str}</font>', ss["BodyText2"]))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
# Divider
|
||||
if idx < len(sorted_actors) - 1:
|
||||
elements.append(Spacer(1, 4))
|
||||
elements.append(HRFlowable(width="100%", thickness=0.5, color=GRAY_200, spaceAfter=4))
|
||||
elements.append(Spacer(1, 4))
|
||||
|
||||
return elements
|
||||
|
||||
|
||||
def build_summary_log_table(entries, ss, max_rows=30):
|
||||
elements = []
|
||||
elements.append(PageBreak())
|
||||
elements.append(Paragraph("Event Summary", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=6))
|
||||
elements.append(Paragraph(
|
||||
f"Aggregated view of {len(entries):,} webhook events — grouped by entity.",
|
||||
ss["SmallGray"],
|
||||
))
|
||||
elements.append(Spacer(1, 8))
|
||||
|
||||
entity_groups = defaultdict(lambda: {
|
||||
"count": 0, "name": "—", "company": "—",
|
||||
"actions": Counter(), "actors": set(),
|
||||
"est_total": None,
|
||||
})
|
||||
|
||||
for e in entries:
|
||||
req = e.get("request", {})
|
||||
bp = req.get("bodyParsed") or {}
|
||||
entity = req.get("entityParsed") or bp.get("Entity") or {}
|
||||
if not isinstance(entity, dict):
|
||||
continue
|
||||
eid = entity.get("Id")
|
||||
if eid is None:
|
||||
continue
|
||||
|
||||
eg = entity_groups[eid]
|
||||
eg["count"] += 1
|
||||
eg["name"] = entity.get("OpportunityName") or entity.get("CompanyName") or eg["name"]
|
||||
eg["company"] = entity.get("CompanyName") or eg["company"]
|
||||
eg["actions"][bp.get("Action", "?")] += 1
|
||||
actor = resolve_actor(e)
|
||||
eg["actors"].add(actor)
|
||||
et = entity.get("EstimatedTotal")
|
||||
if et is not None:
|
||||
eg["est_total"] = et
|
||||
|
||||
sorted_entities = sorted(entity_groups.items(), key=lambda x: -x[1]["count"])
|
||||
|
||||
header = ["ID", "Entity Name", "Company", "Events", "Actions", "Actors", "Est. Total"]
|
||||
rows = [header]
|
||||
|
||||
for eid, eg in sorted_entities[:max_rows]:
|
||||
actions_str = ", ".join(f"{a}({c})" for a, c in eg["actions"].most_common(3))
|
||||
actors_str = ", ".join(sorted(eg["actors"]))
|
||||
total_str = f"${eg['est_total']:,.2f}" if eg["est_total"] is not None else "—"
|
||||
rows.append([
|
||||
str(eid),
|
||||
truncate(eg["name"], 28),
|
||||
truncate(eg["company"], 18),
|
||||
f"{eg['count']:,}",
|
||||
truncate(actions_str, 24),
|
||||
truncate(actors_str, 18),
|
||||
total_str,
|
||||
])
|
||||
|
||||
t = Table(rows, colWidths=[0.45 * inch, 1.7 * inch, 1.1 * inch, 0.55 * inch, 1.2 * inch, 1.0 * inch, 0.8 * inch])
|
||||
t.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), GRAY_800),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 8),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 8),
|
||||
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
|
||||
("GRID", (0, 0), (-1, -1), 0.3, GRAY_200),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, GRAY_50]),
|
||||
("ALIGN", (0, 0), (0, -1), "CENTER"),
|
||||
("ALIGN", (3, 0), (3, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
||||
("TOPPADDING", (0, 1), (-1, -1), 2),
|
||||
("BOTTOMPADDING", (0, 1), (-1, -1), 2),
|
||||
]))
|
||||
elements.append(t)
|
||||
|
||||
if len(sorted_entities) > max_rows:
|
||||
elements.append(Spacer(1, 4))
|
||||
elements.append(Paragraph(
|
||||
f'Showing top {max_rows} of {len(sorted_entities)} entities.',
|
||||
ss["SmallGray"],
|
||||
))
|
||||
|
||||
return elements
|
||||
|
||||
|
||||
def add_page_number(canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFillColor(GRAY_800)
|
||||
canvas.rect(0, 0, LETTER[0], 22, fill=1, stroke=0)
|
||||
canvas.setFillColor(WHITE)
|
||||
canvas.setFont("Helvetica", 7)
|
||||
canvas.drawCentredString(LETTER[0] / 2, 8, f"Page {doc.page}")
|
||||
canvas.setFillColor(GRAY_400)
|
||||
canvas.setFont("Helvetica", 7)
|
||||
canvas.drawString(30, 8, "Optima API · Webhook Log Report")
|
||||
canvas.drawRightString(LETTER[0] - 30, 8, datetime.now(timezone.utc).strftime("%Y-%m-%d"))
|
||||
canvas.restoreState()
|
||||
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
log_dir = os.path.join(script_dir, "..", "cw-api-logs")
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
log_path = sys.argv[1]
|
||||
else:
|
||||
log_path = find_latest_log(log_dir)
|
||||
|
||||
print(f"📄 Reading log: {log_path}")
|
||||
entries = parse_log(log_path)
|
||||
print(f" → {len(entries)} entries parsed")
|
||||
|
||||
stats = analyze(entries)
|
||||
ss = build_styles()
|
||||
|
||||
log_basename = os.path.splitext(os.path.basename(log_path))[0]
|
||||
out_path = os.path.join(script_dir, "..", "cw-api-logs", f"{log_basename}-report.pdf")
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
out_path,
|
||||
pagesize=LETTER,
|
||||
leftMargin=0.6 * inch,
|
||||
rightMargin=0.6 * inch,
|
||||
topMargin=0.5 * inch,
|
||||
bottomMargin=0.5 * inch,
|
||||
)
|
||||
|
||||
elements = []
|
||||
|
||||
# ── Title Banner ──
|
||||
elements.extend(make_header_banner(ss, log_path, stats))
|
||||
|
||||
# ── Overview ──
|
||||
elements.append(Paragraph("Overview", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
|
||||
|
||||
elements.append(make_kpi_row([
|
||||
("Total Events", f"{stats['total_entries']:,}"),
|
||||
("Unique Entities", f"{len(stats['entity_ids']):,}"),
|
||||
("Companies", f"{len(stats['companies']):,}"),
|
||||
("Duration", duration_str(stats["duration_seconds"])),
|
||||
]))
|
||||
elements.append(Spacer(1, 8))
|
||||
elements.append(make_kpi_row([
|
||||
("Events / Min", f"{stats['events_per_minute']:.1f}"),
|
||||
("HTTP Methods", f"{len(stats['methods']):,}"),
|
||||
("Action Types", f"{len(stats['actions']):,}"),
|
||||
("Actors", f"{len(stats['actors']):,}"),
|
||||
]))
|
||||
elements.append(Spacer(1, 12))
|
||||
|
||||
# Time range
|
||||
if stats["first_ts"] and stats["last_ts"]:
|
||||
info = [
|
||||
[
|
||||
Paragraph('<font color="#757575">First Event</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{stats["first_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}</b>', ss["BodyText2"]),
|
||||
Paragraph('<font color="#757575">Last Event</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{stats["last_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}</b>', ss["BodyText2"]),
|
||||
]
|
||||
]
|
||||
ti = Table(info, colWidths=[1.2 * inch, 2.4 * inch, 1.2 * inch, 2.4 * inch])
|
||||
ti.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), GRAY_50),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("BOX", (0, 0), (-1, -1), 0.4, GRAY_200),
|
||||
]))
|
||||
elements.append(ti)
|
||||
elements.append(Spacer(1, 14))
|
||||
|
||||
# ── Charts ──
|
||||
elements.append(Paragraph("Visual Breakdown", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
|
||||
|
||||
elements.append(make_timeline_chart(stats["minute_buckets"]))
|
||||
elements.append(Spacer(1, 12))
|
||||
|
||||
pie_row = [[
|
||||
make_pie_chart("By Action", stats["actions"]),
|
||||
make_pie_chart("By Type", stats["types"]),
|
||||
]]
|
||||
pt = Table(pie_row, colWidths=[3.5 * inch, 3.5 * inch])
|
||||
pt.setStyle(TableStyle([
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
]))
|
||||
elements.append(pt)
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
if len(stats["stages"]) > 1 or len(stats["ratings"]) > 1:
|
||||
pie_row2 = [[
|
||||
make_pie_chart("By Stage", stats["stages"]),
|
||||
make_pie_chart("By Rating", stats["ratings"]),
|
||||
]]
|
||||
pt2 = Table(pie_row2, colWidths=[3.5 * inch, 3.5 * inch])
|
||||
pt2.setStyle(TableStyle([
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
]))
|
||||
elements.append(pt2)
|
||||
|
||||
# Actor pie chart
|
||||
elements.append(Spacer(1, 6))
|
||||
elements.append(make_pie_chart("By Actor", stats["actors"], width=350, height=210))
|
||||
|
||||
# ── General Information ──
|
||||
elements.append(PageBreak())
|
||||
elements.append(Paragraph("General Information", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
|
||||
|
||||
elements.extend(make_table("Response Status Codes", stats["statuses"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("HTTP Methods", stats["methods"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("Webhook Actions", stats["actions"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("Entity Types", stats["types"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("Request Paths", stats["paths"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("Actors", stats["actors"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
|
||||
if stats["companies"]:
|
||||
elements.extend(make_table("Companies", stats["companies"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
if stats["stages"]:
|
||||
elements.extend(make_table("Opportunity Stages", stats["stages"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
if stats["ratings"]:
|
||||
elements.extend(make_table("Opportunity Ratings", stats["ratings"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
|
||||
elements.extend(make_table("Hourly Distribution", stats["hourly_buckets"], ss))
|
||||
|
||||
# ── Actor Deep Dive ──
|
||||
elements.extend(build_actor_activity_section(stats, ss))
|
||||
|
||||
# ── Entity Summary ──
|
||||
elements.extend(build_summary_log_table(entries, ss, max_rows=30))
|
||||
|
||||
# Build
|
||||
doc.build(elements, onFirstPage=add_page_number, onLaterPages=add_page_number)
|
||||
print(f"✅ Report generated: {out_path}")
|
||||
print(f" File size: {os.path.getsize(out_path) / 1024:.1f} KB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -67,3 +67,13 @@ export type SecureValue = Prisma.SecureValueModel
|
||||
*
|
||||
*/
|
||||
export type Credential = Prisma.CredentialModel
|
||||
/**
|
||||
* Model GeneratedQuotes
|
||||
*
|
||||
*/
|
||||
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
|
||||
/**
|
||||
* Model CwMember
|
||||
*
|
||||
*/
|
||||
export type CwMember = Prisma.CwMemberModel
|
||||
|
||||
@@ -89,3 +89,13 @@ export type SecureValue = Prisma.SecureValueModel
|
||||
*
|
||||
*/
|
||||
export type Credential = Prisma.CredentialModel
|
||||
/**
|
||||
* Model GeneratedQuotes
|
||||
*
|
||||
*/
|
||||
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
|
||||
/**
|
||||
* Model CwMember
|
||||
*
|
||||
*/
|
||||
export type CwMember = Prisma.CwMemberModel
|
||||
|
||||
@@ -280,6 +280,23 @@ export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type BytesFilter<$PrismaModel = never> = {
|
||||
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
|
||||
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBytesFilter<$PrismaModel> | runtime.Bytes
|
||||
}
|
||||
|
||||
export type BytesWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
|
||||
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBytesWithAggregatesFilter<$PrismaModel> | runtime.Bytes
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBytesFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBytesFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedStringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
@@ -521,4 +538,21 @@ export type NestedJsonFilterBase<$PrismaModel = never> = {
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type NestedBytesFilter<$PrismaModel = never> = {
|
||||
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
|
||||
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBytesFilter<$PrismaModel> | runtime.Bytes
|
||||
}
|
||||
|
||||
export type NestedBytesWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
|
||||
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBytesWithAggregatesFilter<$PrismaModel> | runtime.Bytes
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBytesFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBytesFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -393,7 +393,9 @@ export const ModelName = {
|
||||
Opportunity: 'Opportunity',
|
||||
CredentialType: 'CredentialType',
|
||||
SecureValue: 'SecureValue',
|
||||
Credential: 'Credential'
|
||||
Credential: 'Credential',
|
||||
GeneratedQuotes: 'GeneratedQuotes',
|
||||
CwMember: 'CwMember'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -409,7 +411,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential"
|
||||
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential" | "generatedQuotes" | "cwMember"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
model: {
|
||||
@@ -1153,6 +1155,154 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
}
|
||||
}
|
||||
}
|
||||
GeneratedQuotes: {
|
||||
payload: Prisma.$GeneratedQuotesPayload<ExtArgs>
|
||||
fields: Prisma.GeneratedQuotesFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.GeneratedQuotesFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.GeneratedQuotesFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.GeneratedQuotesFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.GeneratedQuotesFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.GeneratedQuotesFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.GeneratedQuotesCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.GeneratedQuotesCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.GeneratedQuotesCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.GeneratedQuotesDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.GeneratedQuotesUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.GeneratedQuotesDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.GeneratedQuotesUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.GeneratedQuotesUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.GeneratedQuotesUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.GeneratedQuotesAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateGeneratedQuotes>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.GeneratedQuotesGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.GeneratedQuotesGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.GeneratedQuotesCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.GeneratedQuotesCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
CwMember: {
|
||||
payload: Prisma.$CwMemberPayload<ExtArgs>
|
||||
fields: Prisma.CwMemberFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.CwMemberFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.CwMemberFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.CwMemberFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.CwMemberFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.CwMemberFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.CwMemberCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.CwMemberCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.CwMemberCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.CwMemberDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.CwMemberUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.CwMemberDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.CwMemberUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.CwMemberUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.CwMemberUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.CwMemberAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateCwMember>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.CwMemberGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.CwMemberGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.CwMemberCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.CwMemberCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} & {
|
||||
other: {
|
||||
@@ -1322,6 +1472,7 @@ export const OpportunityScalarFieldEnum = {
|
||||
siteName: 'siteName',
|
||||
customerPO: 'customerPO',
|
||||
totalSalesTax: 'totalSalesTax',
|
||||
probability: 'probability',
|
||||
locationName: 'locationName',
|
||||
locationCwId: 'locationCwId',
|
||||
departmentName: 'departmentName',
|
||||
@@ -1336,6 +1487,7 @@ export const OpportunityScalarFieldEnum = {
|
||||
companyId: 'companyId',
|
||||
productSequence: 'productSequence',
|
||||
cwLastUpdated: 'cwLastUpdated',
|
||||
cwDateEntered: 'cwDateEntered',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
@@ -1384,6 +1536,40 @@ export const CredentialScalarFieldEnum = {
|
||||
export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof CredentialScalarFieldEnum]
|
||||
|
||||
|
||||
export const GeneratedQuotesScalarFieldEnum = {
|
||||
id: 'id',
|
||||
quoteRegenData: 'quoteRegenData',
|
||||
quoteRegenParams: 'quoteRegenParams',
|
||||
quoteRegenHash: 'quoteRegenHash',
|
||||
downloads: 'downloads',
|
||||
quoteFile: 'quoteFile',
|
||||
quoteFileName: 'quoteFileName',
|
||||
opportunityId: 'opportunityId',
|
||||
createdById: 'createdById',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
|
||||
|
||||
|
||||
export const CwMemberScalarFieldEnum = {
|
||||
id: 'id',
|
||||
cwMemberId: 'cwMemberId',
|
||||
identifier: 'identifier',
|
||||
firstName: 'firstName',
|
||||
lastName: 'lastName',
|
||||
officeEmail: 'officeEmail',
|
||||
inactiveFlag: 'inactiveFlag',
|
||||
apiKey: 'apiKey',
|
||||
cwLastUpdated: 'cwLastUpdated',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type CwMemberScalarFieldEnum = (typeof CwMemberScalarFieldEnum)[keyof typeof CwMemberScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -1506,6 +1692,20 @@ export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'J
|
||||
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Bytes'
|
||||
*/
|
||||
export type BytesFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Bytes'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Bytes[]'
|
||||
*/
|
||||
export type ListBytesFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Bytes[]'>
|
||||
|
||||
|
||||
/**
|
||||
* Batch Payload for updateMany & deleteMany & createMany
|
||||
*/
|
||||
@@ -1611,6 +1811,8 @@ export type GlobalOmitConfig = {
|
||||
credentialType?: Prisma.CredentialTypeOmit
|
||||
secureValue?: Prisma.SecureValueOmit
|
||||
credential?: Prisma.CredentialOmit
|
||||
generatedQuotes?: Prisma.GeneratedQuotesOmit
|
||||
cwMember?: Prisma.CwMemberOmit
|
||||
}
|
||||
|
||||
/* Types for Logging */
|
||||
|
||||
@@ -60,7 +60,9 @@ export const ModelName = {
|
||||
Opportunity: 'Opportunity',
|
||||
CredentialType: 'CredentialType',
|
||||
SecureValue: 'SecureValue',
|
||||
Credential: 'Credential'
|
||||
Credential: 'Credential',
|
||||
GeneratedQuotes: 'GeneratedQuotes',
|
||||
CwMember: 'CwMember'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -209,6 +211,7 @@ export const OpportunityScalarFieldEnum = {
|
||||
siteName: 'siteName',
|
||||
customerPO: 'customerPO',
|
||||
totalSalesTax: 'totalSalesTax',
|
||||
probability: 'probability',
|
||||
locationName: 'locationName',
|
||||
locationCwId: 'locationCwId',
|
||||
departmentName: 'departmentName',
|
||||
@@ -223,6 +226,7 @@ export const OpportunityScalarFieldEnum = {
|
||||
companyId: 'companyId',
|
||||
productSequence: 'productSequence',
|
||||
cwLastUpdated: 'cwLastUpdated',
|
||||
cwDateEntered: 'cwDateEntered',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
@@ -271,6 +275,40 @@ export const CredentialScalarFieldEnum = {
|
||||
export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof CredentialScalarFieldEnum]
|
||||
|
||||
|
||||
export const GeneratedQuotesScalarFieldEnum = {
|
||||
id: 'id',
|
||||
quoteRegenData: 'quoteRegenData',
|
||||
quoteRegenParams: 'quoteRegenParams',
|
||||
quoteRegenHash: 'quoteRegenHash',
|
||||
downloads: 'downloads',
|
||||
quoteFile: 'quoteFile',
|
||||
quoteFileName: 'quoteFileName',
|
||||
opportunityId: 'opportunityId',
|
||||
createdById: 'createdById',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
|
||||
|
||||
|
||||
export const CwMemberScalarFieldEnum = {
|
||||
id: 'id',
|
||||
cwMemberId: 'cwMemberId',
|
||||
identifier: 'identifier',
|
||||
firstName: 'firstName',
|
||||
lastName: 'lastName',
|
||||
officeEmail: 'officeEmail',
|
||||
inactiveFlag: 'inactiveFlag',
|
||||
apiKey: 'apiKey',
|
||||
cwLastUpdated: 'cwLastUpdated',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type CwMemberScalarFieldEnum = (typeof CwMemberScalarFieldEnum)[keyof typeof CwMemberScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
|
||||
@@ -18,4 +18,6 @@ export type * from './models/Opportunity.ts'
|
||||
export type * from './models/CredentialType.ts'
|
||||
export type * from './models/SecureValue.ts'
|
||||
export type * from './models/Credential.ts'
|
||||
export type * from './models/GeneratedQuotes.ts'
|
||||
export type * from './models/CwMember.ts'
|
||||
export type * from './commonInputTypes.ts'
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ export type OpportunityAvgAggregateOutputType = {
|
||||
contactCwId: number | null
|
||||
siteCwId: number | null
|
||||
totalSalesTax: number | null
|
||||
probability: number | null
|
||||
locationCwId: number | null
|
||||
departmentCwId: number | null
|
||||
closedByCwId: number | null
|
||||
@@ -60,6 +61,7 @@ export type OpportunitySumAggregateOutputType = {
|
||||
contactCwId: number | null
|
||||
siteCwId: number | null
|
||||
totalSalesTax: number | null
|
||||
probability: number | null
|
||||
locationCwId: number | null
|
||||
departmentCwId: number | null
|
||||
closedByCwId: number | null
|
||||
@@ -98,6 +100,7 @@ export type OpportunityMinAggregateOutputType = {
|
||||
siteName: string | null
|
||||
customerPO: string | null
|
||||
totalSalesTax: number | null
|
||||
probability: number | null
|
||||
locationName: string | null
|
||||
locationCwId: number | null
|
||||
departmentName: string | null
|
||||
@@ -111,6 +114,7 @@ export type OpportunityMinAggregateOutputType = {
|
||||
closedByCwId: number | null
|
||||
companyId: string | null
|
||||
cwLastUpdated: Date | null
|
||||
cwDateEntered: Date | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
}
|
||||
@@ -147,6 +151,7 @@ export type OpportunityMaxAggregateOutputType = {
|
||||
siteName: string | null
|
||||
customerPO: string | null
|
||||
totalSalesTax: number | null
|
||||
probability: number | null
|
||||
locationName: string | null
|
||||
locationCwId: number | null
|
||||
departmentName: string | null
|
||||
@@ -160,6 +165,7 @@ export type OpportunityMaxAggregateOutputType = {
|
||||
closedByCwId: number | null
|
||||
companyId: string | null
|
||||
cwLastUpdated: Date | null
|
||||
cwDateEntered: Date | null
|
||||
createdAt: Date | null
|
||||
updatedAt: Date | null
|
||||
}
|
||||
@@ -196,6 +202,7 @@ export type OpportunityCountAggregateOutputType = {
|
||||
siteName: number
|
||||
customerPO: number
|
||||
totalSalesTax: number
|
||||
probability: number
|
||||
locationName: number
|
||||
locationCwId: number
|
||||
departmentName: number
|
||||
@@ -210,6 +217,7 @@ export type OpportunityCountAggregateOutputType = {
|
||||
companyId: number
|
||||
productSequence: number
|
||||
cwLastUpdated: number
|
||||
cwDateEntered: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
_all: number
|
||||
@@ -230,6 +238,7 @@ export type OpportunityAvgAggregateInputType = {
|
||||
contactCwId?: true
|
||||
siteCwId?: true
|
||||
totalSalesTax?: true
|
||||
probability?: true
|
||||
locationCwId?: true
|
||||
departmentCwId?: true
|
||||
closedByCwId?: true
|
||||
@@ -250,6 +259,7 @@ export type OpportunitySumAggregateInputType = {
|
||||
contactCwId?: true
|
||||
siteCwId?: true
|
||||
totalSalesTax?: true
|
||||
probability?: true
|
||||
locationCwId?: true
|
||||
departmentCwId?: true
|
||||
closedByCwId?: true
|
||||
@@ -288,6 +298,7 @@ export type OpportunityMinAggregateInputType = {
|
||||
siteName?: true
|
||||
customerPO?: true
|
||||
totalSalesTax?: true
|
||||
probability?: true
|
||||
locationName?: true
|
||||
locationCwId?: true
|
||||
departmentName?: true
|
||||
@@ -301,6 +312,7 @@ export type OpportunityMinAggregateInputType = {
|
||||
closedByCwId?: true
|
||||
companyId?: true
|
||||
cwLastUpdated?: true
|
||||
cwDateEntered?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
}
|
||||
@@ -337,6 +349,7 @@ export type OpportunityMaxAggregateInputType = {
|
||||
siteName?: true
|
||||
customerPO?: true
|
||||
totalSalesTax?: true
|
||||
probability?: true
|
||||
locationName?: true
|
||||
locationCwId?: true
|
||||
departmentName?: true
|
||||
@@ -350,6 +363,7 @@ export type OpportunityMaxAggregateInputType = {
|
||||
closedByCwId?: true
|
||||
companyId?: true
|
||||
cwLastUpdated?: true
|
||||
cwDateEntered?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
}
|
||||
@@ -386,6 +400,7 @@ export type OpportunityCountAggregateInputType = {
|
||||
siteName?: true
|
||||
customerPO?: true
|
||||
totalSalesTax?: true
|
||||
probability?: true
|
||||
locationName?: true
|
||||
locationCwId?: true
|
||||
departmentName?: true
|
||||
@@ -400,6 +415,7 @@ export type OpportunityCountAggregateInputType = {
|
||||
companyId?: true
|
||||
productSequence?: true
|
||||
cwLastUpdated?: true
|
||||
cwDateEntered?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
_all?: true
|
||||
@@ -523,6 +539,7 @@ export type OpportunityGroupByOutputType = {
|
||||
siteName: string | null
|
||||
customerPO: string | null
|
||||
totalSalesTax: number
|
||||
probability: number
|
||||
locationName: string | null
|
||||
locationCwId: number | null
|
||||
departmentName: string | null
|
||||
@@ -537,6 +554,7 @@ export type OpportunityGroupByOutputType = {
|
||||
companyId: string | null
|
||||
productSequence: number[]
|
||||
cwLastUpdated: Date | null
|
||||
cwDateEntered: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
_count: OpportunityCountAggregateOutputType | null
|
||||
@@ -596,6 +614,7 @@ export type OpportunityWhereInput = {
|
||||
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
|
||||
probability?: Prisma.FloatFilter<"Opportunity"> | number
|
||||
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
@@ -610,8 +629,10 @@ 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
|
||||
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
|
||||
}
|
||||
|
||||
@@ -647,6 +668,7 @@ export type OpportunityOrderByWithRelationInput = {
|
||||
siteName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
customerPO?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
departmentName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
@@ -661,8 +683,10 @@ 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
|
||||
company?: Prisma.CompanyOrderByWithRelationInput
|
||||
}
|
||||
|
||||
@@ -701,6 +725,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
|
||||
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
|
||||
probability?: Prisma.FloatFilter<"Opportunity"> | number
|
||||
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
@@ -715,8 +740,10 @@ 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
|
||||
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
|
||||
}, "id" | "cwOpportunityId">
|
||||
|
||||
@@ -752,6 +779,7 @@ export type OpportunityOrderByWithAggregationInput = {
|
||||
siteName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
customerPO?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
departmentName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
@@ -766,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
|
||||
@@ -810,6 +839,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
|
||||
siteName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
customerPO?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
totalSalesTax?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number
|
||||
probability?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number
|
||||
locationName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
locationCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null
|
||||
departmentName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
@@ -824,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
|
||||
}
|
||||
@@ -860,6 +891,7 @@ export type OpportunityCreateInput = {
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
@@ -873,8 +905,10 @@ 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
|
||||
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
|
||||
}
|
||||
|
||||
@@ -910,6 +944,7 @@ export type OpportunityUncheckedCreateInput = {
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
@@ -924,8 +959,10 @@ 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
|
||||
}
|
||||
|
||||
export type OpportunityUpdateInput = {
|
||||
@@ -960,6 +997,7 @@ export type OpportunityUpdateInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -973,8 +1011,10 @@ 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
|
||||
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
|
||||
}
|
||||
|
||||
@@ -1010,6 +1050,7 @@ export type OpportunityUncheckedUpdateInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1024,8 +1065,10 @@ 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
|
||||
}
|
||||
|
||||
export type OpportunityCreateManyInput = {
|
||||
@@ -1060,6 +1103,7 @@ export type OpportunityCreateManyInput = {
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
@@ -1074,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
|
||||
}
|
||||
@@ -1110,6 +1155,7 @@ export type OpportunityUpdateManyMutationInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1123,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
|
||||
}
|
||||
@@ -1159,6 +1206,7 @@ export type OpportunityUncheckedUpdateManyInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1173,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
|
||||
}
|
||||
@@ -1227,6 +1276,7 @@ export type OpportunityCountOrderByAggregateInput = {
|
||||
siteName?: Prisma.SortOrder
|
||||
customerPO?: Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationName?: Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentName?: Prisma.SortOrder
|
||||
@@ -1241,6 +1291,7 @@ export type OpportunityCountOrderByAggregateInput = {
|
||||
companyId?: Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
cwLastUpdated?: Prisma.SortOrder
|
||||
cwDateEntered?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
}
|
||||
@@ -1259,6 +1310,7 @@ export type OpportunityAvgOrderByAggregateInput = {
|
||||
contactCwId?: Prisma.SortOrder
|
||||
siteCwId?: Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentCwId?: Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrder
|
||||
@@ -1297,6 +1349,7 @@ export type OpportunityMaxOrderByAggregateInput = {
|
||||
siteName?: Prisma.SortOrder
|
||||
customerPO?: Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationName?: Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentName?: Prisma.SortOrder
|
||||
@@ -1310,6 +1363,7 @@ export type OpportunityMaxOrderByAggregateInput = {
|
||||
closedByCwId?: Prisma.SortOrder
|
||||
companyId?: Prisma.SortOrder
|
||||
cwLastUpdated?: Prisma.SortOrder
|
||||
cwDateEntered?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
}
|
||||
@@ -1346,6 +1400,7 @@ export type OpportunityMinOrderByAggregateInput = {
|
||||
siteName?: Prisma.SortOrder
|
||||
customerPO?: Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationName?: Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentName?: Prisma.SortOrder
|
||||
@@ -1359,6 +1414,7 @@ export type OpportunityMinOrderByAggregateInput = {
|
||||
closedByCwId?: Prisma.SortOrder
|
||||
companyId?: Prisma.SortOrder
|
||||
cwLastUpdated?: Prisma.SortOrder
|
||||
cwDateEntered?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
}
|
||||
@@ -1377,12 +1433,18 @@ export type OpportunitySumOrderByAggregateInput = {
|
||||
contactCwId?: Prisma.SortOrder
|
||||
siteCwId?: Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentCwId?: Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type OpportunityScalarRelationFilter = {
|
||||
is?: Prisma.OpportunityWhereInput
|
||||
isNot?: Prisma.OpportunityWhereInput
|
||||
}
|
||||
|
||||
export type OpportunityCreateNestedManyWithoutCompanyInput = {
|
||||
create?: Prisma.XOR<Prisma.OpportunityCreateWithoutCompanyInput, Prisma.OpportunityUncheckedCreateWithoutCompanyInput> | Prisma.OpportunityCreateWithoutCompanyInput[] | Prisma.OpportunityUncheckedCreateWithoutCompanyInput[]
|
||||
connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutCompanyInput | Prisma.OpportunityCreateOrConnectWithoutCompanyInput[]
|
||||
@@ -1434,6 +1496,20 @@ export type OpportunityUpdateproductSequenceInput = {
|
||||
push?: number | number[]
|
||||
}
|
||||
|
||||
export type OpportunityCreateNestedOneWithoutGeneratedQuotesInput = {
|
||||
create?: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutGeneratedQuotesInput
|
||||
connect?: Prisma.OpportunityWhereUniqueInput
|
||||
}
|
||||
|
||||
export type OpportunityUpdateOneRequiredWithoutGeneratedQuotesNestedInput = {
|
||||
create?: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutGeneratedQuotesInput
|
||||
upsert?: Prisma.OpportunityUpsertWithoutGeneratedQuotesInput
|
||||
connect?: Prisma.OpportunityWhereUniqueInput
|
||||
update?: Prisma.XOR<Prisma.XOR<Prisma.OpportunityUpdateToOneWithWhereWithoutGeneratedQuotesInput, Prisma.OpportunityUpdateWithoutGeneratedQuotesInput>, Prisma.OpportunityUncheckedUpdateWithoutGeneratedQuotesInput>
|
||||
}
|
||||
|
||||
export type OpportunityCreateWithoutCompanyInput = {
|
||||
id?: string
|
||||
cwOpportunityId: number
|
||||
@@ -1466,6 +1542,7 @@ export type OpportunityCreateWithoutCompanyInput = {
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
@@ -1479,8 +1556,10 @@ 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
|
||||
}
|
||||
|
||||
export type OpportunityUncheckedCreateWithoutCompanyInput = {
|
||||
@@ -1515,6 +1594,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
@@ -1528,8 +1608,10 @@ 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
|
||||
}
|
||||
|
||||
export type OpportunityCreateOrConnectWithoutCompanyInput = {
|
||||
@@ -1593,6 +1675,7 @@ export type OpportunityScalarWhereInput = {
|
||||
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
|
||||
probability?: Prisma.FloatFilter<"Opportunity"> | number
|
||||
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
@@ -1607,10 +1690,235 @@ 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
|
||||
}
|
||||
|
||||
export type OpportunityCreateWithoutGeneratedQuotesInput = {
|
||||
id?: string
|
||||
cwOpportunityId: number
|
||||
name: string
|
||||
notes?: string | null
|
||||
typeName?: string | null
|
||||
typeCwId?: number | null
|
||||
stageName?: string | null
|
||||
stageCwId?: number | null
|
||||
statusName?: string | null
|
||||
statusCwId?: number | null
|
||||
priorityName?: string | null
|
||||
priorityCwId?: number | null
|
||||
ratingName?: string | null
|
||||
ratingCwId?: number | null
|
||||
source?: string | null
|
||||
campaignName?: string | null
|
||||
campaignCwId?: number | null
|
||||
primarySalesRepName?: string | null
|
||||
primarySalesRepIdentifier?: string | null
|
||||
primarySalesRepCwId?: number | null
|
||||
secondarySalesRepName?: string | null
|
||||
secondarySalesRepIdentifier?: string | null
|
||||
secondarySalesRepCwId?: number | null
|
||||
companyCwId?: number | null
|
||||
companyName?: string | null
|
||||
contactCwId?: number | null
|
||||
contactName?: string | null
|
||||
siteCwId?: number | null
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
departmentCwId?: number | null
|
||||
expectedCloseDate?: Date | string | null
|
||||
pipelineChangeDate?: Date | string | null
|
||||
dateBecameLead?: Date | string | null
|
||||
closedDate?: Date | string | null
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
cwDateEntered?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
|
||||
}
|
||||
|
||||
export type OpportunityUncheckedCreateWithoutGeneratedQuotesInput = {
|
||||
id?: string
|
||||
cwOpportunityId: number
|
||||
name: string
|
||||
notes?: string | null
|
||||
typeName?: string | null
|
||||
typeCwId?: number | null
|
||||
stageName?: string | null
|
||||
stageCwId?: number | null
|
||||
statusName?: string | null
|
||||
statusCwId?: number | null
|
||||
priorityName?: string | null
|
||||
priorityCwId?: number | null
|
||||
ratingName?: string | null
|
||||
ratingCwId?: number | null
|
||||
source?: string | null
|
||||
campaignName?: string | null
|
||||
campaignCwId?: number | null
|
||||
primarySalesRepName?: string | null
|
||||
primarySalesRepIdentifier?: string | null
|
||||
primarySalesRepCwId?: number | null
|
||||
secondarySalesRepName?: string | null
|
||||
secondarySalesRepIdentifier?: string | null
|
||||
secondarySalesRepCwId?: number | null
|
||||
companyCwId?: number | null
|
||||
companyName?: string | null
|
||||
contactCwId?: number | null
|
||||
contactName?: string | null
|
||||
siteCwId?: number | null
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
departmentCwId?: number | null
|
||||
expectedCloseDate?: Date | string | null
|
||||
pipelineChangeDate?: Date | string | null
|
||||
dateBecameLead?: Date | string | null
|
||||
closedDate?: Date | string | null
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
companyId?: string | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
cwDateEntered?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
|
||||
export type OpportunityCreateOrConnectWithoutGeneratedQuotesInput = {
|
||||
where: Prisma.OpportunityWhereUniqueInput
|
||||
create: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
}
|
||||
|
||||
export type OpportunityUpsertWithoutGeneratedQuotesInput = {
|
||||
update: Prisma.XOR<Prisma.OpportunityUpdateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedUpdateWithoutGeneratedQuotesInput>
|
||||
create: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
where?: Prisma.OpportunityWhereInput
|
||||
}
|
||||
|
||||
export type OpportunityUpdateToOneWithWhereWithoutGeneratedQuotesInput = {
|
||||
where?: Prisma.OpportunityWhereInput
|
||||
data: Prisma.XOR<Prisma.OpportunityUpdateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedUpdateWithoutGeneratedQuotesInput>
|
||||
}
|
||||
|
||||
export type OpportunityUpdateWithoutGeneratedQuotesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwOpportunityId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
typeName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
typeCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
stageName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
stageCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
statusName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
statusCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
priorityName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
priorityCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
ratingName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
ratingCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
source?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
campaignName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
campaignCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
primarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
primarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
primarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
secondarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
secondarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
secondarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
contactCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
contactName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
siteCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
departmentCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
expectedCloseDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
pipelineChangeDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
dateBecameLead?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
closedDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
|
||||
}
|
||||
|
||||
export type OpportunityUncheckedUpdateWithoutGeneratedQuotesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwOpportunityId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
typeName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
typeCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
stageName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
stageCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
statusName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
statusCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
priorityName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
priorityCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
ratingName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
ratingCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
source?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
campaignName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
campaignCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
primarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
primarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
primarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
secondarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
secondarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
secondarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
contactCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
contactName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
siteCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
departmentCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
expectedCloseDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
pipelineChangeDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
dateBecameLead?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
closedDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
||||
export type OpportunityCreateManyCompanyInput = {
|
||||
id?: string
|
||||
cwOpportunityId: number
|
||||
@@ -1643,6 +1951,7 @@ export type OpportunityCreateManyCompanyInput = {
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
@@ -1656,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
|
||||
}
|
||||
@@ -1692,6 +2002,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1705,8 +2016,10 @@ 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
|
||||
}
|
||||
|
||||
export type OpportunityUncheckedUpdateWithoutCompanyInput = {
|
||||
@@ -1741,6 +2054,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1754,8 +2068,10 @@ 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
|
||||
}
|
||||
|
||||
export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
|
||||
@@ -1790,6 +2106,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1803,11 +2120,41 @@ 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
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Count Type OpportunityCountOutputType
|
||||
*/
|
||||
|
||||
export type OpportunityCountOutputType = {
|
||||
generatedQuotes: number
|
||||
}
|
||||
|
||||
export type OpportunityCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
generatedQuotes?: boolean | OpportunityCountOutputTypeCountGeneratedQuotesArgs
|
||||
}
|
||||
|
||||
/**
|
||||
* OpportunityCountOutputType without action
|
||||
*/
|
||||
export type OpportunityCountOutputTypeDefaultArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
/**
|
||||
* Select specific fields to fetch from the OpportunityCountOutputType
|
||||
*/
|
||||
select?: Prisma.OpportunityCountOutputTypeSelect<ExtArgs> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* OpportunityCountOutputType without action
|
||||
*/
|
||||
export type OpportunityCountOutputTypeCountGeneratedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
where?: Prisma.GeneratedQuotesWhereInput
|
||||
}
|
||||
|
||||
|
||||
export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||
id?: boolean
|
||||
@@ -1841,6 +2188,7 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
||||
siteName?: boolean
|
||||
customerPO?: boolean
|
||||
totalSalesTax?: boolean
|
||||
probability?: boolean
|
||||
locationName?: boolean
|
||||
locationCwId?: boolean
|
||||
departmentName?: boolean
|
||||
@@ -1855,9 +2203,12 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
cwDateEntered?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
|
||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.OpportunityCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["opportunity"]>
|
||||
|
||||
export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||
@@ -1892,6 +2243,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
siteName?: boolean
|
||||
customerPO?: boolean
|
||||
totalSalesTax?: boolean
|
||||
probability?: boolean
|
||||
locationName?: boolean
|
||||
locationCwId?: boolean
|
||||
departmentName?: boolean
|
||||
@@ -1906,6 +2258,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
cwDateEntered?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||
@@ -1943,6 +2296,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
siteName?: boolean
|
||||
customerPO?: boolean
|
||||
totalSalesTax?: boolean
|
||||
probability?: boolean
|
||||
locationName?: boolean
|
||||
locationCwId?: boolean
|
||||
departmentName?: boolean
|
||||
@@ -1957,6 +2311,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
cwDateEntered?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||
@@ -1994,6 +2349,7 @@ export type OpportunitySelectScalar = {
|
||||
siteName?: boolean
|
||||
customerPO?: boolean
|
||||
totalSalesTax?: boolean
|
||||
probability?: boolean
|
||||
locationName?: boolean
|
||||
locationCwId?: boolean
|
||||
departmentName?: boolean
|
||||
@@ -2008,13 +2364,16 @@ export type OpportunitySelectScalar = {
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
cwDateEntered?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
}
|
||||
|
||||
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "productSequence" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
|
||||
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "probability" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "productSequence" | "cwLastUpdated" | "cwDateEntered" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
|
||||
export type OpportunityInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
|
||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.OpportunityCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}
|
||||
export type OpportunityIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||
@@ -2026,6 +2385,7 @@ export type OpportunityIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.
|
||||
export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
name: "Opportunity"
|
||||
objects: {
|
||||
generatedQuotes: Prisma.$GeneratedQuotesPayload<ExtArgs>[]
|
||||
company: Prisma.$CompanyPayload<ExtArgs> | null
|
||||
}
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
@@ -2060,6 +2420,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
||||
siteName: string | null
|
||||
customerPO: string | null
|
||||
totalSalesTax: number
|
||||
probability: number
|
||||
locationName: string | null
|
||||
locationCwId: number | null
|
||||
departmentName: string | null
|
||||
@@ -2074,6 +2435,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
||||
companyId: string | null
|
||||
productSequence: number[]
|
||||
cwLastUpdated: Date | null
|
||||
cwDateEntered: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}, ExtArgs["result"]["opportunity"]>
|
||||
@@ -2470,6 +2832,7 @@ readonly fields: OpportunityFieldRefs;
|
||||
*/
|
||||
export interface Prisma__OpportunityClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
|
||||
readonly [Symbol.toStringTag]: "PrismaPromise"
|
||||
generatedQuotes<T extends Prisma.Opportunity$generatedQuotesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Opportunity$generatedQuotesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$GeneratedQuotesPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
company<T extends Prisma.Opportunity$companyArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Opportunity$companyArgs<ExtArgs>>): Prisma.Prisma__CompanyClient<runtime.Types.Result.GetResult<Prisma.$CompanyPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
|
||||
/**
|
||||
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||
@@ -2531,6 +2894,7 @@ export interface OpportunityFieldRefs {
|
||||
readonly siteName: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
readonly customerPO: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
readonly totalSalesTax: Prisma.FieldRef<"Opportunity", 'Float'>
|
||||
readonly probability: Prisma.FieldRef<"Opportunity", 'Float'>
|
||||
readonly locationName: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
readonly locationCwId: Prisma.FieldRef<"Opportunity", 'Int'>
|
||||
readonly departmentName: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
@@ -2545,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'>
|
||||
}
|
||||
@@ -2942,6 +3307,30 @@ export type OpportunityDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.I
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Opportunity.generatedQuotes
|
||||
*/
|
||||
export type Opportunity$generatedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
/**
|
||||
* Select specific fields to fetch from the GeneratedQuotes
|
||||
*/
|
||||
select?: Prisma.GeneratedQuotesSelect<ExtArgs> | null
|
||||
/**
|
||||
* Omit specific fields from the GeneratedQuotes
|
||||
*/
|
||||
omit?: Prisma.GeneratedQuotesOmit<ExtArgs> | null
|
||||
/**
|
||||
* Choose, which related nodes to fetch as well
|
||||
*/
|
||||
include?: Prisma.GeneratedQuotesInclude<ExtArgs> | null
|
||||
where?: Prisma.GeneratedQuotesWhereInput
|
||||
orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[]
|
||||
cursor?: Prisma.GeneratedQuotesWhereUniqueInput
|
||||
take?: number
|
||||
skip?: number
|
||||
distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Opportunity.company
|
||||
*/
|
||||
|
||||
@@ -240,6 +240,7 @@ export type UserWhereInput = {
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
roles?: Prisma.RoleListRelationFilter
|
||||
sessions?: Prisma.SessionListRelationFilter
|
||||
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
||||
}
|
||||
|
||||
export type UserOrderByWithRelationInput = {
|
||||
@@ -257,6 +258,7 @@ export type UserOrderByWithRelationInput = {
|
||||
updatedAt?: Prisma.SortOrder
|
||||
roles?: Prisma.RoleOrderByRelationAggregateInput
|
||||
sessions?: Prisma.SessionOrderByRelationAggregateInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
|
||||
}
|
||||
|
||||
export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||
@@ -277,6 +279,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
roles?: Prisma.RoleListRelationFilter
|
||||
sessions?: Prisma.SessionListRelationFilter
|
||||
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
||||
}, "id" | "login" | "email" | "userId">
|
||||
|
||||
export type UserOrderByWithAggregationInput = {
|
||||
@@ -330,6 +333,7 @@ export type UserCreateInput = {
|
||||
updatedAt?: Date | string
|
||||
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
|
||||
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
|
||||
}
|
||||
|
||||
export type UserUncheckedCreateInput = {
|
||||
@@ -347,6 +351,7 @@ export type UserUncheckedCreateInput = {
|
||||
updatedAt?: Date | string
|
||||
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
|
||||
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
|
||||
}
|
||||
|
||||
export type UserUpdateInput = {
|
||||
@@ -364,6 +369,7 @@ export type UserUpdateInput = {
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
|
||||
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateInput = {
|
||||
@@ -381,6 +387,7 @@ export type UserUncheckedUpdateInput = {
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
|
||||
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
|
||||
}
|
||||
|
||||
export type UserCreateManyInput = {
|
||||
@@ -488,6 +495,11 @@ export type UserOrderByRelationAggregateInput = {
|
||||
_count?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type UserNullableScalarRelationFilter = {
|
||||
is?: Prisma.UserWhereInput | null
|
||||
isNot?: Prisma.UserWhereInput | null
|
||||
}
|
||||
|
||||
export type UserCreateNestedOneWithoutSessionsInput = {
|
||||
create?: Prisma.XOR<Prisma.UserCreateWithoutSessionsInput, Prisma.UserUncheckedCreateWithoutSessionsInput>
|
||||
connectOrCreate?: Prisma.UserCreateOrConnectWithoutSessionsInput
|
||||
@@ -544,6 +556,22 @@ export type UserUncheckedUpdateManyWithoutRolesNestedInput = {
|
||||
deleteMany?: Prisma.UserScalarWhereInput | Prisma.UserScalarWhereInput[]
|
||||
}
|
||||
|
||||
export type UserCreateNestedOneWithoutGeneratedQuotesInput = {
|
||||
create?: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
connectOrCreate?: Prisma.UserCreateOrConnectWithoutGeneratedQuotesInput
|
||||
connect?: Prisma.UserWhereUniqueInput
|
||||
}
|
||||
|
||||
export type UserUpdateOneWithoutGeneratedQuotesNestedInput = {
|
||||
create?: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
connectOrCreate?: Prisma.UserCreateOrConnectWithoutGeneratedQuotesInput
|
||||
upsert?: Prisma.UserUpsertWithoutGeneratedQuotesInput
|
||||
disconnect?: Prisma.UserWhereInput | boolean
|
||||
delete?: Prisma.UserWhereInput | boolean
|
||||
connect?: Prisma.UserWhereUniqueInput
|
||||
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutGeneratedQuotesInput, Prisma.UserUpdateWithoutGeneratedQuotesInput>, Prisma.UserUncheckedUpdateWithoutGeneratedQuotesInput>
|
||||
}
|
||||
|
||||
export type UserCreateWithoutSessionsInput = {
|
||||
id?: string
|
||||
permissions?: string | null
|
||||
@@ -558,6 +586,7 @@ export type UserCreateWithoutSessionsInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
|
||||
}
|
||||
|
||||
export type UserUncheckedCreateWithoutSessionsInput = {
|
||||
@@ -574,6 +603,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
|
||||
}
|
||||
|
||||
export type UserCreateOrConnectWithoutSessionsInput = {
|
||||
@@ -606,6 +636,7 @@ export type UserUpdateWithoutSessionsInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateWithoutSessionsInput = {
|
||||
@@ -622,6 +653,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
|
||||
}
|
||||
|
||||
export type UserCreateWithoutRolesInput = {
|
||||
@@ -638,6 +670,7 @@ export type UserCreateWithoutRolesInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
|
||||
}
|
||||
|
||||
export type UserUncheckedCreateWithoutRolesInput = {
|
||||
@@ -654,6 +687,7 @@ export type UserUncheckedCreateWithoutRolesInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
|
||||
}
|
||||
|
||||
export type UserCreateOrConnectWithoutRolesInput = {
|
||||
@@ -695,6 +729,90 @@ export type UserScalarWhereInput = {
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
}
|
||||
|
||||
export type UserCreateWithoutGeneratedQuotesInput = {
|
||||
id?: string
|
||||
permissions?: string | null
|
||||
login: string
|
||||
name?: string | null
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
|
||||
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
|
||||
}
|
||||
|
||||
export type UserUncheckedCreateWithoutGeneratedQuotesInput = {
|
||||
id?: string
|
||||
permissions?: string | null
|
||||
login: string
|
||||
name?: string | null
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
|
||||
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
|
||||
}
|
||||
|
||||
export type UserCreateOrConnectWithoutGeneratedQuotesInput = {
|
||||
where: Prisma.UserWhereUniqueInput
|
||||
create: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
}
|
||||
|
||||
export type UserUpsertWithoutGeneratedQuotesInput = {
|
||||
update: Prisma.XOR<Prisma.UserUpdateWithoutGeneratedQuotesInput, Prisma.UserUncheckedUpdateWithoutGeneratedQuotesInput>
|
||||
create: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
where?: Prisma.UserWhereInput
|
||||
}
|
||||
|
||||
export type UserUpdateToOneWithWhereWithoutGeneratedQuotesInput = {
|
||||
where?: Prisma.UserWhereInput
|
||||
data: Prisma.XOR<Prisma.UserUpdateWithoutGeneratedQuotesInput, Prisma.UserUncheckedUpdateWithoutGeneratedQuotesInput>
|
||||
}
|
||||
|
||||
export type UserUpdateWithoutGeneratedQuotesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
login?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
name?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
|
||||
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateWithoutGeneratedQuotesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
login?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
name?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
|
||||
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
|
||||
}
|
||||
|
||||
export type UserUpdateWithoutRolesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -709,6 +827,7 @@ export type UserUpdateWithoutRolesInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateWithoutRolesInput = {
|
||||
@@ -725,6 +844,7 @@ export type UserUncheckedUpdateWithoutRolesInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateManyWithoutRolesInput = {
|
||||
@@ -750,11 +870,13 @@ export type UserUncheckedUpdateManyWithoutRolesInput = {
|
||||
export type UserCountOutputType = {
|
||||
roles: number
|
||||
sessions: number
|
||||
generatedQuotes: number
|
||||
}
|
||||
|
||||
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
roles?: boolean | UserCountOutputTypeCountRolesArgs
|
||||
sessions?: boolean | UserCountOutputTypeCountSessionsArgs
|
||||
generatedQuotes?: boolean | UserCountOutputTypeCountGeneratedQuotesArgs
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -781,6 +903,13 @@ export type UserCountOutputTypeCountSessionsArgs<ExtArgs extends runtime.Types.E
|
||||
where?: Prisma.SessionWhereInput
|
||||
}
|
||||
|
||||
/**
|
||||
* UserCountOutputType without action
|
||||
*/
|
||||
export type UserCountOutputTypeCountGeneratedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
where?: Prisma.GeneratedQuotesWhereInput
|
||||
}
|
||||
|
||||
|
||||
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||
id?: boolean
|
||||
@@ -797,6 +926,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
||||
updatedAt?: boolean
|
||||
roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
|
||||
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
|
||||
generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["user"]>
|
||||
|
||||
@@ -849,6 +979,7 @@ export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = run
|
||||
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
|
||||
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
|
||||
generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}
|
||||
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
|
||||
@@ -859,6 +990,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||
objects: {
|
||||
roles: Prisma.$RolePayload<ExtArgs>[]
|
||||
sessions: Prisma.$SessionPayload<ExtArgs>[]
|
||||
generatedQuotes: Prisma.$GeneratedQuotesPayload<ExtArgs>[]
|
||||
}
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
id: string
|
||||
@@ -1269,6 +1401,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
|
||||
readonly [Symbol.toStringTag]: "PrismaPromise"
|
||||
roles<T extends Prisma.User$rolesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$rolesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$RolePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
sessions<T extends Prisma.User$sessionsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$sessionsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SessionPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
generatedQuotes<T extends Prisma.User$generatedQuotesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$generatedQuotesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$GeneratedQuotesPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
/**
|
||||
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
||||
@@ -1745,6 +1878,30 @@ export type User$sessionsArgs<ExtArgs extends runtime.Types.Extensions.InternalA
|
||||
distinct?: Prisma.SessionScalarFieldEnum | Prisma.SessionScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* User.generatedQuotes
|
||||
*/
|
||||
export type User$generatedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
/**
|
||||
* Select specific fields to fetch from the GeneratedQuotes
|
||||
*/
|
||||
select?: Prisma.GeneratedQuotesSelect<ExtArgs> | null
|
||||
/**
|
||||
* Omit specific fields from the GeneratedQuotes
|
||||
*/
|
||||
omit?: Prisma.GeneratedQuotesOmit<ExtArgs> | null
|
||||
/**
|
||||
* Choose, which related nodes to fetch as well
|
||||
*/
|
||||
include?: Prisma.GeneratedQuotesInclude<ExtArgs> | null
|
||||
where?: Prisma.GeneratedQuotesWhereInput
|
||||
orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[]
|
||||
cursor?: Prisma.GeneratedQuotesWhereUniqueInput
|
||||
take?: number
|
||||
skip?: number
|
||||
distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* User without action
|
||||
*/
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"keypair": "^1.0.4",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfmake": "^0.3.5",
|
||||
"prisma": "^7.3.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"zod": "^4.3.6",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "GeneratedQuotes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"quoteFile" BYTEA NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GeneratedQuotes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `opportunityId` to the `GeneratedQuotes` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `quoteFileName` to the `GeneratedQuotes` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `quoteRegenData` to the `GeneratedQuotes` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "GeneratedQuotes" ADD COLUMN "createdById" TEXT,
|
||||
ADD COLUMN "opportunityId" TEXT NOT NULL,
|
||||
ADD COLUMN "quoteFileName" TEXT NOT NULL,
|
||||
ADD COLUMN "quoteRegenData" JSONB NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GeneratedQuotes" ADD CONSTRAINT "GeneratedQuotes_opportunityId_fkey" FOREIGN KEY ("opportunityId") REFERENCES "Opportunity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GeneratedQuotes" ADD CONSTRAINT "GeneratedQuotes_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: Opportunity
|
||||
ALTER TABLE "Opportunity" ADD COLUMN "probability" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- AlterTable: GeneratedQuotes — add columns missing from prior db push
|
||||
ALTER TABLE "GeneratedQuotes" ADD COLUMN "quoteRegenParams" JSONB NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE "GeneratedQuotes" ADD COLUMN "quoteRegenHash" TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE "GeneratedQuotes" ADD COLUMN "downloads" JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
-- AlterTable: GeneratedQuotes — set default on existing quoteRegenData column
|
||||
ALTER TABLE "GeneratedQuotes" ALTER COLUMN "quoteRegenData" SET DEFAULT '{}';
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GeneratedQuotes_quoteRegenHash_key" ON "GeneratedQuotes"("quoteRegenHash");
|
||||
@@ -0,0 +1,22 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "CwMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"cwMemberId" INTEGER NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"officeEmail" TEXT,
|
||||
"inactiveFlag" BOOLEAN NOT NULL DEFAULT false,
|
||||
"apiKey" TEXT,
|
||||
"cwLastUpdated" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CwMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CwMember_cwMemberId_key" ON "CwMember"("cwMemberId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CwMember_identifier_key" ON "CwMember"("identifier");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Opportunity" ADD COLUMN "cwDateEntered" TIMESTAMP(3);
|
||||
+46
-2
@@ -41,8 +41,9 @@ model User {
|
||||
|
||||
sessions Session[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
generatedQuotes GeneratedQuotes[]
|
||||
}
|
||||
|
||||
model Role {
|
||||
@@ -130,6 +131,8 @@ model Opportunity {
|
||||
name String
|
||||
notes String?
|
||||
|
||||
generatedQuotes GeneratedQuotes[]
|
||||
|
||||
// Stage / status / priority / type / rating stored as JSON references
|
||||
// so we don't need separate lookup tables for CW enums
|
||||
typeName String?
|
||||
@@ -165,6 +168,7 @@ model Opportunity {
|
||||
|
||||
// Financials
|
||||
totalSalesTax Float @default(0)
|
||||
probability Float @default(0)
|
||||
|
||||
// Location / department
|
||||
locationName String?
|
||||
@@ -190,6 +194,7 @@ model Opportunity {
|
||||
productSequence Int[] @default([])
|
||||
|
||||
cwLastUpdated DateTime?
|
||||
cwDateEntered DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -244,3 +249,42 @@ model Credential {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model GeneratedQuotes {
|
||||
id String @id @default(uuid())
|
||||
|
||||
quoteRegenData Json @default("{}") // Store any additional data needed for quote regeneration, such as product details, pricing, etc.
|
||||
quoteRegenParams Json @default("{}") // Store parameters used for quote regeneration, such as template ID, formatting options, etc.
|
||||
quoteRegenHash String @unique @default("")
|
||||
|
||||
downloads Json @default("[]") // Array of download records with timestamp and user info
|
||||
|
||||
quoteFile Bytes
|
||||
quoteFileName String
|
||||
|
||||
opportunityId String
|
||||
opportunity Opportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdById String?
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model CwMember {
|
||||
id String @id @default(cuid())
|
||||
|
||||
cwMemberId Int @unique
|
||||
identifier String @unique
|
||||
firstName String
|
||||
lastName String
|
||||
officeEmail String?
|
||||
inactiveFlag Boolean @default(false)
|
||||
|
||||
apiKey String?
|
||||
|
||||
cwLastUpdated DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { companies } from "../../../managers/companies";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* /v1/company/companies/[id]/sites */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/companies/:identifier/sites"],
|
||||
|
||||
async (c) => {
|
||||
const company = await companies.fetch(c.req.param("identifier"));
|
||||
const sites = await company.fetchSites();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company Sites Fetched Successfully!",
|
||||
sites,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["company.fetch", "company.fetch.sites"],
|
||||
}),
|
||||
);
|
||||
@@ -1,7 +1,8 @@
|
||||
import { default as fetchAll } from "./fetchAll";
|
||||
import { default as fetch } from "./[id]/fetch";
|
||||
import { default as configurations } from "./[id]/configurations";
|
||||
import { default as sites } from "./[id]/sites";
|
||||
import { default as unifiSites } from "./[id]/unifiSites";
|
||||
import { default as count } from "./count";
|
||||
|
||||
export { configurations, count, fetch, fetchAll, unifiSites };
|
||||
export { configurations, count, fetch, fetchAll, sites, unifiSites };
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { getMemberCache } from "../../modules/cw-utils/members/memberCache";
|
||||
|
||||
/* GET /v1/cw/members */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/members"],
|
||||
async (c) => {
|
||||
const cache = await getMemberCache();
|
||||
|
||||
const activeOnly = c.req.query("active") !== "false";
|
||||
|
||||
const members = cache
|
||||
.filter((m) => (activeOnly ? !m.inactiveFlag : true))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
identifier: m.identifier,
|
||||
firstName: m.firstName,
|
||||
lastName: m.lastName,
|
||||
name: `${m.firstName} ${m.lastName}`.trim(),
|
||||
officeEmail: m.officeEmail,
|
||||
inactive: m.inactiveFlag,
|
||||
}));
|
||||
|
||||
const sorted = members.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"CW members fetched successfully!",
|
||||
sorted,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware(),
|
||||
);
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { default as callback } from "./callback";
|
||||
import { default as fetchMembers } from "./fetchMembers";
|
||||
|
||||
export { callback };
|
||||
export { callback, fetchMembers };
|
||||
|
||||
@@ -18,11 +18,12 @@ import {
|
||||
fetchAndCacheProducts,
|
||||
fetchAndCacheSite,
|
||||
} from "../../../modules/cache/opportunityCache";
|
||||
import { generatedQuotes } from "../../../managers/generatedQuotes";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products */
|
||||
/* GET /v1/sales/opportunities/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") ?? "";
|
||||
@@ -140,6 +141,11 @@ export default createRoute(
|
||||
.fetchProducts()
|
||||
.then((products) => products.map((p) => p.toJson()));
|
||||
}
|
||||
if (includes.has("quotes")) {
|
||||
subResourcePromises.quotes = generatedQuotes
|
||||
.fetchByOpportunity(item.id)
|
||||
.then((quotes) => quotes.map((q) => q.toJson()));
|
||||
}
|
||||
|
||||
const keys = Object.keys(subResourcePromises);
|
||||
const results = await Promise.all(keys.map((k) => subResourcePromises[k]));
|
||||
|
||||
+52
-14
@@ -1,33 +1,71 @@
|
||||
import { default as fetchAll } from "./fetchAll";
|
||||
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 "./count";
|
||||
import { default as fetch } from "./[id]/fetch";
|
||||
import { default as refresh } from "./[id]/refresh";
|
||||
import { default as products } from "./[id]/products";
|
||||
import { default as addProduct } from "./[id]/addProduct";
|
||||
import { default as addSpecialOrderProduct } from "./[id]/addSpecialOrderProduct";
|
||||
import { default as resequenceProducts } from "./[id]/resequenceProducts";
|
||||
import { default as notes } from "./[id]/notes";
|
||||
import { default as fetchNote } from "./[id]/fetchNote";
|
||||
import { default as createNote } from "./[id]/createNote";
|
||||
import { default as updateNote } from "./[id]/updateNote";
|
||||
import { default as deleteNote } from "./[id]/deleteNote";
|
||||
import { default as contacts } from "./[id]/contacts";
|
||||
import { default as count } from "./opportunities/count";
|
||||
import { default as fetch } from "./opportunities/[id]/fetch";
|
||||
import { default as refresh } from "./opportunities/[id]/refresh";
|
||||
import { default as updateOpportunity } from "./opportunities/[id]/update";
|
||||
import { default as deleteOpportunity } from "./opportunities/[id]/delete";
|
||||
import { default as products } from "./opportunities/[id]/products/fetchAll";
|
||||
import { default as addProduct } from "./opportunities/[id]/products/add";
|
||||
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
|
||||
import { default as addLabor } from "./opportunities/[id]/products/addLabor";
|
||||
import { default as laborOptions } from "./opportunities/[id]/products/laborOptions";
|
||||
import { default as resequenceProducts } from "./opportunities/[id]/products/resequence";
|
||||
import { default as updateProduct } from "./opportunities/[id]/products/update";
|
||||
import { default as cancelProduct } from "./opportunities/[id]/products/cancel";
|
||||
import { default as deleteProduct } from "./opportunities/[id]/products/delete";
|
||||
import { default as notes } from "./opportunities/[id]/notes/fetchAll";
|
||||
import { default as fetchNote } from "./opportunities/[id]/notes/fetch";
|
||||
import { default as createNote } from "./opportunities/[id]/notes/create";
|
||||
import { default as updateNote } from "./opportunities/[id]/notes/update";
|
||||
import { default as deleteNote } from "./opportunities/[id]/notes/delete";
|
||||
import { default as contacts } from "./opportunities/[id]/contacts";
|
||||
import { default as commitQuote } from "./opportunities/[id]/quotes/commit";
|
||||
import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll";
|
||||
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
|
||||
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
|
||||
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
|
||||
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,
|
||||
products,
|
||||
resequenceProducts,
|
||||
updateProduct,
|
||||
cancelProduct,
|
||||
deleteProduct,
|
||||
notes,
|
||||
fetchNote,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
contacts,
|
||||
commitQuote,
|
||||
fetchQuotes,
|
||||
previewQuote,
|
||||
downloadQuote,
|
||||
fetchDownloads,
|
||||
refresh,
|
||||
updateOpportunity,
|
||||
workflowDispatch,
|
||||
workflowStatus,
|
||||
workflowHistory,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { authMiddleware } from "../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/contacts */
|
||||
/* 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);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createRoute } from "../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../middleware/authorization";
|
||||
import GenericError from "../../../../Errors/GenericError";
|
||||
|
||||
/* DELETE /v1/sales/opportunities/opportunity/:identifier */
|
||||
export default createRoute(
|
||||
"delete",
|
||||
["/opportunities/opportunity/:identifier"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
|
||||
try {
|
||||
await opportunities.deleteItem(identifier);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity deleted successfully!",
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
const isAxios =
|
||||
err != null && typeof err === "object" && "isAxiosError" in err;
|
||||
|
||||
if (isAxios) {
|
||||
const axiosErr = err as any;
|
||||
const cwStatus: number = axiosErr.response?.status ?? 502;
|
||||
const cwMessage: string =
|
||||
axiosErr.response?.data?.message ??
|
||||
"Failed to delete the opportunity in ConnectWise";
|
||||
|
||||
return c.json(
|
||||
{
|
||||
status: cwStatus,
|
||||
message: cwMessage,
|
||||
error: "ConnectWiseDeleteError",
|
||||
successful: false,
|
||||
meta: { timestamp: Date.now() },
|
||||
},
|
||||
cwStatus as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.delete"] }),
|
||||
);
|
||||
@@ -0,0 +1,187 @@
|
||||
import { createRoute } from "../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../../modules/permission-utils/processObjectPermissions";
|
||||
import GenericError from "../../../../Errors/GenericError";
|
||||
import { prisma } from "../../../../constants";
|
||||
import { computeSubResourceCacheTTL } from "../../../../modules/algorithms/computeSubResourceCacheTTL";
|
||||
import { computeProductsCacheTTL } from "../../../../modules/algorithms/computeProductsCacheTTL";
|
||||
import {
|
||||
getCachedSite,
|
||||
getCachedNotes,
|
||||
getCachedContacts,
|
||||
getCachedProducts,
|
||||
fetchAndCacheNotes,
|
||||
fetchAndCacheContacts,
|
||||
fetchAndCacheProducts,
|
||||
fetchAndCacheSite,
|
||||
} from "../../../../modules/cache/opportunityCache";
|
||||
import { generatedQuotes } from "../../../../managers/generatedQuotes";
|
||||
|
||||
/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/opportunity/:identifier"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const includeParam = c.req.query("include") ?? "";
|
||||
const includes = new Set(
|
||||
includeParam
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
// ── Quick DB lookup (≈3ms) to get cwOpportunityId for pre-warming ──
|
||||
const isNumeric = /^\d+$/.test(identifier);
|
||||
const dbRecord = await prisma.opportunity.findFirst({
|
||||
where: isNumeric
|
||||
? { cwOpportunityId: Number(identifier) }
|
||||
: { id: identifier },
|
||||
select: {
|
||||
cwOpportunityId: true,
|
||||
companyCwId: true,
|
||||
siteCwId: true,
|
||||
closedFlag: true,
|
||||
closedDate: true,
|
||||
expectedCloseDate: true,
|
||||
cwLastUpdated: true,
|
||||
statusCwId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbRecord) {
|
||||
throw new GenericError({
|
||||
message: "Opportunity not found",
|
||||
name: "OpportunityNotFound",
|
||||
cause: `No opportunity exists with identifier '${identifier}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Compute TTLs from DB state
|
||||
const subTtl = computeSubResourceCacheTTL({
|
||||
closedFlag: dbRecord.closedFlag,
|
||||
closedDate: dbRecord.closedDate,
|
||||
expectedCloseDate: dbRecord.expectedCloseDate,
|
||||
lastUpdated: dbRecord.cwLastUpdated,
|
||||
});
|
||||
const prodTtl = computeProductsCacheTTL({
|
||||
closedFlag: dbRecord.closedFlag,
|
||||
closedDate: dbRecord.closedDate,
|
||||
expectedCloseDate: dbRecord.expectedCloseDate,
|
||||
lastUpdated: dbRecord.cwLastUpdated,
|
||||
statusCwId: dbRecord.statusCwId,
|
||||
});
|
||||
|
||||
// ── Pre-warm sub-resources only on cache miss ───────────────────────
|
||||
// Check Redis first — if the background refresh has kept the keys warm,
|
||||
// skip the CW calls entirely. Only fetch-and-cache on a miss.
|
||||
const cwOppId = dbRecord.cwOpportunityId;
|
||||
const _ignoreErrors = (p: Promise<any>) => p.catch(() => {});
|
||||
|
||||
const prewarmPromises: Promise<any>[] = [];
|
||||
if (dbRecord.companyCwId && dbRecord.siteCwId) {
|
||||
const compId = dbRecord.companyCwId,
|
||||
siteId = dbRecord.siteCwId;
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedSite(compId, siteId).then(
|
||||
(c) => c ?? fetchAndCacheSite(compId, siteId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (includes.has("notes") && subTtl)
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedNotes(cwOppId).then(
|
||||
(c) => c ?? fetchAndCacheNotes(cwOppId, subTtl),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (includes.has("contacts") && subTtl)
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedContacts(cwOppId).then(
|
||||
(c) => c ?? fetchAndCacheContacts(cwOppId, subTtl),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (includes.has("products") && prodTtl)
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedProducts(cwOppId).then(
|
||||
(c) => c ?? fetchAndCacheProducts(cwOppId, prodTtl),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// fetchItem runs its own CW calls (opp, activities, company) —
|
||||
// these execute concurrently with the sub-resource pre-warming above.
|
||||
const [item] = await Promise.all([
|
||||
opportunities.fetchItem(identifier),
|
||||
...prewarmPromises,
|
||||
]);
|
||||
|
||||
// Sub-resources now hit warm Redis cache (near-instant)
|
||||
const subResourcePromises: Record<string, Promise<any>> = {
|
||||
_site: item.fetchSite(),
|
||||
};
|
||||
if (includes.has("notes")) {
|
||||
subResourcePromises.notes = item.fetchNotes();
|
||||
}
|
||||
if (includes.has("contacts")) {
|
||||
subResourcePromises.contacts = item.fetchContacts();
|
||||
}
|
||||
if (includes.has("products")) {
|
||||
subResourcePromises.products = item
|
||||
.fetchProducts()
|
||||
.then((products) => products.map((p) => p.toJson()));
|
||||
}
|
||||
if (includes.has("quotes")) {
|
||||
const includeRegenData = c.req.query("includeRegenData") === "true";
|
||||
const includeRegenParams = c.req.query("includeRegenParams") === "true";
|
||||
subResourcePromises.quotes = generatedQuotes
|
||||
.fetchByOpportunity(item.id)
|
||||
.then((quotes) =>
|
||||
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams })),
|
||||
);
|
||||
}
|
||||
|
||||
const keys = Object.keys(subResourcePromises);
|
||||
const results = await Promise.all(keys.map((k) => subResourcePromises[k]));
|
||||
|
||||
// Apply toJson after site is hydrated (side-effect from fetchSite)
|
||||
const gatedData = await processObjectValuePerms(
|
||||
item.toJson(),
|
||||
"obj.opportunity",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const originalOpportunityNoteText = (gatedData as any).notes;
|
||||
|
||||
// Attach sub-resources (skip the internal _site key)
|
||||
keys.forEach((k, i) => {
|
||||
if (k !== "_site") {
|
||||
(gatedData as any)[k] = results[i];
|
||||
}
|
||||
});
|
||||
|
||||
if (includes.has("notes")) {
|
||||
(gatedData as any).opportunityNoteText =
|
||||
typeof originalOpportunityNoteText === "string"
|
||||
? originalOpportunityNoteText
|
||||
: null;
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity fetched successfully!",
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -1,15 +1,15 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { resolveMember } from "../../../modules/cw-utils/members/memberCache";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/notes */
|
||||
/* 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();
|
||||
@@ -1,14 +1,14 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
|
||||
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
/* 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"));
|
||||
@@ -1,14 +1,14 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
/* 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"));
|
||||
@@ -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/opportunity/:identifier/notes */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/opportunity/:identifier/notes"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
|
||||
const data = await item.fetchNotes();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity notes fetched successfully!",
|
||||
data,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -1,16 +1,16 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { resolveMember } from "../../../modules/cw-utils/members/memberCache";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
||||
import { z } from "zod";
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
/* 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"));
|
||||
+42
-8
@@ -1,9 +1,9 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../../../modules/permission-utils/processObjectPermissions";
|
||||
import { z } from "zod";
|
||||
|
||||
const productItemSchema = z
|
||||
@@ -11,6 +11,7 @@ const productItemSchema = z
|
||||
catalogItem: z.object({ id: z.number().int().positive() }).optional(),
|
||||
forecastDescription: z.string().optional(),
|
||||
productDescription: z.string().optional(),
|
||||
customerDescription: z.string().nullable().optional(),
|
||||
quantity: z.number().positive().optional(),
|
||||
status: z.object({ id: z.number().int().positive() }).optional(),
|
||||
productClass: z.string().optional(),
|
||||
@@ -33,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();
|
||||
@@ -54,7 +55,40 @@ export default createRoute(
|
||||
);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const created = await item.addProducts(gatedItems);
|
||||
|
||||
// Strip customerDescription from forecast payloads — CW only accepts
|
||||
// it on procurement products, not forecast items.
|
||||
const customerDescriptions = gatedItems.map(
|
||||
(g: any) => g.customerDescription,
|
||||
);
|
||||
const forecastPayloads = gatedItems.map(
|
||||
({ customerDescription, ...rest }: any) => rest,
|
||||
);
|
||||
|
||||
const created = await item.addProducts(forecastPayloads);
|
||||
|
||||
// If any items included customerDescription, patch the linked
|
||||
// procurement products after creation. This is best-effort since
|
||||
// newly created forecast items may not have a linked procurement
|
||||
// product yet.
|
||||
const procurementUpdates = created
|
||||
.map((product, idx) => ({
|
||||
product,
|
||||
customerDescription: customerDescriptions[idx],
|
||||
}))
|
||||
.filter((entry) => entry.customerDescription != null);
|
||||
|
||||
if (procurementUpdates.length > 0) {
|
||||
await Promise.all(
|
||||
procurementUpdates.map(({ product, customerDescription }) =>
|
||||
item
|
||||
.updateProcurementProductByForecastItem(product.cwForecastId, {
|
||||
customerDescription,
|
||||
})
|
||||
.catch(() => null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const isBatch = Array.isArray(body);
|
||||
const response = apiResponse.created(
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { procurement } from "../../../../../managers/procurement";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
const LABOR_DEFAULT_RATE = {
|
||||
corporate: 100,
|
||||
residential: 85,
|
||||
} as const;
|
||||
|
||||
const roundMoney = (value: number) => Math.round(value * 100) / 100;
|
||||
|
||||
const addLaborSchema = z
|
||||
.object({
|
||||
laborStyle: z.enum(["field", "tech"]),
|
||||
customerType: z.enum(["corporate", "residential"]).optional(),
|
||||
hours: z.number().positive().optional(),
|
||||
rate: z.number().min(0).optional(),
|
||||
ppu: z.number().min(0).optional(),
|
||||
cpu: z.number().min(0).optional(),
|
||||
taxable: z.boolean().optional(),
|
||||
taxableFlag: z.boolean().optional(),
|
||||
description: z.string().min(1).optional(),
|
||||
customerDescription: z.string().min(1).optional(),
|
||||
procurementNotes: z.string().optional(),
|
||||
productNarrative: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/* POST /v1/sales/opportunities/opportunity/:identifier/products/labor */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/opportunity/:identifier/products/labor"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
const input = addLaborSchema.parse(body);
|
||||
|
||||
const laborCatalog = await procurement.fetchLaborCatalogItems();
|
||||
const selectedCatalog =
|
||||
input.laborStyle === "tech" ? laborCatalog.tech : laborCatalog.field;
|
||||
|
||||
const customerType = input.customerType ?? "corporate";
|
||||
const defaultRate = LABOR_DEFAULT_RATE[customerType];
|
||||
const quantity = input.hours ?? 1;
|
||||
const ppu = input.ppu ?? input.rate ?? defaultRate;
|
||||
const cpu = input.cpu ?? roundMoney(ppu * 0.5);
|
||||
const taxableFlag =
|
||||
input.taxable ?? input.taxableFlag ?? selectedCatalog.salesTaxable;
|
||||
|
||||
const makeCustomField = (
|
||||
caption: string,
|
||||
value: string,
|
||||
fieldId: number,
|
||||
) => ({
|
||||
id: fieldId,
|
||||
caption,
|
||||
type: "Text",
|
||||
entryMethod: "EntryField",
|
||||
value,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
...(input.procurementNotes || input.productNarrative
|
||||
? {
|
||||
customFields: [
|
||||
...(input.procurementNotes
|
||||
? [
|
||||
makeCustomField(
|
||||
"Procurement Notes",
|
||||
input.procurementNotes,
|
||||
29,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(input.productNarrative
|
||||
? [
|
||||
makeCustomField(
|
||||
"Product Narrative",
|
||||
input.productNarrative,
|
||||
46,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
catalogItem: { id: selectedCatalog.cwCatalogId },
|
||||
description:
|
||||
input.description ??
|
||||
selectedCatalog.name ??
|
||||
selectedCatalog.identifier ??
|
||||
`${input.laborStyle.toUpperCase()} Labor`,
|
||||
customerDescription: input.customerDescription,
|
||||
quantity,
|
||||
price: ppu,
|
||||
cost: cpu,
|
||||
taxableFlag,
|
||||
dropshipFlag: false,
|
||||
billableOption: "Billable",
|
||||
};
|
||||
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
const [created] = await opportunity.addProcurementProducts(payload);
|
||||
|
||||
const fields = Array.isArray(created?.customFields)
|
||||
? created.customFields
|
||||
: [];
|
||||
const procurementNotes =
|
||||
fields.find((f: any) => f?.id === 29)?.value ?? null;
|
||||
const productNarrative =
|
||||
fields.find((f: any) => f?.id === 46)?.value ?? null;
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Labor added to opportunity successfully!",
|
||||
{
|
||||
id: created?.id ?? null,
|
||||
forecastDetailId: created?.forecastDetailId ?? null,
|
||||
laborStyle: input.laborStyle,
|
||||
customerType,
|
||||
catalogItem: {
|
||||
id: selectedCatalog.cwCatalogId,
|
||||
identifier: selectedCatalog.identifier,
|
||||
name: selectedCatalog.name,
|
||||
},
|
||||
description: created?.description ?? payload.description,
|
||||
customerDescription:
|
||||
created?.customerDescription ?? input.customerDescription ?? null,
|
||||
quantity: created?.quantity ?? quantity,
|
||||
rate: ppu,
|
||||
ppu,
|
||||
cpu,
|
||||
revenue: roundMoney((created?.quantity ?? quantity) * ppu),
|
||||
cost: roundMoney((created?.quantity ?? quantity) * cpu),
|
||||
taxableFlag: created?.taxableFlag ?? taxableFlag,
|
||||
procurementNotes,
|
||||
productNarrative,
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
|
||||
);
|
||||
+7
-7
@@ -1,9 +1,9 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { procurement } from "../../../managers/procurement";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { procurement } from "../../../../../managers/procurement";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
const specialOrderItemSchema = z
|
||||
@@ -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();
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
import { z } from "zod";
|
||||
|
||||
const cancelProductSchema = z
|
||||
.object({
|
||||
quantityCancelled: z.number().int().min(0),
|
||||
cancellationReason: z.string().nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/cancel */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/opportunity/:identifier/products/:productId/cancel"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const productId = Number(c.req.param("productId"));
|
||||
const body = await c.req.json();
|
||||
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidProductId",
|
||||
message: "productId must be a positive integer",
|
||||
});
|
||||
}
|
||||
|
||||
const input = cancelProductSchema.parse(body);
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
|
||||
const products = await opportunity.fetchProducts();
|
||||
const product = products.find((item) => item.cwForecastId === productId);
|
||||
|
||||
if (!product) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${productId} not found on opportunity`,
|
||||
});
|
||||
}
|
||||
|
||||
const quantity = product.quantity ?? 0;
|
||||
if (input.quantityCancelled > quantity) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidCancelledQuantity",
|
||||
message: `quantityCancelled cannot exceed product quantity (${quantity})`,
|
||||
});
|
||||
}
|
||||
|
||||
await opportunity.setProductCancellation(productId, {
|
||||
quantityCancelled: input.quantityCancelled,
|
||||
cancellationReason: input.cancellationReason,
|
||||
});
|
||||
|
||||
const refreshedProducts = await opportunity.fetchProducts({ fresh: true });
|
||||
const updated = refreshedProducts.find(
|
||||
(item) => item.cwForecastId === productId,
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${productId} not found on opportunity`,
|
||||
});
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
input.quantityCancelled === 0
|
||||
? "Product uncancelled successfully!"
|
||||
: "Product cancellation updated successfully!",
|
||||
updated.toJson(),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
|
||||
/* DELETE /v1/sales/opportunities/opportunity/:identifier/products/:productId */
|
||||
export default createRoute(
|
||||
"delete",
|
||||
["/opportunities/opportunity/:identifier/products/:productId"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const productId = Number(c.req.param("productId"));
|
||||
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidProductId",
|
||||
message: "productId must be a positive integer",
|
||||
});
|
||||
}
|
||||
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
|
||||
// Verify the forecast item exists before attempting deletion
|
||||
const products = await opportunity.fetchProducts();
|
||||
const product = products.find((item) => item.cwForecastId === productId);
|
||||
|
||||
if (!product) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${productId} not found on opportunity`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await opportunity.deleteProduct(productId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Product deleted from opportunity successfully!",
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
const isAxios =
|
||||
err != null && typeof err === "object" && "isAxiosError" in err;
|
||||
|
||||
if (isAxios) {
|
||||
const axiosErr = err as any;
|
||||
const cwStatus: number = axiosErr.response?.status ?? 502;
|
||||
const cwMessage: string =
|
||||
axiosErr.response?.data?.message ??
|
||||
"Failed to delete the product in ConnectWise";
|
||||
|
||||
return c.json(
|
||||
{
|
||||
status: cwStatus,
|
||||
message: cwMessage,
|
||||
error: "ConnectWiseDeleteError",
|
||||
successful: false,
|
||||
meta: { timestamp: Date.now() },
|
||||
},
|
||||
cwStatus as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.delete"] }),
|
||||
);
|
||||
+6
-6
@@ -1,13 +1,13 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/products */
|
||||
/* 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);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { procurement } from "../../../../../managers/procurement";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/opportunity/:identifier/products/labor/options */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/opportunity/:identifier/products/labor/options"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
|
||||
await opportunities.fetchRecord(identifier);
|
||||
|
||||
const laborCatalog = await procurement.fetchLaborCatalogItems();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Labor product options fetched successfully!",
|
||||
{
|
||||
defaults: {
|
||||
customerType: "corporate",
|
||||
rates: {
|
||||
corporate: 100,
|
||||
residential: 85,
|
||||
},
|
||||
cpuMultiplier: 0.5,
|
||||
quantity: 1,
|
||||
},
|
||||
options: {
|
||||
field: {
|
||||
cwCatalogId: laborCatalog.field.cwCatalogId,
|
||||
identifier: laborCatalog.field.identifier,
|
||||
name: laborCatalog.field.name,
|
||||
taxableFlag: laborCatalog.field.salesTaxable,
|
||||
},
|
||||
tech: {
|
||||
cwCatalogId: laborCatalog.tech.cwCatalogId,
|
||||
identifier: laborCatalog.tech.identifier,
|
||||
name: laborCatalog.tech.name,
|
||||
taxableFlag: laborCatalog.tech.salesTaxable,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
|
||||
);
|
||||
+6
-6
@@ -1,14 +1,14 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */
|
||||
/* 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();
|
||||
@@ -0,0 +1,240 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
import { z } from "zod";
|
||||
|
||||
const PRODUCT_NARRATIVE_FIELD_ID = 46;
|
||||
const PROCUREMENT_NOTES_FIELD_ID = 29;
|
||||
|
||||
const updateProductSchema = z
|
||||
.object({
|
||||
productDescription: z.string().min(1).optional(),
|
||||
quantity: z.number().positive().optional(),
|
||||
unitPrice: z.number().min(0).optional(),
|
||||
unitCost: z.number().min(0).optional(),
|
||||
customerDescription: z.string().nullable().optional(),
|
||||
productNarrative: z.string().nullable().optional(),
|
||||
procurementNotes: z.string().nullable().optional(),
|
||||
taxableFlag: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(value) =>
|
||||
Object.values(value).some((item) => item !== undefined && item !== null),
|
||||
"At least one editable field is required",
|
||||
);
|
||||
|
||||
const upsertCustomTextField = (
|
||||
fields: Array<Record<string, unknown>>,
|
||||
fieldId: number,
|
||||
caption: string,
|
||||
value: string,
|
||||
) => {
|
||||
const next = [...fields];
|
||||
const idx = next.findIndex((f) => Number(f.id) === fieldId);
|
||||
|
||||
const field = {
|
||||
id: fieldId,
|
||||
caption,
|
||||
type: "Text",
|
||||
entryMethod: "EntryField",
|
||||
value,
|
||||
};
|
||||
|
||||
if (idx === -1) {
|
||||
next.push(field);
|
||||
return next;
|
||||
}
|
||||
|
||||
next[idx] = {
|
||||
...next[idx],
|
||||
...field,
|
||||
};
|
||||
return next;
|
||||
};
|
||||
|
||||
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/edit */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/opportunity/:identifier/products/:productId/edit"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const productId = Number(c.req.param("productId"));
|
||||
const body = await c.req.json();
|
||||
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidProductId",
|
||||
message: "productId must be a positive integer",
|
||||
});
|
||||
}
|
||||
|
||||
const input = updateProductSchema.parse(body);
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
|
||||
const forecastItems = await opportunity.fetchProducts();
|
||||
const forecastItem = forecastItems.find(
|
||||
(item) => item.cwForecastId === productId,
|
||||
);
|
||||
|
||||
if (!forecastItem) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${productId} not found on opportunity`,
|
||||
});
|
||||
}
|
||||
|
||||
const forecastJson = forecastItem.toJson();
|
||||
const effectiveQuantity = input.quantity ?? forecastJson.quantity ?? 1;
|
||||
|
||||
const forecastPatch: Record<string, unknown> = {};
|
||||
if (input.productDescription !== undefined) {
|
||||
forecastPatch.productDescription = input.productDescription;
|
||||
}
|
||||
if (input.quantity !== undefined) {
|
||||
forecastPatch.quantity = input.quantity;
|
||||
}
|
||||
if (input.unitPrice !== undefined) {
|
||||
forecastPatch.revenue = Number(
|
||||
(input.unitPrice * effectiveQuantity).toFixed(2),
|
||||
);
|
||||
}
|
||||
if (input.unitCost !== undefined) {
|
||||
forecastPatch.cost = Number(
|
||||
(input.unitCost * effectiveQuantity).toFixed(2),
|
||||
);
|
||||
}
|
||||
if (input.taxableFlag !== undefined) {
|
||||
forecastPatch.taxableFlag = input.taxableFlag;
|
||||
}
|
||||
|
||||
const existingProcurement =
|
||||
await opportunity.fetchProcurementProductByForecastItem(productId);
|
||||
|
||||
if (
|
||||
(input.productNarrative !== undefined ||
|
||||
input.procurementNotes !== undefined) &&
|
||||
!existingProcurement
|
||||
) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "ProcurementLinkRequired",
|
||||
message:
|
||||
"Product Narrative and Procurement Notes can only be updated on products linked to a procurement record",
|
||||
});
|
||||
}
|
||||
|
||||
let updatedProcurement = existingProcurement;
|
||||
if (existingProcurement) {
|
||||
const procurementPatch: Record<string, unknown> = {};
|
||||
if (input.productDescription !== undefined) {
|
||||
procurementPatch.description = input.productDescription;
|
||||
}
|
||||
if (input.quantity !== undefined) {
|
||||
procurementPatch.quantity = input.quantity;
|
||||
}
|
||||
if (input.unitPrice !== undefined) {
|
||||
procurementPatch.price = input.unitPrice;
|
||||
}
|
||||
if (input.unitCost !== undefined) {
|
||||
procurementPatch.cost = input.unitCost;
|
||||
}
|
||||
if (
|
||||
input.customerDescription !== undefined &&
|
||||
input.customerDescription !== null
|
||||
) {
|
||||
procurementPatch.customerDescription = input.customerDescription;
|
||||
}
|
||||
|
||||
const existingFields = Array.isArray(existingProcurement.customFields)
|
||||
? existingProcurement.customFields.map((field) => ({ ...field }))
|
||||
: [];
|
||||
|
||||
let updatedFields = existingFields as Array<Record<string, unknown>>;
|
||||
if (
|
||||
input.procurementNotes !== undefined &&
|
||||
input.procurementNotes !== null
|
||||
) {
|
||||
updatedFields = upsertCustomTextField(
|
||||
updatedFields,
|
||||
PROCUREMENT_NOTES_FIELD_ID,
|
||||
"Procurement Notes",
|
||||
input.procurementNotes,
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.productNarrative !== undefined &&
|
||||
input.productNarrative !== null
|
||||
) {
|
||||
updatedFields = upsertCustomTextField(
|
||||
updatedFields,
|
||||
PRODUCT_NARRATIVE_FIELD_ID,
|
||||
"Product Narrative",
|
||||
input.productNarrative,
|
||||
);
|
||||
}
|
||||
if (
|
||||
(input.procurementNotes !== undefined &&
|
||||
input.procurementNotes !== null) ||
|
||||
(input.productNarrative !== undefined &&
|
||||
input.productNarrative !== null)
|
||||
) {
|
||||
procurementPatch.customFields = updatedFields;
|
||||
}
|
||||
|
||||
if (Object.keys(procurementPatch).length > 0) {
|
||||
updatedProcurement =
|
||||
await opportunity.updateProcurementProductByForecastItem(
|
||||
productId,
|
||||
procurementPatch,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let updatedForecast = forecastJson;
|
||||
if (Object.keys(forecastPatch).length > 0) {
|
||||
const patched = await opportunity.updateProduct(productId, forecastPatch);
|
||||
updatedForecast = patched.toJson();
|
||||
}
|
||||
|
||||
const updatedFields = Array.isArray(updatedProcurement?.customFields)
|
||||
? updatedProcurement.customFields
|
||||
: [];
|
||||
const procurementNotes =
|
||||
updatedFields.find(
|
||||
(field: any) => field?.id === PROCUREMENT_NOTES_FIELD_ID,
|
||||
)?.value ?? null;
|
||||
const productNarrative =
|
||||
updatedFields.find(
|
||||
(field: any) => field?.id === PRODUCT_NARRATIVE_FIELD_ID,
|
||||
)?.value ?? null;
|
||||
|
||||
const quantity =
|
||||
updatedProcurement?.quantity ?? updatedForecast.quantity ?? null;
|
||||
const unitPrice = updatedProcurement?.price ?? null;
|
||||
const unitCost = updatedProcurement?.cost ?? null;
|
||||
|
||||
const response = apiResponse.successful("Product updated successfully!", {
|
||||
...updatedForecast,
|
||||
productDescription:
|
||||
updatedProcurement?.description ?? updatedForecast.productDescription,
|
||||
customerDescription:
|
||||
updatedProcurement?.customerDescription ??
|
||||
updatedForecast.customerDescription ??
|
||||
null,
|
||||
quantity,
|
||||
unitPrice,
|
||||
unitCost,
|
||||
procurementNotes,
|
||||
productNarrative,
|
||||
});
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
import { cwMembers } from "../../../../../managers/cwMembers";
|
||||
import {
|
||||
createWorkflowActivity,
|
||||
OptimaType,
|
||||
} from "../../../../../workflows/wf.opportunity";
|
||||
|
||||
const commitQuoteSchema = z
|
||||
.object({
|
||||
lineItemPricing: z.boolean().optional(),
|
||||
includeQuoteNarrative: z.boolean().optional(),
|
||||
includeItemNarratives: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
/* POST /v1/sales/opportunities/opportunity/:identifier/quote/commit */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/opportunity/:identifier/quote/commit"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json().catch(() => undefined);
|
||||
|
||||
const opts = commitQuoteSchema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const user = c.get("user");
|
||||
|
||||
const quote = await item.commitQuote(opts ?? {}, user);
|
||||
|
||||
// Create a workflow activity for the generated quote
|
||||
try {
|
||||
let cwMemberId: number | null = null;
|
||||
|
||||
if (user.cwIdentifier) {
|
||||
const cwMember = await cwMembers.fetch(user.cwIdentifier);
|
||||
cwMemberId = cwMember.cwMemberId;
|
||||
}
|
||||
|
||||
if (cwMemberId) {
|
||||
await createWorkflowActivity({
|
||||
name: `[Workflow] Quote generated — ${item.name}`,
|
||||
opportunityCwId: item.cwOpportunityId,
|
||||
companyCwId: item.companyCwId,
|
||||
assignToCwMemberId: cwMemberId,
|
||||
notes: `Quote "${quote.quoteFileName}" generated.`,
|
||||
optimaType: OptimaType.QuoteGenerated,
|
||||
quoteId: quote.id,
|
||||
});
|
||||
}
|
||||
} catch (activityErr) {
|
||||
console.error(
|
||||
"[Quote Commit] Failed to create workflow activity:",
|
||||
activityErr,
|
||||
);
|
||||
// Don't fail the quote commit if the activity fails
|
||||
}
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Quote committed successfully!",
|
||||
quote.toJson({ includeRegenData: true, includeRegenParams: true }),
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.commit"] }),
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { injectPdfMetadata } from "../../../../../modules/pdf-utils";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
|
||||
const VALID_FETCH_ACTIONS = ["download", "print"] as const;
|
||||
type FetchAction = (typeof VALID_FETCH_ACTIONS)[number];
|
||||
|
||||
/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/download?fetchAction=download|print */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/opportunity/:identifier/quote/:quoteId/download"],
|
||||
async (c) => {
|
||||
const quoteId = c.req.param("quoteId");
|
||||
const user = c.get("user");
|
||||
const fetchAction = c.req.query("fetchAction") as FetchAction | undefined;
|
||||
|
||||
if (!fetchAction || !VALID_FETCH_ACTIONS.includes(fetchAction)) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidFetchAction",
|
||||
message: `Query parameter 'fetchAction' is required and must be one of: ${VALID_FETCH_ACTIONS.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const downloadedAt = new Date().toISOString();
|
||||
|
||||
const quote = await generatedQuotes.recordDownload(quoteId, {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
fetchAction,
|
||||
});
|
||||
|
||||
// Inject download-time metadata into the PDF's document properties
|
||||
const pdfWithMetadata = await injectPdfMetadata(quote.quoteFile, {
|
||||
downloadedAt,
|
||||
downloadedById: user.id,
|
||||
downloadedByName: user.name ?? undefined,
|
||||
downloadedByEmail: user.email ?? undefined,
|
||||
});
|
||||
|
||||
const response = apiResponse.successful("Quote downloaded successfully!", {
|
||||
id: quote.id,
|
||||
quoteFileName: quote.quoteFileName,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: Buffer.from(pdfWithMetadata).toString("base64"),
|
||||
});
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.download"] }),
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/opportunity/:identifier/quotes */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/opportunity/:identifier/quotes"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const includeRegenData = c.req.query("includeRegenData") === "true";
|
||||
const includeRegenParams = c.req.query("includeRegenParams") === "true";
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const quotes = await generatedQuotes.fetchByOpportunity(item.id);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Committed quotes fetched successfully!",
|
||||
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams })),
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/opportunity/:identifier/quotes/downloads */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/opportunity/:identifier/quotes/downloads"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
const quotes = await generatedQuotes.fetchByOpportunity(opportunity.id);
|
||||
|
||||
const data = quotes.map((quote) => ({
|
||||
quoteId: quote.id,
|
||||
quoteFileName: quote.quoteFileName,
|
||||
createdById: quote.createdById,
|
||||
createdAt: quote.createdAt,
|
||||
downloads: quote.downloads,
|
||||
}));
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Quote download history fetched successfully!",
|
||||
data,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.fetch_downloads"] }),
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/preview */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/opportunity/:identifier/quote/:quoteId/preview"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const quoteId = c.req.param("quoteId");
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const quote = await generatedQuotes.fetch(quoteId);
|
||||
|
||||
const regenData =
|
||||
quote.quoteRegenData && typeof quote.quoteRegenData === "object"
|
||||
? (quote.quoteRegenData as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const options =
|
||||
regenData.options && typeof regenData.options === "object"
|
||||
? (regenData.options as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const creator = await quote.fetchCreatedBy();
|
||||
|
||||
const previewBuffer = await item.generateQuote({
|
||||
lineItemPricing: options.lineItemPricing as boolean | undefined,
|
||||
includeQuoteNarrative: options.includeQuoteNarrative as
|
||||
| boolean
|
||||
| undefined,
|
||||
includeItemNarratives: options.includeItemNarratives as
|
||||
| boolean
|
||||
| undefined,
|
||||
showPreview: true,
|
||||
metadata: {
|
||||
quoteId: quote.id,
|
||||
createdById: quote.createdById ?? undefined,
|
||||
createdByName: creator?.name ?? undefined,
|
||||
createdByEmail: creator?.email ?? undefined,
|
||||
createdAt: quote.createdAt?.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const previewBase64 = Buffer.from(previewBuffer).toString("base64");
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Quote preview generated successfully!",
|
||||
{
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
},
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.preview"] }),
|
||||
);
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { authMiddleware } from "../../../middleware/authorization";
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/refresh */
|
||||
/* 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);
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createRoute } from "../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../middleware/authorization";
|
||||
import GenericError from "../../../../Errors/GenericError";
|
||||
import { z } from "zod";
|
||||
|
||||
const updateSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).optional(),
|
||||
notes: z.string().optional(),
|
||||
rating: z.object({ id: z.number() }).optional(),
|
||||
type: z.object({ id: z.number() }).optional(),
|
||||
stage: z.object({ id: z.number() }).optional(),
|
||||
status: z.object({ id: z.number() }).optional(),
|
||||
priority: z.object({ id: z.number() }).optional(),
|
||||
campaign: z.object({ id: z.number() }).optional(),
|
||||
primarySalesRep: z.object({ id: z.number() }).optional(),
|
||||
secondarySalesRep: z.object({ id: z.number() }).nullable().optional(),
|
||||
company: z.object({ id: z.number() }).optional(),
|
||||
contact: z.object({ id: z.number() }).nullable().optional(),
|
||||
site: z.object({ id: z.number() }).nullable().optional(),
|
||||
expectedCloseDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v ? new Date(v).toISOString() : v)),
|
||||
customerPO: z.string().nullable().optional(),
|
||||
source: z.string().nullable().optional(),
|
||||
locationId: z.number().optional(),
|
||||
businessUnitId: z.number().optional(),
|
||||
})
|
||||
.refine((d) => Object.values(d).some((v) => v !== undefined), {
|
||||
message: "At least one field must be provided",
|
||||
});
|
||||
|
||||
/* PATCH /v1/sales/opportunities/opportunity/:identifier */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/opportunity/:identifier"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
const data = updateSchema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
|
||||
try {
|
||||
const updated = await item.updateOpportunity(data);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity updated successfully!",
|
||||
updated.toJson(),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
const isAxios =
|
||||
err != null && typeof err === "object" && "isAxiosError" in err;
|
||||
|
||||
if (isAxios) {
|
||||
const axiosErr = err as any;
|
||||
const cwStatus: number = axiosErr.response?.status ?? 502;
|
||||
const cwData = axiosErr.response?.data;
|
||||
const cwMessage: string =
|
||||
cwData?.message ?? "Failed to update the opportunity in ConnectWise";
|
||||
const cwErrors: unknown[] | undefined = Array.isArray(cwData?.errors)
|
||||
? cwData.errors
|
||||
: undefined;
|
||||
|
||||
return c.json(
|
||||
{
|
||||
status: cwStatus,
|
||||
message: cwMessage,
|
||||
error: "ConnectWiseUpdateError",
|
||||
successful: false,
|
||||
errors: cwErrors,
|
||||
meta: { timestamp: Date.now() },
|
||||
},
|
||||
cwStatus as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
|
||||
throw new GenericError({
|
||||
status: 500,
|
||||
name: "OpportunitySaveError",
|
||||
message: "Failed to save opportunity data",
|
||||
cause: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.update"] }),
|
||||
);
|
||||
@@ -0,0 +1,196 @@
|
||||
import { z } from "zod";
|
||||
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 { opportunities } from "../../../../../managers/opportunities";
|
||||
import { cwMembers } from "../../../../../managers/cwMembers";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
import {
|
||||
processOpportunityAction,
|
||||
type WorkflowAction,
|
||||
type WorkflowUser,
|
||||
} from "../../../../../workflows/wf.opportunity";
|
||||
|
||||
// ── Zod schemas ───────────────────────────────────────────────────────────
|
||||
|
||||
const basePayload = z.object({
|
||||
note: z.string().optional(),
|
||||
timeStarted: z.string().datetime().optional(),
|
||||
timeEnded: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const noteRequiredPayload = z.object({
|
||||
note: z.string().min(1, "A non-empty note is required."),
|
||||
timeStarted: z.string().datetime().optional(),
|
||||
timeEnded: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const dispatchSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("acceptNew"),
|
||||
payload: basePayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("requestReview"),
|
||||
payload: noteRequiredPayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("reviewDecision"),
|
||||
payload: noteRequiredPayload.extend({
|
||||
decision: z.enum(["approve", "reject", "send", "cancel"]),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("sendQuote"),
|
||||
payload: basePayload.extend({
|
||||
quoteConfirmed: z.boolean().optional(),
|
||||
won: z.boolean().optional(),
|
||||
lost: z.boolean().optional(),
|
||||
finalize: z.boolean().optional(),
|
||||
needsRevision: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("markReadyToSend"),
|
||||
payload: basePayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("confirmQuote"),
|
||||
payload: basePayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("finalize"),
|
||||
payload: noteRequiredPayload.extend({
|
||||
outcome: z.enum(["won", "lost"]),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("resurrect"),
|
||||
payload: noteRequiredPayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("beginRevision"),
|
||||
payload: basePayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("resendQuote"),
|
||||
payload: basePayload.extend({
|
||||
quoteConfirmed: z.boolean().optional(),
|
||||
won: z.boolean().optional(),
|
||||
lost: z.boolean().optional(),
|
||||
finalize: z.boolean().optional(),
|
||||
needsRevision: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("cancel"),
|
||||
payload: noteRequiredPayload,
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("reopen"),
|
||||
payload: noteRequiredPayload,
|
||||
}),
|
||||
]);
|
||||
|
||||
// ── Route ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/* POST /v1/sales/opportunities/opportunity/:identifier/workflow */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/opportunity/:identifier/workflow"],
|
||||
async (c) => {
|
||||
try {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
console.log(
|
||||
"[Workflow Dispatch] Raw request body:",
|
||||
JSON.stringify(body, null, 2),
|
||||
);
|
||||
const parsed = dispatchSchema.parse(body);
|
||||
console.log(
|
||||
"[Workflow Dispatch] Parsed payload:",
|
||||
JSON.stringify(parsed.payload, null, 2),
|
||||
);
|
||||
const user = c.get("user");
|
||||
|
||||
// ── Resolve opportunity ────────────────────────────────────────────
|
||||
const opportunity = await opportunities.fetchItem(identifier);
|
||||
|
||||
// ── Build WorkflowUser ─────────────────────────────────────────────
|
||||
if (!user.cwIdentifier) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "MissingCwIdentifier",
|
||||
message:
|
||||
"Your account is not linked to a ConnectWise member. A CW member association is required to execute workflow actions.",
|
||||
});
|
||||
}
|
||||
|
||||
const cwMember = await cwMembers.fetch(user.cwIdentifier);
|
||||
const permissions = await user.readAllPermissions();
|
||||
|
||||
const workflowUser: WorkflowUser = {
|
||||
id: user.id,
|
||||
cwMemberId: cwMember.cwMemberId,
|
||||
permissions,
|
||||
};
|
||||
|
||||
// ── Dispatch ───────────────────────────────────────────────────────
|
||||
const result = await processOpportunityAction(
|
||||
opportunity,
|
||||
parsed as WorkflowAction,
|
||||
workflowUser,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`[Workflow Dispatch] Transition failed for opportunity "${identifier}":`,
|
||||
result.error,
|
||||
);
|
||||
const response = apiResponse.error(
|
||||
new GenericError({
|
||||
status: 422,
|
||||
name: "WorkflowTransitionFailed",
|
||||
message: result.error ?? "Workflow action failed.",
|
||||
}),
|
||||
);
|
||||
return c.json(
|
||||
{
|
||||
...response,
|
||||
data: {
|
||||
previousStatusId: result.previousStatusId,
|
||||
previousStatus: result.previousStatus,
|
||||
newStatusId: result.newStatusId,
|
||||
newStatus: result.newStatus,
|
||||
},
|
||||
},
|
||||
response.status as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Workflow action completed successfully.",
|
||||
{
|
||||
previousStatusId: result.previousStatusId,
|
||||
previousStatus: result.previousStatus,
|
||||
newStatusId: result.newStatusId,
|
||||
newStatus: result.newStatus,
|
||||
activitiesCreated: result.activitiesCreated.map((a) => a.toJson()),
|
||||
coldCheck: result.coldCheck,
|
||||
},
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err: any) {
|
||||
console.error("[Workflow Dispatch] Unhandled error:", err);
|
||||
if (err?.response?.data) {
|
||||
console.error(
|
||||
"[Workflow Dispatch] CW response body:",
|
||||
JSON.stringify(err.response.data, null, 2),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.workflow"] }),
|
||||
);
|
||||
@@ -0,0 +1,150 @@
|
||||
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 { opportunities } from "../../../../../managers/opportunities";
|
||||
import { activityCw } from "../../../../../modules/cw-utils/activities/activities";
|
||||
import { ActivityController } from "../../../../../controllers/ActivityController";
|
||||
import { OptimaType } from "../../../../../workflows/wf.opportunity";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const OPTIMA_TYPE_VALUES = new Set<string>([
|
||||
OptimaType.OpportunityCreated,
|
||||
OptimaType.OpportunitySetup,
|
||||
OptimaType.OpportunityReview,
|
||||
OptimaType.QuoteSent,
|
||||
OptimaType.QuoteConfirmed,
|
||||
OptimaType.QuoteSentConfirmed,
|
||||
OptimaType.QuoteGenerated,
|
||||
OptimaType.Revision,
|
||||
OptimaType.Finalized,
|
||||
OptimaType.Converted,
|
||||
]);
|
||||
|
||||
/** QuoteID custom field ID (matches wf.opportunity.ts QUOTE_ID_FIELD_ID). */
|
||||
const QUOTE_ID_FIELD_ID = 48;
|
||||
|
||||
/** Close Date custom field ID (matches wf.opportunity.ts CLOSE_DATE_FIELD_ID). */
|
||||
const CLOSE_DATE_FIELD_ID = 49;
|
||||
|
||||
/**
|
||||
* Extract the Optima_Type value from a CW activity's custom fields.
|
||||
* Returns the string value if present, or null.
|
||||
*/
|
||||
function extractOptimaType(
|
||||
customFields: { id: number; value: unknown }[] | undefined,
|
||||
): string | null {
|
||||
if (!customFields) return null;
|
||||
const field = customFields.find((f) => f.id === OptimaType.FIELD_ID);
|
||||
if (!field?.value || typeof field.value !== "string") return null;
|
||||
return OPTIMA_TYPE_VALUES.has(field.value) ? field.value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the QuoteID custom field value from a CW activity.
|
||||
* Returns the string value or null.
|
||||
*/
|
||||
function extractQuoteId(
|
||||
customFields: { id: number; value: unknown }[] | undefined,
|
||||
): string | null {
|
||||
if (!customFields) return null;
|
||||
const field = customFields.find((f) => f.id === QUOTE_ID_FIELD_ID);
|
||||
if (!field?.value || typeof field.value !== "string") return null;
|
||||
return field.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the Close Date custom field value from a CW activity.
|
||||
* Returns the ISO-8601 string or null.
|
||||
*/
|
||||
function extractCloseDate(
|
||||
customFields: { id: number; value: unknown }[] | undefined,
|
||||
): string | null {
|
||||
if (!customFields) return null;
|
||||
const field = customFields.find((f) => f.id === CLOSE_DATE_FIELD_ID);
|
||||
if (!field?.value || typeof field.value !== "string") return null;
|
||||
return field.value;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ROUTE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/* GET /v1/sales/opportunities/opportunity/:identifier/workflow/history */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/opportunity/:identifier/workflow/history"],
|
||||
async (c) => {
|
||||
try {
|
||||
const identifier = c.req.param("identifier");
|
||||
const filterType = c.req.query("type") ?? null; // optional filter by Optima_Type value
|
||||
|
||||
// Resolve the opportunity to get the CW opportunity ID
|
||||
const opportunity = await opportunities.fetchItem(identifier);
|
||||
|
||||
// Fetch all activities for this opportunity from CW
|
||||
const activitiesCollection = await activityCw.fetchByOpportunity(
|
||||
opportunity.cwOpportunityId,
|
||||
);
|
||||
|
||||
// Filter to workflow activities (those with a valid Optima_Type)
|
||||
const workflowActivities: {
|
||||
activity: ReturnType<ActivityController["toJson"]>;
|
||||
optimaType: string;
|
||||
quoteId: string | null;
|
||||
closed: boolean;
|
||||
closedAt: string | null;
|
||||
}[] = [];
|
||||
|
||||
for (const [, raw] of activitiesCollection) {
|
||||
const controller = new ActivityController(raw);
|
||||
const json = controller.toJson();
|
||||
const optimaType = extractOptimaType(raw.customFields);
|
||||
|
||||
if (!optimaType) continue;
|
||||
if (filterType && optimaType !== filterType) continue;
|
||||
|
||||
const quoteId = extractQuoteId(raw.customFields);
|
||||
const closed = raw.status?.id === 2;
|
||||
const closedAt = extractCloseDate(raw.customFields);
|
||||
|
||||
workflowActivities.push({
|
||||
activity: json,
|
||||
optimaType,
|
||||
quoteId,
|
||||
closed,
|
||||
closedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
workflowActivities.sort((a, b) => {
|
||||
const dateA = new Date(
|
||||
a.activity.dateEnd ?? a.activity.dateStart ?? 0,
|
||||
).getTime();
|
||||
const dateB = new Date(
|
||||
b.activity.dateEnd ?? b.activity.dateStart ?? 0,
|
||||
).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Workflow history fetched successfully.",
|
||||
{
|
||||
opportunityId: opportunity.id,
|
||||
cwOpportunityId: opportunity.cwOpportunityId,
|
||||
totalActivities: workflowActivities.length,
|
||||
activities: workflowActivities,
|
||||
},
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
console.error("[Workflow History] Unhandled error:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,420 @@
|
||||
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 { opportunities } from "../../../../../managers/opportunities";
|
||||
import {
|
||||
OpportunityStatus,
|
||||
StatusIdToKey,
|
||||
WorkflowPermissions,
|
||||
type OpportunityStatusKey,
|
||||
} from "../../../../../workflows/wf.opportunity";
|
||||
import {
|
||||
checkColdStatus,
|
||||
type ColdCheckResult,
|
||||
} from "../../../../../modules/algorithms/algo.coldThreshold";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ACTION AVAILABILITY MAP
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Per-status list of actions the user can invoke.
|
||||
*
|
||||
* Each entry describes the action key, a human-readable label, the
|
||||
* expected target status(es), whether a note is required, and any
|
||||
* permission gate beyond the base workflow permission.
|
||||
*/
|
||||
interface AvailableAction {
|
||||
action: string;
|
||||
label: string;
|
||||
targetStatuses: { key: OpportunityStatusKey; id: number }[];
|
||||
requiresNote: boolean;
|
||||
requiresPermission: string | null;
|
||||
/** Extra payload fields that can/must be provided. */
|
||||
payloadHints?: Record<string, string>;
|
||||
}
|
||||
|
||||
const ACTION_MAP: Record<number, AvailableAction[]> = {
|
||||
[OpportunityStatus.PendingNew]: [
|
||||
{
|
||||
action: "acceptNew",
|
||||
label: "Accept / Set Up Opportunity",
|
||||
targetStatuses: [{ key: "New", id: OpportunityStatus.New }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
},
|
||||
],
|
||||
|
||||
[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)",
|
||||
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",
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "requestReview",
|
||||
label: "Send to Internal Review",
|
||||
targetStatuses: [
|
||||
{ key: "InternalReview", id: OpportunityStatus.InternalReview },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
},
|
||||
{
|
||||
action: "cancel",
|
||||
label: "Cancel Opportunity",
|
||||
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.CANCEL,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.InternalReview]: [
|
||||
{
|
||||
action: "reviewDecision",
|
||||
label: "Approve (move to Ready to Send)",
|
||||
targetStatuses: [
|
||||
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { decision: '"approve"' },
|
||||
},
|
||||
{
|
||||
action: "reviewDecision",
|
||||
label: "Reject (move to Pending Revision)",
|
||||
targetStatuses: [
|
||||
{ key: "PendingRevision", id: OpportunityStatus.PendingRevision },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { decision: '"reject"' },
|
||||
},
|
||||
{
|
||||
action: "reviewDecision",
|
||||
label: "Send Quote (reviewer sends directly)",
|
||||
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { decision: '"send"' },
|
||||
},
|
||||
{
|
||||
action: "reviewDecision",
|
||||
label: "Cancel from Review",
|
||||
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.CANCEL,
|
||||
payloadHints: { decision: '"cancel"' },
|
||||
},
|
||||
],
|
||||
|
||||
[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",
|
||||
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.PendingRevision]: [
|
||||
{
|
||||
action: "beginRevision",
|
||||
label: "Begin Revision",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.QuoteSent]: [
|
||||
{
|
||||
action: "confirmQuote",
|
||||
label: "Confirm Quote Receipt",
|
||||
targetStatuses: [
|
||||
{ key: "ConfirmedQuote", id: OpportunityStatus.ConfirmedQuote },
|
||||
],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
},
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Mark as Won",
|
||||
targetStatuses: [
|
||||
{ key: "Won", id: OpportunityStatus.Won },
|
||||
{ key: "PendingWon", id: OpportunityStatus.PendingWon },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { outcome: '"won"' },
|
||||
},
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Mark as Lost",
|
||||
targetStatuses: [
|
||||
{ key: "PendingLost", id: OpportunityStatus.PendingLost },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { outcome: '"lost"' },
|
||||
},
|
||||
{
|
||||
action: "resendQuote",
|
||||
label: "Revise & Re-send (back to Active)",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
payloadHints: { needsRevision: "true" },
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.ConfirmedQuote]: [
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Mark as Won",
|
||||
targetStatuses: [
|
||||
{ key: "Won", id: OpportunityStatus.Won },
|
||||
{ key: "PendingWon", id: OpportunityStatus.PendingWon },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { outcome: '"won"' },
|
||||
},
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Mark as Lost",
|
||||
targetStatuses: [
|
||||
{ key: "PendingLost", id: OpportunityStatus.PendingLost },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
payloadHints: { outcome: '"lost"' },
|
||||
},
|
||||
{
|
||||
action: "resendQuote",
|
||||
label: "Revise & Re-send (back to Active)",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
payloadHints: { needsRevision: "true" },
|
||||
},
|
||||
],
|
||||
|
||||
[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",
|
||||
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
|
||||
requiresNote: false,
|
||||
requiresPermission: null,
|
||||
payloadHints: {
|
||||
quoteConfirmed: "boolean — mark confirmed simultaneously",
|
||||
won: "boolean — immediate win",
|
||||
lost: "boolean — immediate rejection",
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "requestReview",
|
||||
label: "Send to Internal Review",
|
||||
targetStatuses: [
|
||||
{ key: "InternalReview", id: OpportunityStatus.InternalReview },
|
||||
],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
},
|
||||
{
|
||||
action: "cancel",
|
||||
label: "Cancel Opportunity",
|
||||
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.CANCEL,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.PendingWon]: [
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Approve Won",
|
||||
targetStatuses: [{ key: "Won", id: OpportunityStatus.Won }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.FINALIZE,
|
||||
payloadHints: { outcome: '"won"' },
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.PendingLost]: [
|
||||
{
|
||||
action: "finalize",
|
||||
label: "Approve Lost",
|
||||
targetStatuses: [{ key: "Lost", id: OpportunityStatus.Lost }],
|
||||
requiresNote: true,
|
||||
requiresPermission: WorkflowPermissions.FINALIZE,
|
||||
payloadHints: { outcome: '"lost"' },
|
||||
},
|
||||
{
|
||||
action: "resurrect",
|
||||
label: "Resurrect (back to Active)",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
},
|
||||
],
|
||||
|
||||
[OpportunityStatus.Won]: [],
|
||||
[OpportunityStatus.Lost]: [],
|
||||
|
||||
[OpportunityStatus.Canceled]: [
|
||||
{
|
||||
action: "reopen",
|
||||
label: "Re-open Opportunity",
|
||||
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
|
||||
requiresNote: true,
|
||||
requiresPermission: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ROUTE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/* GET /v1/sales/opportunities/opportunity/:identifier/workflow */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/opportunity/:identifier/workflow"],
|
||||
async (c) => {
|
||||
try {
|
||||
const identifier = c.req.param("identifier");
|
||||
const user = c.get("user");
|
||||
|
||||
const opportunity = await opportunities.fetchItem(identifier);
|
||||
|
||||
const statusCwId = opportunity.statusCwId;
|
||||
const statusKey: OpportunityStatusKey | null =
|
||||
statusCwId != null ? (StatusIdToKey[statusCwId] ?? null) : null;
|
||||
|
||||
const isOptimaStage = opportunity.stageName === "Optima";
|
||||
const isTerminal =
|
||||
statusCwId === OpportunityStatus.Won ||
|
||||
statusCwId === OpportunityStatus.Lost;
|
||||
|
||||
// ── Resolve available actions (permission-aware) ──────────────────
|
||||
const rawActions =
|
||||
statusCwId != null ? (ACTION_MAP[statusCwId] ?? []) : [];
|
||||
|
||||
const resolvedActions = await Promise.all(
|
||||
rawActions.map(async (a) => {
|
||||
const hasGate =
|
||||
!a.requiresPermission ||
|
||||
(await user.hasPermission(a.requiresPermission));
|
||||
return { ...a, permitted: hasGate };
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Cold check (only for QuoteSent / ConfirmedQuote) ──────────────
|
||||
let coldCheck: ColdCheckResult | null = null;
|
||||
if (
|
||||
statusCwId === OpportunityStatus.QuoteSent ||
|
||||
statusCwId === OpportunityStatus.ConfirmedQuote
|
||||
) {
|
||||
// Fetch activities to determine latest activity date
|
||||
const activities = await opportunity.fetchActivities();
|
||||
const latestDate =
|
||||
activities.length > 0
|
||||
? new Date(
|
||||
Math.max(
|
||||
...activities.map((a) => {
|
||||
const json = a.toJson();
|
||||
return new Date(
|
||||
json.dateEnd ?? json.dateStart ?? 0,
|
||||
).getTime();
|
||||
}),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
coldCheck = checkColdStatus({
|
||||
statusCwId,
|
||||
lastActivityDate: latestDate,
|
||||
});
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Workflow status fetched successfully.",
|
||||
{
|
||||
currentStatusId: statusCwId,
|
||||
currentStatus: statusKey,
|
||||
stageName: opportunity.stageName ?? null,
|
||||
isOptimaStage,
|
||||
isTerminal,
|
||||
availableActions: isOptimaStage ? resolvedActions : [],
|
||||
coldCheck,
|
||||
},
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
console.error("[Workflow Status] Unhandled error:", err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../managers/opportunities";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/count */
|
||||
export default createRoute(
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { z } from "zod";
|
||||
import { cwMembers } from "../../../managers/cwMembers";
|
||||
import {
|
||||
createWorkflowActivity,
|
||||
OptimaType,
|
||||
} from "../../../workflows/wf.opportunity";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
expectedCloseDate: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((v) => new Date(v).toISOString().replace(/\.\d{3}Z$/, "Z")),
|
||||
notes: z.string().optional(),
|
||||
rating: z.object({ id: z.number() }).optional(),
|
||||
type: z.object({ id: z.number() }).optional(),
|
||||
stage: z.object({ id: z.number() }).optional(),
|
||||
status: z.object({ id: z.number() }).optional(),
|
||||
priority: z.object({ id: z.number() }).optional(),
|
||||
campaign: z.object({ id: z.number() }).optional(),
|
||||
primarySalesRep: z.object({ id: z.number() }),
|
||||
secondarySalesRep: z.object({ id: z.number() }).nullable().optional(),
|
||||
company: z.object({ id: z.number() }),
|
||||
contact: z.object({ id: z.number() }),
|
||||
site: z.object({ id: z.number() }).nullable().optional(),
|
||||
source: z.string().nullable().optional(),
|
||||
customerPO: z.string().nullable().optional(),
|
||||
locationId: z.number().optional(),
|
||||
businessUnitId: z.number().optional(),
|
||||
});
|
||||
|
||||
/* POST /v1/sales/opportunities */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities"],
|
||||
async (c) => {
|
||||
const body = await c.req.json();
|
||||
const data = createSchema.parse(body);
|
||||
|
||||
try {
|
||||
const item = await opportunities.createItem(data);
|
||||
|
||||
// Create a workflow activity for the new opportunity
|
||||
try {
|
||||
const user = c.get("user");
|
||||
let cwMemberId: number | null = null;
|
||||
|
||||
if (user.cwIdentifier) {
|
||||
const cwMember = await cwMembers.fetch(user.cwIdentifier);
|
||||
cwMemberId = cwMember.cwMemberId;
|
||||
}
|
||||
|
||||
if (cwMemberId) {
|
||||
await createWorkflowActivity({
|
||||
name: `[Workflow] Opportunity created — ${item.name}`,
|
||||
opportunityCwId: item.cwOpportunityId,
|
||||
companyCwId: item.companyCwId,
|
||||
assignToCwMemberId: cwMemberId,
|
||||
notes: "Opportunity created.",
|
||||
optimaType: OptimaType.OpportunityCreated,
|
||||
});
|
||||
}
|
||||
} catch (activityErr) {
|
||||
console.error(
|
||||
"[Opportunity Create] Failed to create workflow activity:",
|
||||
activityErr,
|
||||
);
|
||||
// Don't fail the opportunity creation if the activity fails
|
||||
}
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Opportunity created successfully!",
|
||||
item.toJson(),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
const isAxios =
|
||||
err != null && typeof err === "object" && "isAxiosError" in err;
|
||||
|
||||
if (isAxios) {
|
||||
const axiosErr = err as any;
|
||||
const cwStatus: number = axiosErr.response?.status ?? 502;
|
||||
const cwData = axiosErr.response?.data;
|
||||
const cwMessage: string =
|
||||
cwData?.message ?? "Failed to create the opportunity in ConnectWise";
|
||||
const cwErrors: unknown[] | undefined = Array.isArray(cwData?.errors)
|
||||
? cwData.errors
|
||||
: undefined;
|
||||
|
||||
return c.json(
|
||||
{
|
||||
status: cwStatus,
|
||||
message: cwMessage,
|
||||
error: "ConnectWiseCreateError",
|
||||
successful: false,
|
||||
errors: cwErrors,
|
||||
meta: { timestamp: Date.now() },
|
||||
},
|
||||
cwStatus as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
|
||||
throw new GenericError({
|
||||
status: 500,
|
||||
name: "OpportunityCreateError",
|
||||
message: "Failed to create opportunity",
|
||||
cause: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.create"] }),
|
||||
);
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../managers/opportunities";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/sales/opportunities */
|
||||
export default createRoute(
|
||||
@@ -4,22 +4,22 @@ 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/@me */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/notes"],
|
||||
["/opportunities/@me"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const user = c.get("user");
|
||||
const includeClosed = c.req.query("includeClosed") === "true";
|
||||
|
||||
const data = await item.fetchNotes();
|
||||
const data = await opportunities.fetchByUser(user.id, { includeClosed });
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity notes fetched successfully!",
|
||||
data,
|
||||
"Opportunities fetched successfully!",
|
||||
data.map((item) => item.toJson()),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
|
||||
);
|
||||
@@ -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"] }),
|
||||
);
|
||||
@@ -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"] }),
|
||||
);
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Socket } from "socket.io";
|
||||
import { attachSocketEventPermissions } from "../middleware/authorization";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
|
||||
const LIVE_QUOTE_PREVIEW_PERMISSION = "sales.opportunity.fetch";
|
||||
|
||||
export const registerLiveQuotePreviewHandlers = (socket: Socket) => {
|
||||
attachSocketEventPermissions(socket, {
|
||||
"opp:live_quote_preview": [LIVE_QUOTE_PREVIEW_PERMISSION],
|
||||
});
|
||||
|
||||
const registeredLivePreviewEvents = new Set<string>();
|
||||
|
||||
socket.on(
|
||||
"opp:live_quote_preview",
|
||||
async (
|
||||
payload: { id?: string | number },
|
||||
ack?: (response: { ok: boolean; event?: string; error?: string }) => void,
|
||||
) => {
|
||||
const oppId = payload?.id;
|
||||
const normalizedId =
|
||||
typeof oppId === "string" || typeof oppId === "number"
|
||||
? `${oppId}`
|
||||
: "";
|
||||
|
||||
if (!normalizedId) {
|
||||
if (ack) return ack({ ok: false, error: "Missing opportunity id" });
|
||||
socket.emit("opp:live_quote_preview:error", {
|
||||
message: "Missing opportunity id",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dataEvent = `opp:live_quote_preview:${normalizedId}:data`;
|
||||
const previewEvent = `opp:live_quote_preview:${normalizedId}:preview`;
|
||||
const roomName = `opp:live_quote_preview:${normalizedId}`;
|
||||
|
||||
if (!registeredLivePreviewEvents.has(dataEvent)) {
|
||||
registeredLivePreviewEvents.add(dataEvent);
|
||||
socket.join(roomName);
|
||||
|
||||
socket.on(dataEvent, async (data: any) => {
|
||||
socket.to(roomName).emit(dataEvent, data);
|
||||
|
||||
try {
|
||||
const opportunity = await opportunities.fetchRecord(normalizedId);
|
||||
const opts =
|
||||
data?.options && typeof data.options === "object"
|
||||
? data.options
|
||||
: data;
|
||||
|
||||
const previewBuffer = await opportunity.generateQuote({
|
||||
lineItemPricing: opts?.lineItemPricing,
|
||||
includeQuoteNarrative: opts?.includeQuoteNarrative,
|
||||
includeItemNarratives: opts?.includeItemNarratives,
|
||||
logoPath: opts?.logoPath,
|
||||
showPreview: true,
|
||||
});
|
||||
|
||||
const previewBase64 = Buffer.from(previewBuffer).toString("base64");
|
||||
|
||||
socket.to(roomName).emit(previewEvent, {
|
||||
id: normalizedId,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
});
|
||||
|
||||
socket.to(roomName).emit(dataEvent, {
|
||||
id: normalizedId,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
});
|
||||
|
||||
socket.emit(previewEvent, {
|
||||
id: normalizedId,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
});
|
||||
|
||||
socket.emit(dataEvent, {
|
||||
id: normalizedId,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
});
|
||||
} catch (err: any) {
|
||||
socket.emit("opp:live_quote_preview:error", {
|
||||
message: err?.message ?? "Failed to generate live quote preview",
|
||||
id: normalizedId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (ack) return ack({ ok: true, event: dataEvent });
|
||||
|
||||
socket.emit("opp:live_quote_preview:ready", {
|
||||
id: normalizedId,
|
||||
event: dataEvent,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { setupSecureNamespace } from "./secure";
|
||||
|
||||
export const setupSockets = () => {
|
||||
setupSecureNamespace();
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Socket } from "socket.io";
|
||||
import UserController from "../../../controllers/UserController";
|
||||
|
||||
type SecureSocket = Socket & {
|
||||
data: {
|
||||
user?: UserController;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export const attachSocketEventPermissions = (
|
||||
socket: Socket,
|
||||
eventPermissions: Record<string, string[]>,
|
||||
): boolean => {
|
||||
const user = (socket.data?.user as UserController | undefined) ?? undefined;
|
||||
if (!user) return false;
|
||||
|
||||
socket.use(async (packet, packetNext) => {
|
||||
const eventName = packet[0];
|
||||
|
||||
if (typeof eventName !== "string") return packetNext();
|
||||
|
||||
const eventRequiredPermissions = eventPermissions[eventName] ?? [];
|
||||
if (eventRequiredPermissions.length === 0) return packetNext();
|
||||
|
||||
const eventChecks = await Promise.all(
|
||||
eventRequiredPermissions.map((permission) =>
|
||||
user.hasPermission(permission),
|
||||
),
|
||||
);
|
||||
|
||||
if (eventChecks.includes(false)) {
|
||||
return packetNext(new Error("Forbidden: insufficient permissions"));
|
||||
}
|
||||
|
||||
return packetNext();
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const socketAuthMiddleware = (permParams?: {
|
||||
permissions?: string[];
|
||||
eventPermissions?: Record<string, string[]>;
|
||||
}) => {
|
||||
return async (socket: SecureSocket, next: (err?: Error) => void) => {
|
||||
const user = socket.data.user;
|
||||
if (!user) return next(new Error("Unauthorized"));
|
||||
|
||||
const requiredPermissions = permParams?.permissions ?? [];
|
||||
|
||||
if (requiredPermissions.length > 0) {
|
||||
const permissionChecks = await Promise.all(
|
||||
requiredPermissions.map((permission) => user.hasPermission(permission)),
|
||||
);
|
||||
|
||||
if (permissionChecks.includes(false)) {
|
||||
return next(new Error("Forbidden: insufficient permissions"));
|
||||
}
|
||||
}
|
||||
|
||||
const eventPermissions = permParams?.eventPermissions;
|
||||
|
||||
if (eventPermissions) {
|
||||
const attached = attachSocketEventPermissions(socket, eventPermissions);
|
||||
if (!attached) return next(new Error("Unauthorized"));
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Namespace } from "socket.io";
|
||||
import { io, prisma } from "../../constants";
|
||||
import { sessions } from "../../managers/sessions";
|
||||
import { socketAuthMiddleware } from "./middleware/authorization";
|
||||
import { registerLiveQuotePreviewHandlers } from "./events/liveQuotePreview";
|
||||
|
||||
const SESSION_ENFORCEMENT_INTERVAL_MS = 60 * 1000;
|
||||
const MAX_TIMEOUT_MS = 2_147_483_647;
|
||||
|
||||
const AUTH_HEADER_REGEX =
|
||||
/^(Bearer|Key)\s([a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+)$/;
|
||||
|
||||
const resolveAuthorization = (handshake: {
|
||||
auth?: Record<string, unknown>;
|
||||
headers?: Record<string, unknown>;
|
||||
}): string | null => {
|
||||
const headerAuth = handshake.headers?.authorization;
|
||||
if (typeof headerAuth === "string" && headerAuth.length > 0)
|
||||
return headerAuth;
|
||||
|
||||
const authAuthorization = handshake.auth?.authorization;
|
||||
if (typeof authAuthorization === "string" && authAuthorization.length > 0)
|
||||
return authAuthorization;
|
||||
|
||||
const authToken = handshake.auth?.token;
|
||||
if (typeof authToken === "string" && authToken.length > 0)
|
||||
return `Bearer ${authToken}`;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const setupSecureNamespace = (): Namespace => {
|
||||
const secureNamespace = io.of("/secure");
|
||||
|
||||
secureNamespace.use(async (socket, next) => {
|
||||
try {
|
||||
const authorization = resolveAuthorization(socket.handshake as any);
|
||||
if (!authorization)
|
||||
return next(new Error("Unauthorized: missing authorization"));
|
||||
|
||||
const components = authorization.match(AUTH_HEADER_REGEX);
|
||||
if (!components)
|
||||
return next(new Error("Unauthorized: invalid authorization format"));
|
||||
|
||||
const authValue = components[2] ?? "";
|
||||
const session = await sessions.fetch({ accessToken: authValue });
|
||||
const user = await session.fetchUser();
|
||||
|
||||
socket.data.user = user;
|
||||
socket.data.session = session;
|
||||
|
||||
return next();
|
||||
} catch {
|
||||
return next(new Error("Unauthorized"));
|
||||
}
|
||||
});
|
||||
|
||||
secureNamespace.use(socketAuthMiddleware());
|
||||
|
||||
secureNamespace.on("connection", (socket) => {
|
||||
const sessionId = socket.data.session?.id as string | undefined;
|
||||
const sessionExpiresAt = socket.data.session?.expires
|
||||
? new Date(socket.data.session.expires).getTime()
|
||||
: null;
|
||||
|
||||
const disconnectForSession = () => {
|
||||
if (socket.disconnected) return;
|
||||
socket.emit("secure:session:expired");
|
||||
socket.disconnect(true);
|
||||
};
|
||||
|
||||
let expiryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleExpiryDisconnect = () => {
|
||||
if (sessionExpiresAt === null) return;
|
||||
|
||||
const remainingMs = sessionExpiresAt - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
disconnectForSession();
|
||||
return;
|
||||
}
|
||||
|
||||
const delayMs = Math.min(remainingMs, MAX_TIMEOUT_MS);
|
||||
expiryTimeout = setTimeout(scheduleExpiryDisconnect, delayMs);
|
||||
};
|
||||
|
||||
scheduleExpiryDisconnect();
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
if (!sessionId) {
|
||||
disconnectForSession();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await prisma.session.findFirst({
|
||||
where: { id: sessionId },
|
||||
select: { id: true, expires: true, invalidatedAt: true },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
disconnectForSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.invalidatedAt) {
|
||||
disconnectForSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.expires.getTime() <= Date.now()) {
|
||||
disconnectForSession();
|
||||
}
|
||||
}, SESSION_ENFORCEMENT_INTERVAL_MS);
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
clearInterval(interval);
|
||||
if (expiryTimeout) clearTimeout(expiryTimeout);
|
||||
});
|
||||
|
||||
registerLiveQuotePreviewHandlers(socket);
|
||||
|
||||
socket.emit("secure:connected", {
|
||||
userId: socket.data.user?.id ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
return secureNamespace;
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { CwMember } from "../../generated/prisma/client";
|
||||
import type { CWMember } from "../modules/cw-utils/members/fetchAllMembers";
|
||||
|
||||
/**
|
||||
* CW Member Controller
|
||||
*
|
||||
* Domain model class that encapsulates a ConnectWise Member entity,
|
||||
* providing access to member data and serialization for the API.
|
||||
*/
|
||||
export class CwMemberController {
|
||||
public readonly id: string;
|
||||
public readonly cwMemberId: number;
|
||||
public readonly identifier: string;
|
||||
public firstName: string;
|
||||
public lastName: string;
|
||||
public officeEmail: string | null;
|
||||
public inactiveFlag: boolean;
|
||||
public apiKey: string | null;
|
||||
public cwLastUpdated: Date | null;
|
||||
public readonly createdAt: Date;
|
||||
public readonly updatedAt: Date;
|
||||
|
||||
constructor(data: CwMember) {
|
||||
this.id = data.id;
|
||||
this.cwMemberId = data.cwMemberId;
|
||||
this.identifier = data.identifier;
|
||||
this.firstName = data.firstName;
|
||||
this.lastName = data.lastName;
|
||||
this.officeEmail = data.officeEmail;
|
||||
this.inactiveFlag = data.inactiveFlag;
|
||||
this.apiKey = data.apiKey;
|
||||
this.cwLastUpdated = data.cwLastUpdated;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Name
|
||||
*
|
||||
* Returns the member's full name, falling back to the identifier.
|
||||
*/
|
||||
public get fullName(): string {
|
||||
const name = `${this.firstName} ${this.lastName}`.trim();
|
||||
return name || this.identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map CW Member → Prisma create/update payload
|
||||
*
|
||||
* Static helper used by both the controller and the refresh sync.
|
||||
*/
|
||||
public static mapCwToDb(item: CWMember) {
|
||||
return {
|
||||
identifier: item.identifier,
|
||||
firstName: item.firstName ?? "",
|
||||
lastName: item.lastName ?? "",
|
||||
officeEmail: item.officeEmail ?? null,
|
||||
inactiveFlag: item.inactiveFlag ?? false,
|
||||
cwLastUpdated: item._info?.lastUpdated
|
||||
? new Date(item._info.lastUpdated)
|
||||
: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Serializes the member into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
return {
|
||||
id: this.id,
|
||||
cwMemberId: this.cwMemberId,
|
||||
identifier: this.identifier,
|
||||
firstName: this.firstName,
|
||||
lastName: this.lastName,
|
||||
fullName: this.fullName,
|
||||
officeEmail: this.officeEmail,
|
||||
inactiveFlag: this.inactiveFlag,
|
||||
apiKey: this.apiKey,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export class ForecastProductController {
|
||||
public catalogItemIdentifier: string | null;
|
||||
|
||||
public productDescription: string;
|
||||
public customerDescription: string | null;
|
||||
public productNarrative: string | null;
|
||||
public productClass: string;
|
||||
public forecastType: string;
|
||||
|
||||
@@ -74,6 +76,9 @@ export class ForecastProductController {
|
||||
this.catalogItemIdentifier = data.catalogItem?.identifier ?? null;
|
||||
|
||||
this.productDescription = data.productDescription;
|
||||
this.customerDescription = data.customerDescription ?? null;
|
||||
this.productNarrative =
|
||||
data.customFields?.find((f) => f.id === 46)?.value?.toString() ?? null;
|
||||
this.productClass = data.productClass;
|
||||
this.forecastType = data.forecastType;
|
||||
|
||||
@@ -118,6 +123,24 @@ export class ForecastProductController {
|
||||
* Enriches this forecast product with cancellation data from the
|
||||
* procurement products endpoint.
|
||||
*/
|
||||
/**
|
||||
* Apply Procurement Custom Fields
|
||||
*
|
||||
* Enriches this forecast product with custom field data from the
|
||||
* procurement products endpoint (the forecast endpoint does not
|
||||
* return customFields).
|
||||
*/
|
||||
public applyProcurementCustomFields(data: {
|
||||
customFields?: Array<{ id: number; value?: unknown }>;
|
||||
}): void {
|
||||
const narrative = data.customFields
|
||||
?.find((f) => f.id === 46)
|
||||
?.value?.toString();
|
||||
if (narrative) {
|
||||
this.productNarrative = narrative;
|
||||
}
|
||||
}
|
||||
|
||||
public applyCancellationData(data: {
|
||||
cancelledFlag?: boolean;
|
||||
quantityCancelled?: number;
|
||||
@@ -154,6 +177,38 @@ export class ForecastProductController {
|
||||
return this.revenue - this.cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective Quantity
|
||||
*
|
||||
* Returns the quantity adjusted for cancellations (minimum 0).
|
||||
*/
|
||||
public get effectiveQuantity(): number {
|
||||
if (this.cancellationType === "full") return 0;
|
||||
return Math.max(0, this.quantity - this.quantityCancelled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective Revenue
|
||||
*
|
||||
* Returns the revenue adjusted proportionally for cancelled units.
|
||||
*/
|
||||
public get effectiveRevenue(): number {
|
||||
if (this.cancellationType === "full" || this.quantity <= 0) return 0;
|
||||
const unitPrice = this.revenue / this.quantity;
|
||||
return unitPrice * this.effectiveQuantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective Cost
|
||||
*
|
||||
* Returns the cost adjusted proportionally for cancelled units.
|
||||
*/
|
||||
public get effectiveCost(): number {
|
||||
if (this.cancellationType === "full" || this.quantity <= 0) return 0;
|
||||
const unitCost = this.cost / this.quantity;
|
||||
return unitCost * this.effectiveQuantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelled
|
||||
*
|
||||
@@ -201,12 +256,17 @@ export class ForecastProductController {
|
||||
? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier }
|
||||
: null,
|
||||
productDescription: this.productDescription,
|
||||
customerDescription: this.customerDescription,
|
||||
productNarrative: this.productNarrative,
|
||||
productClass: this.productClass,
|
||||
forecastType: this.forecastType,
|
||||
revenue: this.revenue,
|
||||
cost: this.cost,
|
||||
margin: this.margin,
|
||||
profit: this.profit,
|
||||
effectiveQuantity: this.effectiveQuantity,
|
||||
effectiveRevenue: this.effectiveRevenue,
|
||||
effectiveCost: this.effectiveCost,
|
||||
percentage: this.percentage,
|
||||
includeFlag: this.includeFlag,
|
||||
linkFlag: this.linkFlag,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
GeneratedQuotes,
|
||||
Opportunity,
|
||||
Role,
|
||||
User,
|
||||
} from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { OpportunityController } from "./OpportunityController";
|
||||
import UserController from "./UserController";
|
||||
|
||||
export class GeneratedQuoteController {
|
||||
public readonly id: string;
|
||||
|
||||
public quoteRegenData: unknown;
|
||||
public quoteRegenParams: unknown;
|
||||
public quoteRegenHash: string;
|
||||
|
||||
public downloads: unknown[];
|
||||
|
||||
public quoteFile: Uint8Array;
|
||||
public quoteFileName: string;
|
||||
|
||||
public opportunityId: string;
|
||||
public createdById: string | null;
|
||||
|
||||
public createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
private _opportunity: OpportunityController | null;
|
||||
private _createdBy: UserController | null;
|
||||
|
||||
constructor(
|
||||
data: GeneratedQuotes & {
|
||||
opportunity?: Opportunity | null;
|
||||
createdBy?: (User & { roles: Role[] }) | null;
|
||||
},
|
||||
) {
|
||||
this.id = data.id;
|
||||
|
||||
this.quoteRegenData = data.quoteRegenData;
|
||||
this.quoteRegenParams = data.quoteRegenParams;
|
||||
this.quoteRegenHash = data.quoteRegenHash;
|
||||
|
||||
this.downloads = Array.isArray(data.downloads)
|
||||
? (data.downloads as unknown[])
|
||||
: [];
|
||||
|
||||
this.quoteFile = data.quoteFile;
|
||||
this.quoteFileName = data.quoteFileName;
|
||||
|
||||
this.opportunityId = data.opportunityId;
|
||||
this.createdById = data.createdById;
|
||||
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
|
||||
this._opportunity = data.opportunity
|
||||
? new OpportunityController(data.opportunity)
|
||||
: null;
|
||||
|
||||
this._createdBy = data.createdBy
|
||||
? new UserController(data.createdBy)
|
||||
: null;
|
||||
}
|
||||
|
||||
public async fetchOpportunity(): Promise<OpportunityController | null> {
|
||||
if (this._opportunity) return this._opportunity;
|
||||
|
||||
const opportunity = await prisma.opportunity.findFirst({
|
||||
where: { id: this.opportunityId },
|
||||
});
|
||||
|
||||
if (!opportunity) return null;
|
||||
|
||||
this._opportunity = new OpportunityController(opportunity);
|
||||
return this._opportunity;
|
||||
}
|
||||
|
||||
public async fetchCreatedBy(): Promise<UserController | null> {
|
||||
if (this._createdBy) return this._createdBy;
|
||||
if (!this.createdById) return null;
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: this.createdById },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
this._createdBy = new UserController(user);
|
||||
return this._createdBy;
|
||||
}
|
||||
|
||||
public toJson(opts?: {
|
||||
includeFile?: boolean;
|
||||
encodeFileAsBase64?: boolean;
|
||||
includeRegenData?: boolean;
|
||||
includeRegenParams?: boolean;
|
||||
includeDownloads?: boolean;
|
||||
includeOpportunity?: boolean;
|
||||
includeCreatedBy?: boolean;
|
||||
}): Record<string, any> {
|
||||
return {
|
||||
id: this.id,
|
||||
quoteFileName: this.quoteFileName,
|
||||
quoteRegenHash: this.quoteRegenHash,
|
||||
opportunityId: this.opportunityId,
|
||||
createdById: this.createdById,
|
||||
downloads: opts?.includeDownloads ? this.downloads : undefined,
|
||||
quoteRegenData: opts?.includeRegenData ? this.quoteRegenData : undefined,
|
||||
quoteRegenParams: opts?.includeRegenParams
|
||||
? this.quoteRegenParams
|
||||
: undefined,
|
||||
quoteFile: !opts?.includeFile
|
||||
? undefined
|
||||
: opts?.encodeFileAsBase64
|
||||
? Buffer.from(this.quoteFile).toString("base64")
|
||||
: this.quoteFile,
|
||||
opportunity:
|
||||
opts?.includeOpportunity && this._opportunity
|
||||
? this._opportunity.toJson()
|
||||
: undefined,
|
||||
createdBy:
|
||||
opts?.includeCreatedBy && this._createdBy
|
||||
? this._createdBy.toJson()
|
||||
: undefined,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,17 +14,20 @@ import {
|
||||
CWForecastItemCreate,
|
||||
CWOpportunity,
|
||||
CWOpportunityNote,
|
||||
CWOpportunityUpdate,
|
||||
CWProcurementProduct,
|
||||
CWProcurementProductCreate,
|
||||
} from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
import {
|
||||
resolveMember,
|
||||
resolveMembers,
|
||||
getMemberCache,
|
||||
} from "../modules/cw-utils/members/memberCache";
|
||||
import { ForecastProductController } from "./ForecastProductController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { computeSubResourceCacheTTL } from "../modules/algorithms/computeSubResourceCacheTTL";
|
||||
import { computeProductsCacheTTL } from "../modules/algorithms/computeProductsCacheTTL";
|
||||
import UserController from "./UserController";
|
||||
import {
|
||||
getCachedNotes,
|
||||
getCachedContacts,
|
||||
@@ -37,6 +40,13 @@ import {
|
||||
invalidateNotesCache,
|
||||
invalidateProductsCache,
|
||||
} from "../modules/cache/opportunityCache";
|
||||
import {
|
||||
generateQuote as generateQuotePdf,
|
||||
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
|
||||
@@ -81,6 +91,7 @@ export class OpportunityController {
|
||||
public customerPO: string | null;
|
||||
|
||||
public totalSalesTax: number;
|
||||
public probability: number;
|
||||
|
||||
public locationName: string | null;
|
||||
public locationCwId: number | null;
|
||||
@@ -97,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.
|
||||
@@ -131,6 +143,29 @@ export class OpportunityController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve primary sales rep info for quote generation.
|
||||
*
|
||||
* Looks up the primary sales rep in the CW member cache and returns
|
||||
* their name and email. Returns undefined if no rep is assigned.
|
||||
*/
|
||||
private async _resolveSalesRep(): Promise<
|
||||
{ name: string; email?: string } | undefined
|
||||
> {
|
||||
if (!this.primarySalesRepIdentifier) return undefined;
|
||||
const cache = await getMemberCache();
|
||||
const member = cache.get(this.primarySalesRepIdentifier);
|
||||
const name = member
|
||||
? `${member.firstName} ${member.lastName}`.trim() ||
|
||||
this.primarySalesRepName ||
|
||||
this.primarySalesRepIdentifier
|
||||
: (this.primarySalesRepName ?? this.primarySalesRepIdentifier);
|
||||
return {
|
||||
name,
|
||||
email: member?.officeEmail ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
constructor(
|
||||
data: Opportunity & { company?: Company | null },
|
||||
opts?: {
|
||||
@@ -174,6 +209,7 @@ export class OpportunityController {
|
||||
this.customerPO = data.customerPO;
|
||||
|
||||
this.totalSalesTax = data.totalSalesTax;
|
||||
this.probability = data.probability;
|
||||
|
||||
this.locationName = data.locationName;
|
||||
this.locationCwId = data.locationCwId;
|
||||
@@ -190,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;
|
||||
@@ -203,6 +240,18 @@ export class OpportunityController {
|
||||
this._activities = opts?.activities ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate Custom Fields
|
||||
*
|
||||
* Lazily fetches the opportunity's custom fields from ConnectWise
|
||||
* if they haven't been loaded yet.
|
||||
*/
|
||||
private async _hydrateCustomFields(): Promise<void> {
|
||||
if (this._customFields !== null) return;
|
||||
const cwData = await fetchOpportunity(this.cwOpportunityId);
|
||||
this._customFields = cwData.customFields ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company
|
||||
*
|
||||
@@ -248,6 +297,30 @@ export class OpportunityController {
|
||||
return new OpportunityController(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Opportunity
|
||||
*
|
||||
* Patches the opportunity in ConnectWise with the provided fields,
|
||||
* then syncs the updated data back to the local database.
|
||||
*
|
||||
* @param data — Partial fields to update on the CW opportunity
|
||||
* @returns A fresh OpportunityController with the updated data
|
||||
*/
|
||||
public async updateOpportunity(
|
||||
data: CWOpportunityUpdate,
|
||||
): Promise<OpportunityController> {
|
||||
const cwData = await opportunityCw.update(this.cwOpportunityId, data);
|
||||
const mapped = OpportunityController.mapCwToDb(cwData);
|
||||
|
||||
const updated = await prisma.opportunity.update({
|
||||
where: { id: this.id },
|
||||
data: mapped,
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
return new OpportunityController(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw CW data
|
||||
*
|
||||
@@ -297,6 +370,7 @@ export class OpportunityController {
|
||||
customerPO: item.customerPO ?? null,
|
||||
|
||||
totalSalesTax: item.totalSalesTax ?? 0,
|
||||
probability: normalizeProbabilityPercent(item.probability?.name),
|
||||
|
||||
locationName: item.location?.name ?? null,
|
||||
locationCwId: item.location?.id ?? null,
|
||||
@@ -320,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -536,6 +613,397 @@ export class OpportunityController {
|
||||
return this._buildProductControllers(forecast, procProducts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Quote PDF
|
||||
*
|
||||
* Builds a customer-facing quote PDF using the opportunity, company, site,
|
||||
* and product data available to this controller.
|
||||
*/
|
||||
public async generateQuote(opts?: {
|
||||
lineItemPricing?: boolean;
|
||||
includeQuoteNarrative?: boolean;
|
||||
includeItemNarratives?: boolean;
|
||||
showPreview?: boolean; // INTERNAL ONLY
|
||||
logoPath?: string;
|
||||
metadata?: QuoteMetadata;
|
||||
}): Promise<Buffer> {
|
||||
const options = {
|
||||
lineItemPricing: opts?.lineItemPricing ?? true,
|
||||
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
|
||||
includeItemNarratives: opts?.includeItemNarratives ?? true,
|
||||
showPreview: opts?.showPreview ?? false,
|
||||
logoPath: opts?.logoPath,
|
||||
};
|
||||
|
||||
const products = await this.fetchProducts();
|
||||
const activeProducts = products.filter(
|
||||
(item) => item.includeFlag && item.cancellationType !== "full",
|
||||
);
|
||||
|
||||
if (activeProducts.length === 0) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "QuoteGenerationError",
|
||||
message: "Cannot generate a quote with no included line items",
|
||||
});
|
||||
}
|
||||
|
||||
const company = await this.fetchCompany();
|
||||
const companyJson = company?.toJson({
|
||||
includeAddress: true,
|
||||
includePrimaryContact: true,
|
||||
includeAllContacts: false,
|
||||
});
|
||||
const site = await this.fetchSite();
|
||||
|
||||
const siteAddress = [
|
||||
site?.address?.line1,
|
||||
site?.address?.line2,
|
||||
[site?.address?.city, site?.address?.state, site?.address?.zip]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const companyAddress = [
|
||||
companyJson?.cw_Data?.address?.line1,
|
||||
companyJson?.cw_Data?.address?.line2,
|
||||
[
|
||||
companyJson?.cw_Data?.address?.city,
|
||||
companyJson?.cw_Data?.address?.state,
|
||||
companyJson?.cw_Data?.address?.zip,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const addressLines = siteAddress.length > 0 ? siteAddress : companyAddress;
|
||||
|
||||
const lineItems = activeProducts.map((item) => {
|
||||
const isLabor = item.productClass === "Service";
|
||||
const quantity = item.effectiveQuantity > 0 ? item.effectiveQuantity : 1;
|
||||
const lineTotal = Number.isFinite(item.revenue) ? item.revenue : 0;
|
||||
const unitPrice = isLabor ? lineTotal : lineTotal / quantity;
|
||||
|
||||
const itemNarrative = item.productNarrative || null;
|
||||
|
||||
const shouldIncludeNarrative =
|
||||
options.includeItemNarratives && !!itemNarrative;
|
||||
|
||||
return {
|
||||
qty: isLabor ? 1 : quantity,
|
||||
description: item.productDescription || "Line Item",
|
||||
unitPrice,
|
||||
narrative: shouldIncludeNarrative ? itemNarrative : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const 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 = [
|
||||
companyJson?.cw_Data?.primaryContact?.firstName,
|
||||
companyJson?.cw_Data?.primaryContact?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
const customerName =
|
||||
this.contactName ||
|
||||
primaryContactFullName ||
|
||||
this.companyName ||
|
||||
"Customer";
|
||||
|
||||
const normalizedTaxRate = getExpectedSalesTaxRate(
|
||||
site?.address ?? companyJson?.cw_Data?.address,
|
||||
);
|
||||
const taxLabel =
|
||||
normalizedTaxRate > 0
|
||||
? `Sales Tax (${(normalizedTaxRate * 100).toFixed(2)}%)`
|
||||
: "Sales Tax";
|
||||
|
||||
await this._hydrateCustomFields();
|
||||
|
||||
const quoteNarrativeField = options.includeQuoteNarrative
|
||||
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
|
||||
undefined
|
||||
: undefined;
|
||||
|
||||
// Fall back to the customerDescription of a QUO-Narrative product
|
||||
const quoNarrativeProduct = !quoteNarrativeField
|
||||
? activeProducts.find((p) => p.catalogItemIdentifier === "QUO-Narrative")
|
||||
: undefined;
|
||||
const quoteNarrative =
|
||||
quoteNarrativeField ??
|
||||
quoNarrativeProduct?.customerDescription ??
|
||||
undefined;
|
||||
|
||||
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
|
||||
|
||||
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
|
||||
|
||||
// Only show attention if it differs from the customer name
|
||||
const attention =
|
||||
this.contactName && this.contactName !== customerName
|
||||
? this.contactName
|
||||
: undefined;
|
||||
|
||||
// Only show company if it's meaningfully different from the customer name
|
||||
// (catches "Patterson, Diane" vs "Diane Patterson" style duplicates)
|
||||
const normalise = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/[,.\s]+/g, " ")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.sort()
|
||||
.join(" ");
|
||||
const showCompany = normalise(companyLine) !== normalise(customerName);
|
||||
|
||||
return generateQuotePdf(
|
||||
{
|
||||
customer: {
|
||||
name: customerName,
|
||||
company: showCompany ? companyLine : undefined,
|
||||
attention,
|
||||
address:
|
||||
addressLines.length > 0 ? addressLines : ["Address unavailable"],
|
||||
},
|
||||
contact: {
|
||||
email: companyJson?.cw_Data?.primaryContact?.email ?? undefined,
|
||||
phone: companyJson?.cw_Data?.primaryContact?.phone ?? undefined,
|
||||
},
|
||||
salesRep: await this._resolveSalesRep(),
|
||||
quote: {
|
||||
quoteNumber: this.cwOpportunityId.toString(),
|
||||
date: new Date().toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
description: quoteDescription,
|
||||
},
|
||||
lineItems,
|
||||
taxableSubtotal: taxableSubTotal,
|
||||
quoteNarrative,
|
||||
tax: {
|
||||
rate: normalizedTaxRate,
|
||||
label: taxLabel,
|
||||
},
|
||||
isPreview: options.showPreview,
|
||||
showLineItemPricing: options.lineItemPricing,
|
||||
metadata: opts?.metadata,
|
||||
},
|
||||
{},
|
||||
options.logoPath,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit Quote
|
||||
*
|
||||
* Generates a non-preview quote PDF and stores it in the GeneratedQuotes
|
||||
* table with a full data snapshot for exact reproduction, regeneration
|
||||
* metadata, and creator attribution.
|
||||
*/
|
||||
public async commitQuote(
|
||||
opts: {
|
||||
lineItemPricing?: boolean;
|
||||
includeQuoteNarrative?: boolean;
|
||||
includeItemNarratives?: boolean;
|
||||
logoPath?: string;
|
||||
} = {},
|
||||
user: UserController,
|
||||
) {
|
||||
const quoteOptions = {
|
||||
lineItemPricing: opts?.lineItemPricing ?? true,
|
||||
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
|
||||
includeItemNarratives: opts?.includeItemNarratives ?? true,
|
||||
logoPath: opts?.logoPath,
|
||||
};
|
||||
|
||||
// ── Fetch all data sources BEFORE generating ──────────────────────
|
||||
const products = await this.fetchProducts();
|
||||
const company = await this.fetchCompany();
|
||||
const companyJson = company?.toJson({
|
||||
includeAddress: true,
|
||||
includePrimaryContact: true,
|
||||
includeAllContacts: false,
|
||||
});
|
||||
const site = await this.fetchSite();
|
||||
const salesRep = await this._resolveSalesRep();
|
||||
await this._hydrateCustomFields();
|
||||
|
||||
const quoteNarrativeField = quoteOptions.includeQuoteNarrative
|
||||
? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ??
|
||||
null)
|
||||
: null;
|
||||
|
||||
// Fall back to the customerDescription of a QUO-Narrative product
|
||||
const quoNarrativeProduct = !quoteNarrativeField
|
||||
? products.find((p) => p.catalogItemIdentifier === "QUO-Narrative")
|
||||
: undefined;
|
||||
const quoteNarrative =
|
||||
quoteNarrativeField ?? quoNarrativeProduct?.customerDescription ?? null;
|
||||
|
||||
// ── Pre-generate IDs & timestamps for metadata ───────────────────
|
||||
const quoteId = crypto.randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
// ── Generate the PDF ──────────────────────────────────────────────
|
||||
const quoteBuffer = await this.generateQuote({
|
||||
...quoteOptions,
|
||||
showPreview: false,
|
||||
metadata: {
|
||||
quoteId,
|
||||
createdById: user.id,
|
||||
createdByName: user.name ?? undefined,
|
||||
createdByEmail: user.email ?? undefined,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
|
||||
const fileTimestamp = createdAt.replace(/[:.]/g, "-");
|
||||
const quoteFileName = `OPP-${this.cwOpportunityId}-${fileTimestamp}.pdf`;
|
||||
|
||||
// ── Build the full data snapshot ──────────────────────────────────
|
||||
const siteAddress = [
|
||||
site?.address?.line1,
|
||||
site?.address?.line2,
|
||||
[site?.address?.city, site?.address?.state, site?.address?.zip]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const companyAddress = [
|
||||
companyJson?.cw_Data?.address?.line1,
|
||||
companyJson?.cw_Data?.address?.line2,
|
||||
[
|
||||
companyJson?.cw_Data?.address?.city,
|
||||
companyJson?.cw_Data?.address?.state,
|
||||
companyJson?.cw_Data?.address?.zip,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const primaryContactFullName = [
|
||||
companyJson?.cw_Data?.primaryContact?.firstName,
|
||||
companyJson?.cw_Data?.primaryContact?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
const regenData = {
|
||||
// Generation options
|
||||
options: {
|
||||
lineItemPricing: quoteOptions.lineItemPricing,
|
||||
includeQuoteNarrative: quoteOptions.includeQuoteNarrative,
|
||||
includeItemNarratives: quoteOptions.includeItemNarratives,
|
||||
},
|
||||
|
||||
// Opportunity metadata
|
||||
opportunity: {
|
||||
id: this.id,
|
||||
cwOpportunityId: this.cwOpportunityId,
|
||||
name: this.name,
|
||||
totalSalesTax: this.totalSalesTax,
|
||||
contactName: this.contactName,
|
||||
companyName: this.companyName,
|
||||
},
|
||||
|
||||
// Customer / company / site snapshot
|
||||
customer: {
|
||||
preparedFor:
|
||||
this.contactName ||
|
||||
primaryContactFullName ||
|
||||
this.companyName ||
|
||||
"Customer",
|
||||
companyName: this.companyName ?? company?.name ?? null,
|
||||
primaryContact: companyJson?.cw_Data?.primaryContact
|
||||
? {
|
||||
firstName: companyJson.cw_Data.primaryContact.firstName ?? null,
|
||||
lastName: companyJson.cw_Data.primaryContact.lastName ?? null,
|
||||
email: companyJson.cw_Data.primaryContact.email ?? null,
|
||||
phone: companyJson.cw_Data.primaryContact.phone ?? null,
|
||||
}
|
||||
: null,
|
||||
siteAddress: siteAddress.length > 0 ? siteAddress : null,
|
||||
companyAddress: companyAddress.length > 0 ? companyAddress : null,
|
||||
},
|
||||
|
||||
// Sales rep snapshot
|
||||
salesRep: salesRep ?? null,
|
||||
|
||||
// Quote narrative
|
||||
quoteNarrative: quoteNarrative ?? null,
|
||||
|
||||
// Full product snapshot
|
||||
products: products.map((p) => ({
|
||||
cwForecastId: p.cwForecastId,
|
||||
forecastDescription: p.forecastDescription,
|
||||
productDescription: p.productDescription,
|
||||
customerDescription: p.customerDescription,
|
||||
productNarrative: p.productNarrative,
|
||||
productClass: p.productClass,
|
||||
forecastType: p.forecastType,
|
||||
catalogItem: p.catalogItemCwId
|
||||
? { id: p.catalogItemCwId, identifier: p.catalogItemIdentifier }
|
||||
: null,
|
||||
quantity: p.quantity,
|
||||
effectiveQuantity: p.effectiveQuantity,
|
||||
revenue: p.revenue,
|
||||
cost: p.cost,
|
||||
margin: p.margin,
|
||||
percentage: p.percentage,
|
||||
includeFlag: p.includeFlag,
|
||||
taxableFlag: p.taxableFlag,
|
||||
recurringFlag: p.recurringFlag,
|
||||
recurringRevenue: p.recurringRevenue,
|
||||
recurringCost: p.recurringCost,
|
||||
sequenceNumber: p.sequenceNumber,
|
||||
cancelledFlag: p.cancelledFlag,
|
||||
cancellationType: p.cancellationType,
|
||||
quantityCancelled: p.quantityCancelled,
|
||||
cancelledReason: p.cancelledReason,
|
||||
cancelledDate: p.cancelledDate,
|
||||
})),
|
||||
|
||||
// Timestamp of when this snapshot was taken
|
||||
snapshotTimestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const regenParams = {
|
||||
opportunityId: this.id,
|
||||
cwOpportunityId: this.cwOpportunityId,
|
||||
};
|
||||
|
||||
const hasher = new Bun.CryptoHasher("sha256");
|
||||
hasher.update(JSON.stringify({ regenData, regenParams }));
|
||||
const quoteRegenHash = hasher.digest("hex");
|
||||
|
||||
return generatedQuotes.create({
|
||||
id: quoteId,
|
||||
quoteRegenData: regenData,
|
||||
quoteRegenParams: regenParams,
|
||||
quoteRegenHash,
|
||||
quoteFile: quoteBuffer,
|
||||
quoteFileName,
|
||||
opportunityId: this.id,
|
||||
createdById: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ForecastProductController[] from raw CW data.
|
||||
*
|
||||
@@ -593,6 +1061,7 @@ export class OpportunityController {
|
||||
const procData = cancellationMap.get(item.id);
|
||||
if (procData) {
|
||||
ctrl.applyCancellationData(procData as any);
|
||||
ctrl.applyProcurementCustomFields(procData as any);
|
||||
}
|
||||
return ctrl;
|
||||
},
|
||||
@@ -782,6 +1251,41 @@ export class OpportunityController {
|
||||
return this.fetchProducts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append Product Sequence IDs
|
||||
*
|
||||
* Adds newly created forecast item IDs to the end of the local
|
||||
* productSequence array, preserving existing order and avoiding duplicates.
|
||||
*/
|
||||
private async appendProductSequenceIds(ids: number[]): Promise<void> {
|
||||
const normalizedIds = ids.filter(
|
||||
(id): id is number => Number.isInteger(id) && id > 0,
|
||||
);
|
||||
if (normalizedIds.length === 0) return;
|
||||
|
||||
const current = await prisma.opportunity.findUnique({
|
||||
where: { id: this.id },
|
||||
select: { productSequence: true },
|
||||
});
|
||||
|
||||
const existing = current?.productSequence ?? [];
|
||||
const existingSet = new Set(existing);
|
||||
const idsToAppend = normalizedIds.filter((id) => !existingSet.has(id));
|
||||
if (idsToAppend.length === 0) {
|
||||
this.productSequence = existing;
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSequence = [...existing, ...idsToAppend];
|
||||
|
||||
await prisma.opportunity.update({
|
||||
where: { id: this.id },
|
||||
data: { productSequence: updatedSequence },
|
||||
});
|
||||
|
||||
this.productSequence = updatedSequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Products
|
||||
*
|
||||
@@ -800,6 +1304,7 @@ export class OpportunityController {
|
||||
this.cwOpportunityId,
|
||||
data,
|
||||
);
|
||||
await this.appendProductSequenceIds(created.map((item) => item.id));
|
||||
await invalidateProductsCache(this.cwOpportunityId);
|
||||
return created.map((item) => new ForecastProductController(item));
|
||||
} catch (err: any) {
|
||||
@@ -845,6 +1350,11 @@ export class OpportunityController {
|
||||
}));
|
||||
|
||||
const created = await opportunityCw.createProcurementProducts(normalized);
|
||||
await this.appendProductSequenceIds(
|
||||
created
|
||||
.map((item) => item.forecastDetailId)
|
||||
.filter((id): id is number => typeof id === "number"),
|
||||
);
|
||||
await invalidateProductsCache(this.cwOpportunityId);
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
@@ -873,6 +1383,120 @@ export class OpportunityController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Product
|
||||
*
|
||||
* Removes a forecast item from this opportunity in ConnectWise,
|
||||
* removes the item ID from the local productSequence, and
|
||||
* invalidates the products cache.
|
||||
*
|
||||
* @param forecastItemId - The CW forecast item ID to delete
|
||||
*/
|
||||
public async deleteProduct(forecastItemId: number): Promise<void> {
|
||||
await opportunityCw.deleteProduct(this.cwOpportunityId, forecastItemId);
|
||||
|
||||
// Remove the deleted item from the local product sequence
|
||||
if (this.productSequence.includes(forecastItemId)) {
|
||||
const updatedSequence = this.productSequence.filter(
|
||||
(id) => id !== forecastItemId,
|
||||
);
|
||||
|
||||
await prisma.opportunity.update({
|
||||
where: { id: this.id },
|
||||
data: { productSequence: updatedSequence },
|
||||
});
|
||||
|
||||
this.productSequence = updatedSequence;
|
||||
}
|
||||
|
||||
await invalidateProductsCache(this.cwOpportunityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Procurement Product By Forecast Item
|
||||
*
|
||||
* Returns the linked procurement product for a forecast item ID,
|
||||
* or null when no procurement record exists.
|
||||
*/
|
||||
public async fetchProcurementProductByForecastItem(
|
||||
forecastItemId: number,
|
||||
): Promise<CWProcurementProduct | null> {
|
||||
return opportunityCw.fetchProcurementProductByForecastDetail(
|
||||
this.cwOpportunityId,
|
||||
forecastItemId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Procurement Product By Forecast Item
|
||||
*
|
||||
* Finds the linked procurement product for a forecast item and updates it.
|
||||
* Returns null when no linked procurement product exists.
|
||||
*/
|
||||
public async updateProcurementProductByForecastItem(
|
||||
forecastItemId: number,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<CWProcurementProduct | null> {
|
||||
const linked =
|
||||
await this.fetchProcurementProductByForecastItem(forecastItemId);
|
||||
if (!linked?.id) return null;
|
||||
|
||||
const updated = await opportunityCw.updateProcurementProduct(
|
||||
linked.id,
|
||||
data,
|
||||
);
|
||||
await invalidateProductsCache(this.cwOpportunityId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Product Cancellation
|
||||
*
|
||||
* Updates cancellation fields on the procurement product linked to a
|
||||
* forecast item. A quantity of 0 is treated as uncancelled.
|
||||
*/
|
||||
public async setProductCancellation(
|
||||
forecastItemId: number,
|
||||
opts: { quantityCancelled: number; cancellationReason?: string | null },
|
||||
): Promise<CWProcurementProduct> {
|
||||
const linked =
|
||||
await this.fetchProcurementProductByForecastItem(forecastItemId);
|
||||
|
||||
if (!linked?.id) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ProcurementProductNotFound",
|
||||
message:
|
||||
"No linked procurement product found for the specified forecast item",
|
||||
});
|
||||
}
|
||||
|
||||
const quantityCancelled = Math.max(0, Math.trunc(opts.quantityCancelled));
|
||||
const cancelledFlag = quantityCancelled > 0;
|
||||
|
||||
const updated = await this.updateProcurementProductByForecastItem(
|
||||
forecastItemId,
|
||||
{
|
||||
quantityCancelled,
|
||||
cancelledFlag,
|
||||
cancelledReason: cancelledFlag
|
||||
? (opts.cancellationReason ?? null)
|
||||
: null,
|
||||
},
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ProcurementProductNotFound",
|
||||
message:
|
||||
"No linked procurement product found for the specified forecast item",
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Note
|
||||
*
|
||||
@@ -934,11 +1558,26 @@ export class OpportunityController {
|
||||
* Serializes the opportunity into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
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,
|
||||
name: this.name,
|
||||
notes: this.notes,
|
||||
description: this.notes,
|
||||
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
|
||||
stage: this.stageCwId
|
||||
? { id: this.stageCwId, name: this.stageName }
|
||||
@@ -989,6 +1628,8 @@ export class OpportunityController {
|
||||
: null,
|
||||
customerPO: this.customerPO,
|
||||
totalSalesTax: this.totalSalesTax,
|
||||
expectedSalesTax,
|
||||
probability: this.probability,
|
||||
location: this.locationCwId
|
||||
? { id: this.locationCwId, name: this.locationName }
|
||||
: null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { refresh } from "./api/auth";
|
||||
import app from "./api/server";
|
||||
import { setupSockets } from "./api/sockets";
|
||||
import {
|
||||
engine,
|
||||
PORT,
|
||||
@@ -15,7 +16,9 @@ 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";
|
||||
import { events, setupEventDebugger } from "./modules/globalEvents";
|
||||
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
||||
@@ -68,6 +71,9 @@ Bun.serve({
|
||||
|
||||
console.log(`[startup] Server listening on port ${PORT}`);
|
||||
|
||||
setupSockets();
|
||||
console.log("[startup] Socket namespaces initialized");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background initialisation — none of this blocks the server.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -173,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(
|
||||
@@ -184,6 +206,17 @@ setInterval(
|
||||
30 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Refresh CW members DB table every hour
|
||||
await safeStartup("refreshCwMembers", refreshCwMembers);
|
||||
setInterval(
|
||||
() => {
|
||||
return refreshCwMembers().catch((err) =>
|
||||
console.error(`[interval] refreshCwMembers failed: ${briefErr(err)}`),
|
||||
);
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
|
||||
await safeStartup("syncSites", () => unifiSites.syncSites());
|
||||
setInterval(() => {
|
||||
return unifiSites
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { prisma } from "../constants";
|
||||
import { CwMemberController } from "../controllers/CwMemberController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
|
||||
/**
|
||||
* CW Members Manager
|
||||
*
|
||||
* Thin persistence layer wrapping Prisma calls for the CwMember model.
|
||||
* Returns CwMemberController instances as domain objects.
|
||||
*/
|
||||
export const cwMembers = {
|
||||
/**
|
||||
* Fetch a single CW member by internal ID, CW member ID, or identifier.
|
||||
*/
|
||||
fetch: async (idOrIdentifier: string): Promise<CwMemberController> => {
|
||||
const isNumeric = /^\d+$/.test(idOrIdentifier);
|
||||
|
||||
const record = await prisma.cwMember.findFirst({
|
||||
where: isNumeric
|
||||
? { cwMemberId: Number(idOrIdentifier) }
|
||||
: {
|
||||
OR: [{ id: idOrIdentifier }, { identifier: idOrIdentifier }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "CwMemberNotFound",
|
||||
message: `CW Member "${idOrIdentifier}" not found`,
|
||||
});
|
||||
}
|
||||
|
||||
return new CwMemberController(record);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all CW members with optional filtering.
|
||||
*/
|
||||
fetchAll: async (opts?: {
|
||||
includeInactive?: boolean;
|
||||
}): Promise<CwMemberController[]> => {
|
||||
const where = opts?.includeInactive ? {} : { inactiveFlag: false };
|
||||
|
||||
const records = await prisma.cwMember.findMany({
|
||||
where,
|
||||
orderBy: { lastName: "asc" },
|
||||
});
|
||||
|
||||
return records.map((r) => new CwMemberController(r));
|
||||
},
|
||||
|
||||
/**
|
||||
* Count CW members.
|
||||
*/
|
||||
count: async (opts?: { includeInactive?: boolean }): Promise<number> => {
|
||||
const where = opts?.includeInactive ? {} : { inactiveFlag: false };
|
||||
return prisma.cwMember.count({ where });
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the API key for a CW member.
|
||||
*/
|
||||
updateApiKey: async (
|
||||
idOrIdentifier: string,
|
||||
apiKey: string | null,
|
||||
): Promise<CwMemberController> => {
|
||||
const member = await cwMembers.fetch(idOrIdentifier);
|
||||
|
||||
const updated = await prisma.cwMember.update({
|
||||
where: { id: member.id },
|
||||
data: { apiKey },
|
||||
});
|
||||
|
||||
return new CwMemberController(updated);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { prisma } from "../constants";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { GeneratedQuoteController } from "../controllers/GeneratedQuoteController";
|
||||
|
||||
const generatedQuoteInclude = {
|
||||
opportunity: true,
|
||||
createdBy: {
|
||||
include: {
|
||||
roles: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const generatedQuotes = {
|
||||
async fetch(id: string): Promise<GeneratedQuoteController> {
|
||||
const quote = await prisma.generatedQuotes.findFirst({
|
||||
where: { id },
|
||||
include: generatedQuoteInclude,
|
||||
});
|
||||
|
||||
if (!quote) {
|
||||
throw new GenericError({
|
||||
message: "Generated quote not found",
|
||||
name: "GeneratedQuoteNotFound",
|
||||
cause: `No generated quote exists with ID '${id}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return new GeneratedQuoteController(quote);
|
||||
},
|
||||
|
||||
async fetchByOpportunity(
|
||||
opportunityId: string,
|
||||
): Promise<GeneratedQuoteController[]> {
|
||||
const rows = await prisma.generatedQuotes.findMany({
|
||||
where: { opportunityId },
|
||||
include: generatedQuoteInclude,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return rows.map((row) => new GeneratedQuoteController(row));
|
||||
},
|
||||
|
||||
async fetchByCreator(
|
||||
createdById: string,
|
||||
): Promise<GeneratedQuoteController[]> {
|
||||
const rows = await prisma.generatedQuotes.findMany({
|
||||
where: { createdById },
|
||||
include: generatedQuoteInclude,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return rows.map((row) => new GeneratedQuoteController(row));
|
||||
},
|
||||
|
||||
async fetchByHash(
|
||||
quoteRegenHash: string,
|
||||
): Promise<GeneratedQuoteController | null> {
|
||||
const quote = await prisma.generatedQuotes.findUnique({
|
||||
where: { quoteRegenHash },
|
||||
include: generatedQuoteInclude,
|
||||
});
|
||||
|
||||
return quote ? new GeneratedQuoteController(quote) : null;
|
||||
},
|
||||
|
||||
async create(data: {
|
||||
id?: string;
|
||||
quoteRegenData: unknown;
|
||||
quoteRegenParams: unknown;
|
||||
quoteRegenHash: string;
|
||||
quoteFile: Buffer | Uint8Array;
|
||||
quoteFileName: string;
|
||||
opportunityId: string;
|
||||
createdById: string;
|
||||
}): Promise<GeneratedQuoteController> {
|
||||
const opportunity = await prisma.opportunity.findFirst({
|
||||
where: { id: data.opportunityId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!opportunity) {
|
||||
throw new GenericError({
|
||||
message: "Opportunity not found",
|
||||
name: "OpportunityNotFound",
|
||||
cause: `No opportunity exists with ID '${data.opportunityId}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const createdBy = await prisma.user.findFirst({
|
||||
where: { id: data.createdById },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!createdBy) {
|
||||
throw new GenericError({
|
||||
message: "User not found",
|
||||
name: "UserNotFound",
|
||||
cause: `No user exists with ID '${data.createdById}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const quote = await prisma.generatedQuotes.create({
|
||||
data: {
|
||||
...(data.id ? { id: data.id } : {}),
|
||||
quoteRegenData: data.quoteRegenData as any,
|
||||
quoteRegenParams: data.quoteRegenParams as any,
|
||||
quoteRegenHash: data.quoteRegenHash,
|
||||
quoteFile: Buffer.from(data.quoteFile),
|
||||
quoteFileName: data.quoteFileName,
|
||||
opportunityId: data.opportunityId,
|
||||
createdById: data.createdById,
|
||||
},
|
||||
include: generatedQuoteInclude,
|
||||
});
|
||||
|
||||
return new GeneratedQuoteController(quote);
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const existing = await prisma.generatedQuotes.findFirst({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new GenericError({
|
||||
message: "Generated quote not found",
|
||||
name: "GeneratedQuoteNotFound",
|
||||
cause: `No generated quote exists with ID '${id}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.generatedQuotes.delete({
|
||||
where: { id },
|
||||
});
|
||||
},
|
||||
|
||||
async recordDownload(
|
||||
id: string,
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email: string;
|
||||
fetchAction: string;
|
||||
},
|
||||
): Promise<GeneratedQuoteController> {
|
||||
const existing = await prisma.generatedQuotes.findFirst({
|
||||
where: { id },
|
||||
select: { id: true, downloads: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new GenericError({
|
||||
message: "Generated quote not found",
|
||||
name: "GeneratedQuoteNotFound",
|
||||
cause: `No generated quote exists with ID '${id}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const currentDownloads = Array.isArray(existing.downloads)
|
||||
? (existing.downloads as unknown[])
|
||||
: [];
|
||||
|
||||
const downloadRecord = {
|
||||
downloadedAt: new Date().toISOString(),
|
||||
fetchAction: user.fetchAction,
|
||||
userId: user.id,
|
||||
userName: user.name ?? null,
|
||||
userEmail: user.email,
|
||||
};
|
||||
|
||||
const updated = await prisma.generatedQuotes.update({
|
||||
where: { id },
|
||||
data: {
|
||||
downloads: [...currentDownloads, downloadRecord] as any,
|
||||
},
|
||||
include: generatedQuoteInclude,
|
||||
});
|
||||
|
||||
return new GeneratedQuoteController(updated);
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { OpportunityController } from "../controllers/OpportunityController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
||||
import { CWOpportunityCreate } from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
|
||||
import {
|
||||
getCachedActivities,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
fetchAndCacheActivities,
|
||||
fetchAndCacheCompanyCwData,
|
||||
fetchAndCacheOppCwData,
|
||||
invalidateAllOpportunityCaches,
|
||||
} from "../modules/cache/opportunityCache";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -127,6 +129,45 @@ async function buildActivities(
|
||||
}
|
||||
|
||||
export const opportunities = {
|
||||
/**
|
||||
* Create Opportunity
|
||||
*
|
||||
* Creates a new opportunity in ConnectWise, then stores the resulting
|
||||
* record in the local database and returns an OpportunityController.
|
||||
*
|
||||
* @param data — Fields required by the ConnectWise `POST /sales/opportunities` endpoint
|
||||
* @returns {Promise<OpportunityController>}
|
||||
*/
|
||||
async createItem(data: CWOpportunityCreate): Promise<OpportunityController> {
|
||||
const cwData = await opportunityCw.create(data);
|
||||
const mapped = OpportunityController.mapCwToDb(cwData);
|
||||
|
||||
// Resolve optional local company relation
|
||||
const companyId = cwData.company?.id
|
||||
? ((
|
||||
await prisma.company.findFirst({
|
||||
where: { cw_CompanyId: cwData.company.id },
|
||||
select: { id: true },
|
||||
})
|
||||
)?.id ?? null)
|
||||
: null;
|
||||
|
||||
const record = await prisma.opportunity.create({
|
||||
data: {
|
||||
cwOpportunityId: cwData.id,
|
||||
...mapped,
|
||||
companyId,
|
||||
},
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
return new OpportunityController(record, {
|
||||
company: record.company
|
||||
? new CompanyController(record.company)
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Record (lightweight)
|
||||
*
|
||||
@@ -310,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,
|
||||
@@ -360,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" } },
|
||||
@@ -377,7 +418,7 @@ export const opportunities = {
|
||||
include: { company: true },
|
||||
skip,
|
||||
take: rpp,
|
||||
orderBy: { expectedCloseDate: "asc" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
@@ -404,7 +445,7 @@ export const opportunities = {
|
||||
*/
|
||||
async count(opts?: { openOnly?: boolean }): Promise<number> {
|
||||
return prisma.opportunity.count({
|
||||
where: opts?.openOnly ? { closedFlag: false } : undefined,
|
||||
where: opts?.openOnly ? { closedDate: null } : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -428,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" } },
|
||||
@@ -463,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" },
|
||||
@@ -484,4 +525,104 @@ 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<OpportunityController[]>}
|
||||
*/
|
||||
async fetchByUser(
|
||||
userId: string,
|
||||
opts?: { includeClosed?: boolean },
|
||||
): Promise<OpportunityController[]> {
|
||||
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
|
||||
*
|
||||
* Deletes an opportunity from ConnectWise, removes the local database
|
||||
* record, and invalidates all related Redis caches.
|
||||
*
|
||||
* @param identifier - Internal ID (string) or CW opportunity ID (number)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteItem(identifier: string | number): Promise<void> {
|
||||
const isNumeric =
|
||||
typeof identifier === "number" || /^\d+$/.test(String(identifier));
|
||||
|
||||
const record = await prisma.opportunity.findFirst({
|
||||
where: isNumeric
|
||||
? { cwOpportunityId: Number(identifier) }
|
||||
: { id: identifier as string },
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new GenericError({
|
||||
message: "Opportunity not found",
|
||||
name: "OpportunityNotFound",
|
||||
cause: `No opportunity exists with identifier '${identifier}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete from ConnectWise first
|
||||
await opportunityCw.delete(record.cwOpportunityId);
|
||||
|
||||
// Remove the local DB record
|
||||
await prisma.opportunity.delete({ where: { id: record.id } });
|
||||
|
||||
// Invalidate all related caches
|
||||
await invalidateAllOpportunityCaches(record.cwOpportunityId);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,61 @@ const catalogItemInclude = {
|
||||
linkedItems: true,
|
||||
} as const;
|
||||
|
||||
const LABOR_STYLE_CANDIDATES = {
|
||||
field: ["LABOR & INSTALLATION - FIELD", "LABOR - FIELD", "LABOR FIELD"],
|
||||
tech: ["LABOR & INSTALLATION - TECH", "LABOR - TECH", "LABOR TECH"],
|
||||
} as const;
|
||||
|
||||
async function findCatalogByExactCandidates(
|
||||
candidates: readonly string[],
|
||||
): Promise<CatalogItemController | null> {
|
||||
for (const candidate of candidates) {
|
||||
const item = await prisma.catalogItem.findFirst({
|
||||
where: {
|
||||
inactive: false,
|
||||
OR: [
|
||||
{ identifier: { equals: candidate, mode: "insensitive" } },
|
||||
{ name: { equals: candidate, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
include: catalogItemInclude,
|
||||
});
|
||||
|
||||
if (item) return new CatalogItemController(item);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findCatalogByLaborStyle(
|
||||
style: "field" | "tech",
|
||||
): Promise<CatalogItemController | null> {
|
||||
const fallback = await prisma.catalogItem.findFirst({
|
||||
where: {
|
||||
inactive: false,
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ identifier: { contains: "labor", mode: "insensitive" } },
|
||||
{ name: { contains: "labor", mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{ identifier: { contains: style, mode: "insensitive" } },
|
||||
{ name: { contains: style, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
include: catalogItemInclude,
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
if (!fallback) return null;
|
||||
return new CatalogItemController(fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter options for catalog item queries.
|
||||
*/
|
||||
@@ -204,6 +259,36 @@ export const procurement = {
|
||||
return new CatalogItemController(item);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Labor Catalog Items
|
||||
*
|
||||
* Resolves canonical Field and Tech labor products from the local catalog.
|
||||
* Prefers exact identifier/name matches, then falls back to keyword matching.
|
||||
*/
|
||||
async fetchLaborCatalogItems(): Promise<{
|
||||
field: CatalogItemController;
|
||||
tech: CatalogItemController;
|
||||
}> {
|
||||
const fieldItem =
|
||||
(await findCatalogByExactCandidates(LABOR_STYLE_CANDIDATES.field)) ??
|
||||
(await findCatalogByLaborStyle("field"));
|
||||
const techItem =
|
||||
(await findCatalogByExactCandidates(LABOR_STYLE_CANDIDATES.tech)) ??
|
||||
(await findCatalogByLaborStyle("tech"));
|
||||
|
||||
if (!fieldItem || !techItem) {
|
||||
throw new GenericError({
|
||||
message: "Labor catalog products are not configured",
|
||||
name: "LaborCatalogProductsNotFound",
|
||||
cause:
|
||||
"Expected active FIELD and TECH labor catalog items in the local catalog",
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
return { field: fieldItem, tech: techItem };
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Catalog Items (Paginated)
|
||||
*
|
||||
|
||||
@@ -99,7 +99,7 @@ export const users = {
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
userId: msData.id,
|
||||
email: msData.mail,
|
||||
email: msData.mail ?? msData.userPrincipalName,
|
||||
name: `${msData.givenName} ${msData.surname}`,
|
||||
login: msData.userPrincipalName,
|
||||
cwIdentifier,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @module algo.coldThreshold
|
||||
*
|
||||
* Cold-Detection Algorithm
|
||||
* ========================
|
||||
*
|
||||
* Determines whether an opportunity has stalled in a status long enough
|
||||
* to be considered "cold". When an opportunity goes cold it is
|
||||
* automatically moved to InternalReview, a system-generated activity is
|
||||
* logged, and it is flagged for the internal review report.
|
||||
*
|
||||
* ## Thresholds (defaults)
|
||||
*
|
||||
* | Status | Stall Threshold |
|
||||
* |-----------------|-----------------|
|
||||
* | QuoteSent | 14 days |
|
||||
* | ConfirmedQuote | 30 days |
|
||||
*
|
||||
* Only these two statuses are eligible for cold detection. All other
|
||||
* statuses return `cold: false`.
|
||||
*
|
||||
* ## How "last activity date" is determined
|
||||
*
|
||||
* The algorithm uses `lastActivityDate` — the most recent of:
|
||||
* - the latest activity's `dateStart`
|
||||
* - the opportunity's `cwLastUpdated`
|
||||
*
|
||||
* The caller is responsible for resolving this value before calling
|
||||
* `checkColdStatus`.
|
||||
*/
|
||||
|
||||
import type { OpportunityController } from "../../controllers/OpportunityController";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Stall thresholds in milliseconds, keyed by CW status ID. */
|
||||
export const COLD_THRESHOLDS: Record<number, { days: number; ms: number }> = {
|
||||
/** QuoteSent — CW status ID 43, "03. Quote Sent" */
|
||||
43: { days: 14, ms: 14 * 24 * 60 * 60 * 1000 },
|
||||
|
||||
/** ConfirmedQuote — CW status ID 57, "04. Confirmed Quote" */
|
||||
57: { days: 30, ms: 30 * 24 * 60 * 60 * 1000 },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ColdCheckInput {
|
||||
/** Current CW status ID of the opportunity. */
|
||||
statusCwId: number | null;
|
||||
|
||||
/**
|
||||
* The most recent meaningful date to measure staleness from.
|
||||
* Typically the latest of the last activity dateStart or cwLastUpdated.
|
||||
*/
|
||||
lastActivityDate: Date | null;
|
||||
|
||||
/** Override for "now" — useful for testing. Defaults to `new Date()`. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface ColdCheckResult {
|
||||
/** Whether the opportunity is considered cold. */
|
||||
cold: boolean;
|
||||
|
||||
/**
|
||||
* Which threshold triggered the cold flag.
|
||||
* `null` when `cold` is `false`.
|
||||
*/
|
||||
triggeredBy: {
|
||||
statusCwId: number;
|
||||
statusName: string;
|
||||
thresholdDays: number;
|
||||
staleDays: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATUS_NAMES: Record<number, string> = {
|
||||
43: "QuoteSent",
|
||||
57: "ConfirmedQuote",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Evaluate whether an opportunity has exceeded its cold-stall threshold.
|
||||
*
|
||||
* @returns A `ColdCheckResult` indicating cold status and trigger metadata.
|
||||
*/
|
||||
export function checkColdStatus(_input: ColdCheckInput): ColdCheckResult {
|
||||
// Bypassed — always returns not-cold until cold-stall feature is ready
|
||||
return { cold: false, triggeredBy: null };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @module algo.followUpScheduler
|
||||
*
|
||||
* Follow-Up Scheduling Algorithm
|
||||
* ===============================
|
||||
*
|
||||
* Determines the due date for follow-up activities created by the
|
||||
* opportunity workflow. The follow-up is always assigned to the user
|
||||
* who triggered its creation.
|
||||
*
|
||||
* ## TODO — Calendar-aware scheduling
|
||||
*
|
||||
* This module currently uses a **dummy algorithm** that schedules the
|
||||
* follow-up for the next business day at 10:00 AM local time.
|
||||
*
|
||||
* It needs to be replaced with an availability-aware algorithm that:
|
||||
* 1. Reads the assigned user's calendar (Microsoft Graph / CW schedule).
|
||||
* 2. Finds the earliest open slot of sufficient duration.
|
||||
* 3. Respects company-wide blackout dates (holidays, company events).
|
||||
* 4. Accounts for the user's working-hours preferences.
|
||||
*
|
||||
* Until that integration is complete, the simple "next business day"
|
||||
* heuristic is used as a placeholder.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FollowUpScheduleInput {
|
||||
/** The user who triggered the activity (follow-up is assigned to them). */
|
||||
triggeredByUserId: string;
|
||||
|
||||
/** Optional override for "now" — useful for testing. */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface FollowUpScheduleResult {
|
||||
/** Suggested due date for the follow-up activity. */
|
||||
dueDate: Date;
|
||||
|
||||
/** ISO string version for CW API payloads. */
|
||||
dueDateIso: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Schedule a follow-up activity.
|
||||
*
|
||||
* Returns a suggested `dueDate` for the follow-up activity.
|
||||
* Currently uses dummy logic: next business day at 10:00 AM.
|
||||
*
|
||||
* @param input - Scheduling parameters
|
||||
* @returns The scheduled follow-up date
|
||||
*/
|
||||
export function scheduleFollowUp(
|
||||
input: FollowUpScheduleInput,
|
||||
): FollowUpScheduleResult {
|
||||
const now = input.now ?? new Date();
|
||||
const dueDate = getNextBusinessDay(now);
|
||||
|
||||
// Set to 10:00 AM
|
||||
dueDate.setHours(10, 0, 0, 0);
|
||||
|
||||
return {
|
||||
dueDate,
|
||||
dueDateIso: dueDate.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the next business day (Mon–Fri) from the given date.
|
||||
* If the given date is already a weekday before 10 AM, returns
|
||||
* the NEXT business day (not the same day).
|
||||
*/
|
||||
function getNextBusinessDay(from: Date): Date {
|
||||
const result = new Date(from);
|
||||
|
||||
// Always advance at least one day
|
||||
result.setDate(result.getDate() + 1);
|
||||
|
||||
const day = result.getDay();
|
||||
|
||||
// Saturday → Monday (+2)
|
||||
if (day === 6) result.setDate(result.getDate() + 2);
|
||||
// Sunday → Monday (+1)
|
||||
if (day === 0) result.setDate(result.getDate() + 1);
|
||||
|
||||
return result;
|
||||
}
|
||||
+18
@@ -499,6 +499,24 @@ export async function invalidateProductsCache(
|
||||
await redis.del(productsCacheKey(cwOpportunityId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cached data for an opportunity.
|
||||
*
|
||||
* Removes activities, notes, contacts, products, and CW data cache keys.
|
||||
* Call this when an opportunity is deleted.
|
||||
*/
|
||||
export async function invalidateAllOpportunityCaches(
|
||||
cwOpportunityId: number,
|
||||
): Promise<void> {
|
||||
await redis.del(
|
||||
activityCacheKey(cwOpportunityId),
|
||||
notesCacheKey(cwOpportunityId),
|
||||
contactsCacheKey(cwOpportunityId),
|
||||
productsCacheKey(cwOpportunityId),
|
||||
oppCwDataCacheKey(cwOpportunityId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Site TTL — 20 minutes. Site/address data rarely changes so we cache
|
||||
* aggressively. The background refresh does NOT proactively warm site keys;
|
||||
|
||||
+900
@@ -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<void> | 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<string, MemberSalesMetrics>;
|
||||
}
|
||||
|
||||
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<number, any>();
|
||||
|
||||
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<OpportunityRevenue, "cacheHit"> => {
|
||||
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<OpportunityRevenue, "cacheHit"> => {
|
||||
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<CachedOpportunityRevenue | null> => {
|
||||
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<OpportunityRevenue, "cacheHit">,
|
||||
) => {
|
||||
await redis.set(
|
||||
oppRevenueKey(cwOpportunityId),
|
||||
JSON.stringify(revenue),
|
||||
"PX",
|
||||
METRICS_CACHE_TTL_MS,
|
||||
);
|
||||
};
|
||||
|
||||
const resolveProbabilityRatio = async (opp: {
|
||||
cwOpportunityId: number;
|
||||
probability: number;
|
||||
}): Promise<number> => {
|
||||
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<OpportunityRevenue> => {
|
||||
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 <T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<T> => {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Timeout")), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
mapper: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
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<void> {
|
||||
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<string, OpportunityRow[]>();
|
||||
for (const identifier of memberIdentifiers) {
|
||||
opportunitiesByMember.set(identifier, []);
|
||||
}
|
||||
|
||||
for (const opp of opportunityRows) {
|
||||
const assigned = new Set<string>();
|
||||
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<string, MemberSalesMetrics> = {};
|
||||
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<SalesMetricsCacheEnvelope | null> {
|
||||
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<MemberSalesMetrics | null> {
|
||||
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;
|
||||
}
|
||||
@@ -17,20 +17,26 @@ export interface CWMember {
|
||||
* Fetches every member from ConnectWise using pagination and returns them
|
||||
* in a Collection keyed by their identifier (e.g. "jroberts").
|
||||
*
|
||||
* @param opts.conditions - Optional CW conditions string to filter members
|
||||
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
|
||||
*/
|
||||
export const fetchAllCwMembers = async (): Promise<
|
||||
Collection<string, CWMember>
|
||||
> => {
|
||||
export const fetchAllCwMembers = async (opts?: {
|
||||
conditions?: string;
|
||||
}): Promise<Collection<string, CWMember>> => {
|
||||
const members = new Collection<string, CWMember>();
|
||||
const pageSize = 1000;
|
||||
const conditionsParam = opts?.conditions
|
||||
? `&conditions=${encodeURIComponent(opts.conditions)}`
|
||||
: "";
|
||||
|
||||
const { data: countData } = await connectWiseApi.get("/system/members/count");
|
||||
const { data: countData } = await connectWiseApi.get(
|
||||
`/system/members/count${conditionsParam ? `?${conditionsParam.slice(1)}` : ""}`,
|
||||
);
|
||||
const totalPages = Math.ceil(countData.count / pageSize);
|
||||
|
||||
for (let page = 0; page < totalPages; page++) {
|
||||
const { data } = await connectWiseApi.get<CWMember[]>(
|
||||
`/system/members?page=${page + 1}&pageSize=${pageSize}`,
|
||||
`/system/members?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
|
||||
);
|
||||
|
||||
for (const member of data) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { prisma } from "../../../constants";
|
||||
import { events } from "../../globalEvents";
|
||||
import { fetchAllCwMembers, type CWMember } from "./fetchAllMembers";
|
||||
import { setMemberCache } from "./memberCache";
|
||||
import { CwMemberController } from "../../../controllers/CwMemberController";
|
||||
|
||||
/**
|
||||
* Is Regular User
|
||||
*
|
||||
* Returns true if the CW member looks like a real person rather than
|
||||
* a service account (e.g. "labtech", "Admin"). A regular user must
|
||||
* have a last name and an email address.
|
||||
*/
|
||||
const isRegularUser = (member: CWMember): boolean =>
|
||||
!member.inactiveFlag &&
|
||||
Boolean(member.lastName?.trim()) &&
|
||||
Boolean(member.officeEmail?.trim());
|
||||
|
||||
/**
|
||||
* Refresh CW Members
|
||||
*
|
||||
* Syncs local CwMember records with ConnectWise using a stale-check
|
||||
* pattern:
|
||||
* 1. Fetch all members from CW
|
||||
* 2. Filter to regular users (active, non-service accounts)
|
||||
* 3. Compare against local cwLastUpdated timestamps
|
||||
* 4. Upsert stale/new records
|
||||
* 5. Also refreshes the in-memory member cache
|
||||
*/
|
||||
export const refreshCwMembers = async () => {
|
||||
events.emit("cw:members:db:refresh:check");
|
||||
|
||||
// 1. Fetch all members from CW
|
||||
const allCwMembers = await fetchAllCwMembers();
|
||||
|
||||
// Also refresh the in-memory cache with ALL members (used for name resolution)
|
||||
await setMemberCache(allCwMembers);
|
||||
|
||||
// 2. Filter to regular users only (active, has last name + email)
|
||||
const cwMembers = allCwMembers.filter(isRegularUser);
|
||||
|
||||
// 2. Fetch all DB records with their identifier and cwLastUpdated
|
||||
const dbItems = await prisma.cwMember.findMany({
|
||||
select: { cwMemberId: true, cwLastUpdated: true },
|
||||
});
|
||||
const dbMap = new Map(
|
||||
dbItems.map((item) => [item.cwMemberId, item.cwLastUpdated]),
|
||||
);
|
||||
|
||||
// 3. Determine stale / new IDs
|
||||
const staleIds: number[] = [];
|
||||
|
||||
for (const [, member] of cwMembers) {
|
||||
const cwLastUpdated = member._info?.lastUpdated
|
||||
? new Date(member._info.lastUpdated)
|
||||
: null;
|
||||
const dbLastUpdated = dbMap.get(member.id) ?? null;
|
||||
|
||||
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
|
||||
staleIds.push(member.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (staleIds.length === 0) {
|
||||
events.emit("cw:members:db:refresh:skipped", {
|
||||
totalCw: cwMembers.size,
|
||||
totalDb: dbItems.length,
|
||||
staleCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
events.emit("cw:members:db:refresh:started", {
|
||||
totalCw: cwMembers.size,
|
||||
totalDb: dbItems.length,
|
||||
staleCount: staleIds.length,
|
||||
});
|
||||
|
||||
// 4. Upsert stale/new items
|
||||
const staleIdSet = new Set(staleIds);
|
||||
const updatedCount = (
|
||||
await Promise.all(
|
||||
[...cwMembers.values()]
|
||||
.filter((m) => staleIdSet.has(m.id))
|
||||
.map(async (member) => {
|
||||
const mapped = CwMemberController.mapCwToDb(member);
|
||||
|
||||
return prisma.cwMember.upsert({
|
||||
where: { cwMemberId: member.id },
|
||||
create: {
|
||||
cwMemberId: member.id,
|
||||
...mapped,
|
||||
},
|
||||
update: mapped,
|
||||
});
|
||||
}),
|
||||
)
|
||||
).filter(Boolean).length;
|
||||
|
||||
events.emit("cw:members:db:refresh:completed", {
|
||||
totalCw: cwMembers.size,
|
||||
totalDb: dbItems.length,
|
||||
staleCount: staleIds.length,
|
||||
itemsUpdated: updatedCount,
|
||||
});
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { Collection } from "@discordjs/collection";
|
||||
import { connectWiseApi } from "../../../constants";
|
||||
import {
|
||||
CWOpportunity,
|
||||
CWOpportunityCreate,
|
||||
CWOpportunitySummary,
|
||||
CWForecast,
|
||||
CWForecastItem,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
CWOpportunityNoteCreate,
|
||||
CWOpportunityNoteUpdate,
|
||||
CWOpportunityContact,
|
||||
CWOpportunityUpdate,
|
||||
} from "./opportunity.types";
|
||||
|
||||
export const opportunityCw = {
|
||||
@@ -100,6 +102,45 @@ export const opportunityCw = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create Opportunity
|
||||
*
|
||||
* Creates a new opportunity in ConnectWise via POST.
|
||||
* Strips null/undefined values from the payload — CW rejects
|
||||
* null reference objects on create; omitting them lets CW apply
|
||||
* its own defaults.
|
||||
*/
|
||||
create: async (data: CWOpportunityCreate): Promise<CWOpportunity> => {
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(data).filter(([, v]) => v != null),
|
||||
);
|
||||
const response = await connectWiseApi.post("/sales/opportunities", cleaned);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update Opportunity
|
||||
*
|
||||
* Applies a JSON Patch update to an opportunity record in ConnectWise.
|
||||
* Each key in `data` produces a replace operation.
|
||||
*/
|
||||
update: async (
|
||||
opportunityId: number,
|
||||
data: CWOpportunityUpdate,
|
||||
): Promise<CWOpportunity> => {
|
||||
const operations = Object.entries(data).map(([key, value]) => ({
|
||||
op: "replace" as const,
|
||||
path: key,
|
||||
value,
|
||||
}));
|
||||
|
||||
const response = await connectWiseApi.patch(
|
||||
`/sales/opportunities/${opportunityId}`,
|
||||
operations,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunities by Company
|
||||
*
|
||||
@@ -254,6 +295,31 @@ export const opportunityCw = {
|
||||
return touchedIndices.map((i) => (updated.forecastItems ?? [])[i]!);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Forecast Item
|
||||
*
|
||||
* Removes a forecast item from an opportunity by PUTting the forecast
|
||||
* without the target item. CW's forecast endpoint replaces the entire
|
||||
* forecast items list on PUT.
|
||||
*/
|
||||
deleteProduct: async (
|
||||
opportunityId: number,
|
||||
forecastItemId: number,
|
||||
): Promise<void> => {
|
||||
const forecast = await opportunityCw.fetchProducts(opportunityId);
|
||||
const items = forecast.forecastItems ?? [];
|
||||
|
||||
const filtered = items.filter((fi) => fi.id !== forecastItemId);
|
||||
if (filtered.length === items.length) {
|
||||
throw new Error(
|
||||
`Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const url = `/sales/opportunities/${opportunityId}/forecast`;
|
||||
await connectWiseApi.put(url, { ...forecast, forecastItems: filtered });
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunity Notes
|
||||
*
|
||||
@@ -354,7 +420,7 @@ export const opportunityCw = {
|
||||
opportunityId: number,
|
||||
): Promise<Record<string, unknown>[]> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate`,
|
||||
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate,customFields`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
@@ -381,4 +447,54 @@ export const opportunityCw = {
|
||||
|
||||
return created;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Procurement Product by Forecast Detail
|
||||
*
|
||||
* Finds the procurement product linked to a given forecast item ID
|
||||
* on an opportunity.
|
||||
*/
|
||||
fetchProcurementProductByForecastDetail: async (
|
||||
opportunityId: number,
|
||||
forecastDetailId: number,
|
||||
): Promise<CWProcurementProduct | null> => {
|
||||
const conditions = `opportunity/id=${opportunityId} and forecastDetailId=${forecastDetailId}`;
|
||||
const response = await connectWiseApi.get(
|
||||
`/procurement/products?conditions=${encodeURIComponent(conditions)}&fields=id,forecastDetailId,description,customerDescription,quantity,price,cost,taxableFlag,specialOrderFlag,customFields`,
|
||||
);
|
||||
|
||||
const items = (response.data ?? []) as CWProcurementProduct[];
|
||||
return items[0] ?? null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update Procurement Product
|
||||
*
|
||||
* Applies a JSON Patch update to a procurement product record.
|
||||
*/
|
||||
updateProcurementProduct: async (
|
||||
procurementProductId: number,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<CWProcurementProduct> => {
|
||||
const operations = Object.entries(data).map(([key, value]) => ({
|
||||
op: "replace" as const,
|
||||
path: key,
|
||||
value,
|
||||
}));
|
||||
|
||||
const response = await connectWiseApi.patch(
|
||||
`/procurement/products/${procurementProductId}`,
|
||||
operations,
|
||||
);
|
||||
return response.data as CWProcurementProduct;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Opportunity
|
||||
*
|
||||
* Deletes an opportunity from ConnectWise by its CW opportunity ID.
|
||||
*/
|
||||
delete: async (opportunityId: number): Promise<void> => {
|
||||
await connectWiseApi.delete(`/sales/opportunities/${opportunityId}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface CWOpportunity {
|
||||
closedDate: string;
|
||||
closedBy: CWMemberReference;
|
||||
totalSalesTax: number;
|
||||
probability: CWReference;
|
||||
shipToCompany: CWCompanyReference;
|
||||
shipToContact: CWContactReference;
|
||||
shipToSite: CWSiteReference;
|
||||
@@ -212,7 +213,6 @@ export interface CWForecastItemCreate {
|
||||
catalogItem?: { id: number };
|
||||
forecastDescription?: string;
|
||||
productDescription?: string;
|
||||
customerDescription?: string;
|
||||
quantity?: number;
|
||||
status?: { id: number };
|
||||
productClass?: string;
|
||||
@@ -262,6 +262,48 @@ export interface CWProcurementProduct {
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWOpportunityUpdate {
|
||||
name?: string;
|
||||
notes?: string;
|
||||
rating?: { id: number };
|
||||
type?: { id: number };
|
||||
stage?: { id: number };
|
||||
status?: { id: number };
|
||||
priority?: { id: number };
|
||||
campaign?: { id: number };
|
||||
primarySalesRep?: { id: number };
|
||||
secondarySalesRep?: { id: number } | null;
|
||||
company?: { id: number };
|
||||
contact?: { id: number } | null;
|
||||
site?: { id: number } | null;
|
||||
expectedCloseDate?: string;
|
||||
customerPO?: string | null;
|
||||
source?: string | null;
|
||||
locationId?: number;
|
||||
businessUnitId?: number;
|
||||
}
|
||||
|
||||
export interface CWOpportunityCreate {
|
||||
name: string;
|
||||
expectedCloseDate: string;
|
||||
primarySalesRep: { id: number };
|
||||
company: { id: number };
|
||||
contact: { id: number };
|
||||
type?: { id: number };
|
||||
stage?: { id: number };
|
||||
status?: { id: number };
|
||||
priority?: { id: number };
|
||||
campaign?: { id: number };
|
||||
secondarySalesRep?: { id: number } | null;
|
||||
site?: { id: number } | null;
|
||||
notes?: string;
|
||||
rating?: { id: number };
|
||||
source?: string | null;
|
||||
customerPO?: string | null;
|
||||
locationId?: number;
|
||||
businessUnitId?: number;
|
||||
}
|
||||
|
||||
export interface CWOpportunitySummary {
|
||||
id: number;
|
||||
_info?: Record<string, string>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CWOpportunity } from "./opportunity.types";
|
||||
import { normalizeProbabilityPercent } from "../../sales-utils/normalizeProbability";
|
||||
|
||||
export type ProcessedOpportunity = ReturnType<
|
||||
typeof processOpportunityResponse
|
||||
@@ -14,6 +15,7 @@ export const processOpportunityResponse = (opportunity: CWOpportunity) => ({
|
||||
expectedCloseDate: opportunity.expectedCloseDate,
|
||||
closedDate: opportunity.closedDate,
|
||||
closedFlag: opportunity.closedFlag,
|
||||
probability: normalizeProbabilityPercent(opportunity.probability?.name),
|
||||
type: opportunity.type
|
||||
? { id: opportunity.type.id, name: opportunity.type.name }
|
||||
: null,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "../../../constants";
|
||||
import { events } from "../../globalEvents";
|
||||
import { opportunityCw } from "./opportunities";
|
||||
import { OpportunityController } from "../../../controllers/OpportunityController";
|
||||
import { invalidateAllOpportunityCaches } from "../../cache/opportunityCache";
|
||||
|
||||
/**
|
||||
* Refresh Opportunities
|
||||
@@ -21,11 +22,14 @@ export const refreshOpportunities = async () => {
|
||||
|
||||
// 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated
|
||||
const dbItems = await prisma.opportunity.findMany({
|
||||
select: { 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[] = [];
|
||||
@@ -34,18 +38,48 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Reconcile — find local records that no longer exist in CW
|
||||
const orphanedItems = dbItems.filter(
|
||||
(item) => !cwSummaries.has(item.cwOpportunityId),
|
||||
);
|
||||
|
||||
if (orphanedItems.length > 0) {
|
||||
console.log(
|
||||
`[refreshOpportunities] Reconciling ${orphanedItems.length} orphaned local record(s) not found in CW`,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
orphanedItems.map(async (item) => {
|
||||
await prisma.opportunity.delete({ where: { id: item.id } });
|
||||
await invalidateAllOpportunityCaches(item.cwOpportunityId);
|
||||
}),
|
||||
);
|
||||
|
||||
events.emit("cw:opportunities:refresh:reconciled", {
|
||||
orphanedCount: orphanedItems.length,
|
||||
removedCwIds: orphanedItems.map((i) => i.cwOpportunityId),
|
||||
});
|
||||
}
|
||||
|
||||
if (staleIds.length === 0) {
|
||||
events.emit("cw:opportunities:refresh:skipped", {
|
||||
totalCw: cwSummaries.size,
|
||||
totalDb: dbItems.length,
|
||||
staleCount: 0,
|
||||
orphanedCount: orphanedItems.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -106,5 +140,6 @@ export const refreshOpportunities = async () => {
|
||||
totalDb: dbItems.length,
|
||||
staleCount: staleIds.length,
|
||||
itemsUpdated: updatedCount,
|
||||
orphanedCount: orphanedItems.length,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -171,11 +171,17 @@ interface EventTypes {
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
itemsUpdated: number;
|
||||
orphanedCount: number;
|
||||
}) => void;
|
||||
"cw:opportunities:refresh:reconciled": (data: {
|
||||
orphanedCount: number;
|
||||
removedCwIds: number[];
|
||||
}) => void;
|
||||
"cw:opportunities:refresh:skipped": (data: {
|
||||
totalCw: number;
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
orphanedCount: number;
|
||||
}) => void;
|
||||
|
||||
// Cache Events
|
||||
@@ -194,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;
|
||||
@@ -205,6 +229,25 @@ interface EventTypes {
|
||||
totalUsers: number;
|
||||
usersUpdated: number;
|
||||
}) => void;
|
||||
|
||||
// ConnectWise Members DB Sync Events
|
||||
"cw:members:db:refresh:check": () => void;
|
||||
"cw:members:db:refresh:started": (data: {
|
||||
totalCw: number;
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
}) => void;
|
||||
"cw:members:db:refresh:completed": (data: {
|
||||
totalCw: number;
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
itemsUpdated: number;
|
||||
}) => void;
|
||||
"cw:members:db:refresh:skipped": (data: {
|
||||
totalCw: number;
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const events = new Eventra<EventTypes>();
|
||||
|
||||
@@ -0,0 +1,784 @@
|
||||
import PdfPrinter from "pdfmake/src/Printer";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface QuoteLineItem {
|
||||
qty: number;
|
||||
description: string;
|
||||
unitPrice: number;
|
||||
narrative?: string;
|
||||
}
|
||||
|
||||
export interface CustomerInfo {
|
||||
name: string;
|
||||
company?: string;
|
||||
attention?: string;
|
||||
address: string[];
|
||||
}
|
||||
|
||||
export interface CustomerContact {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface QuoteDetails {
|
||||
quoteNumber: string;
|
||||
date: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface TaxConfig {
|
||||
rate: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SalesRepInfo {
|
||||
name: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface QuoteMetadata {
|
||||
quoteId?: string;
|
||||
createdById?: string;
|
||||
createdByName?: string;
|
||||
createdByEmail?: string;
|
||||
createdAt?: string;
|
||||
downloadedAt?: string;
|
||||
downloadedById?: string;
|
||||
downloadedByName?: string;
|
||||
downloadedByEmail?: string;
|
||||
}
|
||||
|
||||
export interface QuoteData {
|
||||
customer: CustomerInfo;
|
||||
contact: CustomerContact;
|
||||
quote: QuoteDetails;
|
||||
lineItems: QuoteLineItem[];
|
||||
taxableSubtotal?: number;
|
||||
tax: TaxConfig;
|
||||
salesRep?: SalesRepInfo;
|
||||
quoteNarrative?: string;
|
||||
isPreview?: boolean;
|
||||
showLineItemPricing?: boolean;
|
||||
metadata?: QuoteMetadata;
|
||||
}
|
||||
|
||||
export interface QuoteTheme {
|
||||
brandPrimary: string;
|
||||
brandDark: string;
|
||||
brandLight: string;
|
||||
accent: string;
|
||||
headerBg: string;
|
||||
footerBg: string;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME: QuoteTheme = {
|
||||
brandPrimary: "#8B5E0B",
|
||||
brandDark: "#5C3D07",
|
||||
brandLight: "#F5EDE0",
|
||||
accent: "#C67F17",
|
||||
headerBg: "#2D2317",
|
||||
footerBg: "#F5EDE0",
|
||||
};
|
||||
|
||||
const SLATE = "#3A3A3A";
|
||||
const SLATE_MID = "#636363";
|
||||
const SLATE_LIGHT = "#8E8E8E";
|
||||
const WHITE = "#FFFFFF";
|
||||
const ROW_ALT = "#FAF7F2";
|
||||
const DIVIDER = "#D4C5A9";
|
||||
|
||||
const PAGE_H = 792;
|
||||
const PAGE_W = 612;
|
||||
const MARGIN_L = 40;
|
||||
const MARGIN_R = 40;
|
||||
const MARGIN_TOP = 26;
|
||||
const MARGIN_BOTTOM = 65;
|
||||
const CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R;
|
||||
|
||||
const DEFAULT_DISCLAIMER =
|
||||
"Prices valid for 30 days from quote date. Taxes invoiced per jurisdiction regardless of presence on this quote.";
|
||||
|
||||
const COMPANY = {
|
||||
name: "Total Tech Solutions LLC",
|
||||
contactPerson: "Courtney Stevens",
|
||||
address: ["PO Box 331", "Murray, KY 42071"],
|
||||
phone: "(270) 761-8324",
|
||||
email: "courtney.stevens@totaltech.net",
|
||||
licenseInfo: "Licensed in Kentucky & Tennessee · TN License #2173",
|
||||
} as const;
|
||||
|
||||
const DEFAULT_LOGO_PATH = join(process.cwd(), "logo.png");
|
||||
|
||||
const fontDir = join(process.cwd(), "node_modules/pdfmake/build/fonts/Roboto");
|
||||
const fonts = {
|
||||
Roboto: {
|
||||
normal: join(fontDir, "Roboto-Regular.ttf"),
|
||||
bold: join(fontDir, "Roboto-Medium.ttf"),
|
||||
italics: join(fontDir, "Roboto-Italic.ttf"),
|
||||
bolditalics: join(fontDir, "Roboto-MediumItalic.ttf"),
|
||||
},
|
||||
};
|
||||
|
||||
const printer = new PdfPrinter(fonts as never);
|
||||
|
||||
const fmt = (n: number) =>
|
||||
"$" + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
|
||||
const hr = (color = DIVIDER, weight = 0.75) => ({
|
||||
canvas: [
|
||||
{
|
||||
type: "line",
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: CONTENT_W,
|
||||
y2: 0,
|
||||
lineWidth: weight,
|
||||
lineColor: color,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function loadLogoDataUrl(logoPath: string): string | null {
|
||||
try {
|
||||
const raw = readFileSync(logoPath);
|
||||
const ext = logoPath.toLowerCase().endsWith(".png") ? "png" : "jpeg";
|
||||
return `data:image/${ext};base64,${raw.toString("base64")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateQuote(
|
||||
data: QuoteData,
|
||||
theme: Partial<QuoteTheme> = {},
|
||||
logoPath = DEFAULT_LOGO_PATH,
|
||||
): Promise<Buffer> {
|
||||
const t: QuoteTheme = { ...DEFAULT_THEME, ...theme };
|
||||
const subTotal = data.lineItems.reduce(
|
||||
(sum, item) => sum + item.qty * item.unitPrice,
|
||||
0,
|
||||
);
|
||||
const taxableSubTotal = Math.max(0, data.taxableSubtotal ?? subTotal);
|
||||
const taxAmount = taxableSubTotal * data.tax.rate;
|
||||
const total = subTotal + taxAmount;
|
||||
const logoDataUrl = loadLogoDataUrl(logoPath);
|
||||
|
||||
const showPricing = data.showLineItemPricing ?? false;
|
||||
|
||||
const tableHeader = [
|
||||
{ text: "Qty", style: "thCell", alignment: "center" },
|
||||
{ text: "Description", style: "thCell" },
|
||||
...(showPricing
|
||||
? [
|
||||
{ text: "Unit Price", style: "thCell", alignment: "right" },
|
||||
{ text: "Total", style: "thCell", alignment: "right" },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const colCount = showPricing ? 4 : 2;
|
||||
|
||||
const tableRows: Record<string, unknown>[][] = [];
|
||||
for (const item of data.lineItems) {
|
||||
// Build the description cell — stack description + narrative so they
|
||||
// are a single cell and pdfmake never splits them across pages.
|
||||
const descriptionCell: Record<string, unknown> = item.narrative
|
||||
? {
|
||||
stack: [
|
||||
{ text: item.description, style: "tdCell" },
|
||||
{
|
||||
text: item.narrative,
|
||||
style: "narrative",
|
||||
margin: [0, 2, 8, 0],
|
||||
},
|
||||
],
|
||||
}
|
||||
: { text: item.description, style: "tdCell" };
|
||||
|
||||
tableRows.push([
|
||||
{ text: String(item.qty), style: "tdCell", alignment: "center" },
|
||||
descriptionCell,
|
||||
...(showPricing
|
||||
? [
|
||||
{
|
||||
text: fmt(item.unitPrice),
|
||||
style: "tdCell",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
},
|
||||
{
|
||||
text: fmt(item.qty * item.unitPrice),
|
||||
style: "tdCell",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
||||
const headerImage = logoDataUrl
|
||||
? { image: logoDataUrl, width: 200 }
|
||||
: {
|
||||
stack: [{ text: COMPANY.name, style: "companyName" }],
|
||||
width: 200,
|
||||
};
|
||||
|
||||
const docDefinition = {
|
||||
pageSize: "LETTER" as const,
|
||||
pageMargins: [MARGIN_L, MARGIN_TOP, MARGIN_R, MARGIN_BOTTOM] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
],
|
||||
|
||||
info: {
|
||||
title: `Quote ${data.quote.quoteNumber}`,
|
||||
author: data.metadata?.createdByName ?? COMPANY.name,
|
||||
subject: data.quote.description,
|
||||
creator: COMPANY.name,
|
||||
producer: COMPANY.name,
|
||||
keywords: [
|
||||
data.metadata?.quoteId ? `quoteId:${data.metadata.quoteId}` : null,
|
||||
data.metadata?.createdById
|
||||
? `createdById:${data.metadata.createdById}`
|
||||
: null,
|
||||
data.metadata?.createdByEmail
|
||||
? `createdByEmail:${data.metadata.createdByEmail}`
|
||||
: null,
|
||||
data.metadata?.createdAt
|
||||
? `createdAt:${data.metadata.createdAt}`
|
||||
: null,
|
||||
data.metadata?.downloadedAt
|
||||
? `downloadedAt:${data.metadata.downloadedAt}`
|
||||
: null,
|
||||
data.metadata?.downloadedById
|
||||
? `downloadedById:${data.metadata.downloadedById}`
|
||||
: null,
|
||||
data.metadata?.downloadedByName
|
||||
? `downloadedByName:${data.metadata.downloadedByName}`
|
||||
: null,
|
||||
data.metadata?.downloadedByEmail
|
||||
? `downloadedByEmail:${data.metadata.downloadedByEmail}`
|
||||
: null,
|
||||
data.isPreview ? "preview:true" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; "),
|
||||
},
|
||||
|
||||
defaultStyle: {
|
||||
font: "Roboto",
|
||||
fontSize: 9.5,
|
||||
color: SLATE,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
|
||||
styles: {
|
||||
companyName: { fontSize: 18, bold: true, color: t.brandDark },
|
||||
quoteLabel: { fontSize: 24, color: t.accent, bold: true, opacity: 0.12 },
|
||||
sectionTitle: {
|
||||
fontSize: 8.5,
|
||||
bold: true,
|
||||
color: t.brandPrimary,
|
||||
characterSpacing: 1.2,
|
||||
},
|
||||
sectionBody: { fontSize: 9, color: SLATE },
|
||||
sectionMuted: { fontSize: 8.5, color: SLATE_MID },
|
||||
infoLabel: {
|
||||
fontSize: 8,
|
||||
bold: true,
|
||||
color: SLATE_LIGHT,
|
||||
characterSpacing: 0.5,
|
||||
},
|
||||
infoValue: { fontSize: 10, bold: true, color: t.brandDark },
|
||||
contactLabel: { fontSize: 8, bold: true, color: SLATE_LIGHT },
|
||||
contactValue: { fontSize: 9, color: SLATE },
|
||||
thCell: {
|
||||
fontSize: 8.5,
|
||||
bold: true,
|
||||
color: WHITE,
|
||||
characterSpacing: 0.5,
|
||||
},
|
||||
tdCell: { fontSize: 9, color: SLATE },
|
||||
narrative: {
|
||||
fontSize: 8,
|
||||
color: SLATE_MID,
|
||||
italics: true,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
totalsLabel: { fontSize: 9, color: SLATE_MID },
|
||||
totalsValue: { fontSize: 9, color: SLATE, bold: true },
|
||||
totalFinalLabel: { fontSize: 11, bold: true, color: WHITE },
|
||||
totalFinalValue: { fontSize: 12, bold: true, color: t.brandDark },
|
||||
footerText: { fontSize: 7.5, color: SLATE_MID },
|
||||
footerBold: { fontSize: 7.5, color: t.brandPrimary, bold: true },
|
||||
disclaimer: { fontSize: 7, color: SLATE_LIGHT, italics: true },
|
||||
},
|
||||
|
||||
...(data.isPreview
|
||||
? {
|
||||
watermark: {
|
||||
text: "PREVIEW",
|
||||
color: t.brandDark,
|
||||
opacity: 0.15,
|
||||
bold: true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
background: () => ({
|
||||
canvas: [
|
||||
{ type: "rect", x: 0, y: 0, w: PAGE_W, h: 6, color: t.accent },
|
||||
{ type: "rect", x: 0, y: 6, w: 4, h: 786, color: t.brandLight },
|
||||
],
|
||||
}),
|
||||
|
||||
content: [
|
||||
{
|
||||
margin: [0, 4, 0, 0],
|
||||
columns: [
|
||||
headerImage,
|
||||
{
|
||||
stack: [
|
||||
{ text: COMPANY.name, style: "companyName", alignment: "right" },
|
||||
{
|
||||
text: "QUOTE",
|
||||
style: "quoteLabel",
|
||||
alignment: "right",
|
||||
margin: [0, -4, 0, 0],
|
||||
},
|
||||
],
|
||||
width: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{ ...hr(t.accent, 1.5), margin: [0, 8, 0, 0] },
|
||||
|
||||
{
|
||||
margin: [0, 7, 0, 7],
|
||||
columns: [
|
||||
{
|
||||
width: "auto",
|
||||
stack: [
|
||||
{ text: "QUOTE NUMBER", style: "infoLabel" },
|
||||
{
|
||||
text: data.quote.quoteNumber,
|
||||
style: "infoValue",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
width: "auto",
|
||||
margin: [30, 0, 0, 0],
|
||||
stack: [
|
||||
{ text: "DATE", style: "infoLabel" },
|
||||
{
|
||||
text: data.quote.date,
|
||||
style: "infoValue",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
width: "*",
|
||||
margin: [30, 0, 0, 0],
|
||||
stack: [
|
||||
{ text: "DESCRIPTION", style: "infoLabel" },
|
||||
{
|
||||
text: data.quote.description,
|
||||
style: "infoValue",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{ ...hr(), margin: [0, 0, 0, 10] },
|
||||
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
width: 155,
|
||||
stack: [
|
||||
{ text: "FROM", style: "sectionTitle", margin: [0, 0, 0, 6] },
|
||||
{
|
||||
text: data.salesRep?.name ?? COMPANY.contactPerson,
|
||||
style: "sectionBody",
|
||||
bold: true,
|
||||
},
|
||||
{
|
||||
text: COMPANY.name,
|
||||
style: "sectionMuted",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
...COMPANY.address.map((line) => ({
|
||||
text: line,
|
||||
style: "sectionMuted",
|
||||
})),
|
||||
{
|
||||
text: COMPANY.phone,
|
||||
style: "sectionBody",
|
||||
margin: [0, 4, 0, 0],
|
||||
},
|
||||
{
|
||||
text: data.salesRep?.email ?? COMPANY.email,
|
||||
style: "sectionMuted",
|
||||
margin: [0, 1, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
width: 175,
|
||||
margin: [25, 0, 0, 0],
|
||||
stack: [
|
||||
{
|
||||
text: "PREPARED FOR",
|
||||
style: "sectionTitle",
|
||||
margin: [0, 0, 0, 6],
|
||||
},
|
||||
{ text: data.customer.name, style: "sectionBody", bold: true },
|
||||
...(data.customer.company
|
||||
? [
|
||||
{
|
||||
text: data.customer.company,
|
||||
style: "sectionMuted",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(data.customer.attention
|
||||
? [{ text: data.customer.attention, style: "sectionMuted" }]
|
||||
: []),
|
||||
...data.customer.address.map((line) => ({
|
||||
text: line,
|
||||
style: "sectionMuted",
|
||||
})),
|
||||
],
|
||||
},
|
||||
|
||||
...(data.contact.email || data.contact.phone
|
||||
? [
|
||||
{
|
||||
width: "*" as const,
|
||||
margin: [20, 0, 0, 0] as [number, number, number, number],
|
||||
stack: [
|
||||
{
|
||||
text: "CONTACT",
|
||||
style: "sectionTitle",
|
||||
margin: [0, 0, 0, 6],
|
||||
},
|
||||
...(data.contact.email
|
||||
? [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
text: "Email",
|
||||
style: "contactLabel",
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
text: data.contact.email,
|
||||
style: "contactValue",
|
||||
width: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(data.contact.phone
|
||||
? [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
text: "Mobile",
|
||||
style: "contactLabel",
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
text: data.contact.phone,
|
||||
style: "contactValue",
|
||||
width: "*",
|
||||
},
|
||||
],
|
||||
margin: [0, 4, 0, 0],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
|
||||
{ ...hr(), margin: [0, 10, 0, 0] },
|
||||
|
||||
...(data.quoteNarrative
|
||||
? [
|
||||
{
|
||||
margin: [0, 8, 0, 6] as [number, number, number, number],
|
||||
table: {
|
||||
widths: [2, "*"],
|
||||
body: [
|
||||
[
|
||||
{
|
||||
text: "",
|
||||
fillColor: t.accent,
|
||||
border: [false, false, false, false],
|
||||
},
|
||||
{
|
||||
text: data.quoteNarrative,
|
||||
fontSize: 9,
|
||||
color: SLATE_MID,
|
||||
italics: true,
|
||||
lineHeight: 1.4,
|
||||
margin: [8, 6, 8, 6],
|
||||
fillColor: ROW_ALT,
|
||||
border: [false, false, false, false],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
layout: {
|
||||
hLineWidth: () => 0,
|
||||
vLineWidth: () => 0,
|
||||
paddingLeft: () => 0,
|
||||
paddingRight: () => 0,
|
||||
paddingTop: () => 0,
|
||||
paddingBottom: () => 0,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
margin: [0, 10, 0, 0],
|
||||
table: {
|
||||
headerRows: 1,
|
||||
dontBreakRows: true,
|
||||
widths: showPricing ? [40, "*", 75, 75] : [40, "*"],
|
||||
body: [tableHeader, ...tableRows],
|
||||
},
|
||||
layout: {
|
||||
fillColor: (rowIndex: number) => {
|
||||
if (rowIndex === 0) return t.headerBg;
|
||||
return rowIndex % 2 === 0 ? ROW_ALT : null;
|
||||
},
|
||||
hLineWidth: (i: number, node: { table: { body: unknown[] } }) => {
|
||||
if (i === 0 || i === 1) return 0;
|
||||
if (i === node.table.body.length) return 1;
|
||||
return 0.5;
|
||||
},
|
||||
vLineWidth: () => 0,
|
||||
hLineColor: (i: number, node: { table: { body: unknown[] } }) =>
|
||||
i === node.table.body.length ? t.headerBg : "#E8E0D0",
|
||||
paddingLeft: (col: number) => (col === 0 ? 6 : 8),
|
||||
paddingRight: () => 8,
|
||||
paddingTop: () => 4,
|
||||
paddingBottom: () => 4,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
unbreakable: true,
|
||||
stack: [
|
||||
{
|
||||
margin: [0, 6, 0, 0],
|
||||
columns: [
|
||||
{ width: "*", text: "" },
|
||||
{
|
||||
width: 250,
|
||||
table: {
|
||||
widths: ["*", 110],
|
||||
body: [
|
||||
[
|
||||
{
|
||||
text: "Subtotal",
|
||||
style: "totalsLabel",
|
||||
margin: [0, 5, 0, 5],
|
||||
border: [false, false, false, true],
|
||||
},
|
||||
{
|
||||
text: fmt(subTotal),
|
||||
style: "totalsValue",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
margin: [0, 5, 0, 5],
|
||||
border: [false, false, false, true],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: data.tax.label,
|
||||
style: "totalsLabel",
|
||||
margin: [0, 5, 0, 5],
|
||||
border: [false, false, false, true],
|
||||
},
|
||||
{
|
||||
text: fmt(taxAmount),
|
||||
style: "totalsValue",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
margin: [0, 5, 0, 5],
|
||||
border: [false, false, false, true],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: "TOTAL",
|
||||
style: "totalFinalLabel",
|
||||
fillColor: t.headerBg,
|
||||
margin: [10, 8, 6, 8],
|
||||
border: [false, false, false, false],
|
||||
},
|
||||
{
|
||||
text: fmt(total),
|
||||
style: "totalFinalValue",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
fillColor: t.brandLight,
|
||||
margin: [6, 7, 8, 7],
|
||||
border: [false, false, false, false],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
layout: {
|
||||
hLineWidth: (i: number) => (i >= 1 && i <= 2 ? 0.5 : 0),
|
||||
vLineWidth: () => 0,
|
||||
hLineColor: () => "#E0D6C6",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
margin: [0, 40, 0, 0],
|
||||
columns: [
|
||||
{
|
||||
width: "50%",
|
||||
stack: [
|
||||
{
|
||||
canvas: [
|
||||
{
|
||||
type: "line",
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 220,
|
||||
y2: 0,
|
||||
lineWidth: 0.75,
|
||||
lineColor: "#999",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Authorized Signature",
|
||||
fontSize: 7,
|
||||
color: "#888",
|
||||
margin: [0, 3, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
width: "50%",
|
||||
stack: [
|
||||
{
|
||||
canvas: [
|
||||
{
|
||||
type: "line",
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 160,
|
||||
y2: 0,
|
||||
lineWidth: 0.75,
|
||||
lineColor: "#999",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Date",
|
||||
fontSize: 7,
|
||||
color: "#888",
|
||||
margin: [0, 3, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
footer: (currentPage: number, pageCount: number) => ({
|
||||
margin: [0, 0, 0, 0],
|
||||
stack: [
|
||||
{
|
||||
canvas: [
|
||||
{ type: "rect", x: 0, y: 0, w: PAGE_W, h: 44, color: t.footerBg },
|
||||
],
|
||||
},
|
||||
{
|
||||
margin: [MARGIN_L, -38, MARGIN_R, 0],
|
||||
columns: [
|
||||
{
|
||||
width: "*",
|
||||
stack: [
|
||||
{
|
||||
text: [
|
||||
{ text: COMPANY.name, style: "footerBold" },
|
||||
{
|
||||
text: ` · ${COMPANY.licenseInfo}`,
|
||||
style: "footerText",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
width: "auto",
|
||||
text: `Page ${currentPage} of ${pageCount}`,
|
||||
style: "footerText",
|
||||
alignment: "right",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
margin: [MARGIN_L, 4, MARGIN_R, 0],
|
||||
text: DEFAULT_DISCLAIMER,
|
||||
style: "disclaimer",
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const maybeDoc = printer.createPdfKitDocument(docDefinition as never) as any;
|
||||
const pdfDoc =
|
||||
maybeDoc && typeof maybeDoc.then === "function" ? await maybeDoc : maybeDoc;
|
||||
|
||||
if (!pdfDoc || typeof pdfDoc.on !== "function") {
|
||||
throw new Error("Failed to initialize PDF document stream");
|
||||
}
|
||||
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
pdfDoc.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
pdfDoc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
pdfDoc.on("error", reject);
|
||||
if (typeof pdfDoc.end === "function") {
|
||||
pdfDoc.end();
|
||||
} else {
|
||||
reject(new Error("PDF document stream does not support end()"));
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./generateQuote";
|
||||
export * from "./injectPdfMetadata";
|
||||
@@ -0,0 +1,48 @@
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
|
||||
export interface DownloadMetadata {
|
||||
downloadedAt: string;
|
||||
downloadedById: string;
|
||||
downloadedByName?: string;
|
||||
downloadedByEmail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects download-time metadata into an existing PDF's document properties.
|
||||
*
|
||||
* Appends download-specific key:value pairs to the PDF's Keywords field
|
||||
* (matching the semicolon-delimited format used at commit time) and updates
|
||||
* the ModificationDate.
|
||||
*
|
||||
* Returns the modified PDF as a `Uint8Array`.
|
||||
*/
|
||||
export async function injectPdfMetadata(
|
||||
pdfBytes: Buffer | Uint8Array,
|
||||
metadata: DownloadMetadata,
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
|
||||
// Build new keyword entries in the same format used by generateQuote
|
||||
const newKeywordPairs = [
|
||||
`downloadedAt:${metadata.downloadedAt}`,
|
||||
`downloadedById:${metadata.downloadedById}`,
|
||||
metadata.downloadedByName
|
||||
? `downloadedByName:${metadata.downloadedByName}`
|
||||
: null,
|
||||
metadata.downloadedByEmail
|
||||
? `downloadedByEmail:${metadata.downloadedByEmail}`
|
||||
: null,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
// Append to existing keywords (preserve commit-time metadata)
|
||||
const existingKeywords = pdfDoc.getKeywords() ?? "";
|
||||
const separator = existingKeywords.length > 0 ? "; " : "";
|
||||
pdfDoc.setKeywords([
|
||||
existingKeywords + separator + newKeywordPairs.join("; "),
|
||||
]);
|
||||
|
||||
// Update modification date to download time
|
||||
pdfDoc.setModificationDate(new Date(metadata.downloadedAt));
|
||||
|
||||
return pdfDoc.save();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @module cw.opportunityService
|
||||
*
|
||||
* ConnectWise Opportunity Service
|
||||
* ================================
|
||||
*
|
||||
* Methods for ConnectWise integrations that the opportunity workflow
|
||||
* calls. Some are still stubs (marked with console.warn); others are
|
||||
* fully implemented against the CW REST API.
|
||||
*/
|
||||
|
||||
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
||||
import { connectWiseApi } from "../constants";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TimeEntryInput {
|
||||
/** CW activity ID to charge the time entry to. */
|
||||
activityId: number;
|
||||
/** CW member ID of the user submitting time. */
|
||||
cwMemberId: number;
|
||||
/** ISO-8601 datetime when work started. */
|
||||
timeStart: string;
|
||||
/** ISO-8601 datetime when work ended. */
|
||||
timeEnd: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface TimeEntryResult {
|
||||
success: boolean;
|
||||
cwTimeEntryId: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface StatusSyncInput {
|
||||
opportunityId: number;
|
||||
statusCwId: number;
|
||||
}
|
||||
|
||||
export interface StatusSyncResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Submit a time entry to ConnectWise for an opportunity activity.
|
||||
*
|
||||
* Called automatically whenever `timeStart` and `timeEnd` are provided
|
||||
* on a workflow action.
|
||||
*/
|
||||
export async function submitTimeEntry(
|
||||
input: TimeEntryInput,
|
||||
): Promise<TimeEntryResult> {
|
||||
try {
|
||||
const stripMs = (d: string) => d.replace(/\.\d{3}Z$/, "Z");
|
||||
|
||||
const response = await connectWiseApi.post("/time/entries", {
|
||||
member: { id: input.cwMemberId },
|
||||
chargeToType: "Activity",
|
||||
chargeToId: input.activityId,
|
||||
timeStart: stripMs(input.timeStart),
|
||||
timeEnd: stripMs(input.timeEnd),
|
||||
notes: input.notes,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
cwTimeEntryId: response.data?.id ?? null,
|
||||
message: `Time entry ${response.data?.id} created for activity ${input.activityId}.`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[cw.opportunityService] submitTimeEntry FAILED — activityId=${input.activityId}, cwMemberId=${input.cwMemberId}`,
|
||||
error?.response?.data ?? error,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
cwTimeEntryId: null,
|
||||
message: `Failed to submit time entry: ${error?.message ?? "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync an opportunity's status to ConnectWise.
|
||||
*
|
||||
* Called whenever the workflow transitions an opportunity to a new
|
||||
* status, ensuring the CW record stays in sync.
|
||||
*/
|
||||
export async function syncOpportunityStatus(
|
||||
input: StatusSyncInput,
|
||||
): Promise<StatusSyncResult> {
|
||||
try {
|
||||
await opportunityCw.update(input.opportunityId, {
|
||||
status: { id: input.statusCwId },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Opportunity ${input.opportunityId} status synced to CW status ID ${input.statusCwId}.`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[cw.opportunityService] syncOpportunityStatus FAILED — opportunityId=${input.opportunityId}, statusCwId=${input.statusCwId}`,
|
||||
error?.response?.data ?? error,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to sync opportunity status: ${error?.message ?? "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
+213
-14
@@ -82,6 +82,12 @@ export const PERMISSION_NODES = {
|
||||
usedIn: ["src/api/companies/[id]/configurations.ts"],
|
||||
dependencies: ["company.fetch"],
|
||||
},
|
||||
{
|
||||
node: "company.fetch.sites",
|
||||
description: "Fetch company sites from ConnectWise",
|
||||
usedIn: ["src/api/companies/[id]/sites.ts"],
|
||||
dependencies: ["company.fetch"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -399,11 +405,12 @@ export const PERMISSION_NODES = {
|
||||
description:
|
||||
"Fetch a single opportunity and its sub-resources (products, notes, contacts)",
|
||||
usedIn: [
|
||||
"src/api/sales/[id]/fetch.ts",
|
||||
"src/api/sales/[id]/products.ts",
|
||||
"src/api/sales/[id]/notes.ts",
|
||||
"src/api/sales/[id]/fetchNote.ts",
|
||||
"src/api/sales/[id]/contacts.ts",
|
||||
"src/api/sales/opportunities/[id]/fetch.ts",
|
||||
"src/api/sales/opportunities/[id]/products/fetchAll.ts",
|
||||
"src/api/sales/opportunities/[id]/notes/fetchAll.ts",
|
||||
"src/api/sales/opportunities/[id]/notes/fetch.ts",
|
||||
"src/api/sales/opportunities/[id]/contacts.ts",
|
||||
"src/api/sockets/events/liveQuotePreview.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -411,47 +418,103 @@ export const PERMISSION_NODES = {
|
||||
description:
|
||||
"Fetch multiple opportunities, count, or opportunity types",
|
||||
usedIn: [
|
||||
"src/api/sales/fetchAll.ts",
|
||||
"src/api/sales/count.ts",
|
||||
"src/api/sales/opportunities/fetchAll.ts",
|
||||
"src/api/sales/opportunities/count.ts",
|
||||
"src/api/sales/fetchOpportunityTypes.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.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=<cwIdentifier>` 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",
|
||||
usedIn: ["src/api/sales/[id]/refresh.ts"],
|
||||
usedIn: ["src/api/sales/opportunities/[id]/refresh.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.update",
|
||||
description:
|
||||
"Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/update.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.create",
|
||||
description: "Create a new opportunity in ConnectWise",
|
||||
usedIn: ["src/api/sales/opportunities/create.ts"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.delete",
|
||||
description:
|
||||
"Delete an opportunity from ConnectWise and the local database",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/delete.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.note.create",
|
||||
description: "Create a new note on an opportunity",
|
||||
usedIn: ["src/api/sales/[id]/createNote.ts"],
|
||||
usedIn: ["src/api/sales/opportunities/[id]/notes/create.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.note.update",
|
||||
description: "Update an existing note on an opportunity",
|
||||
usedIn: ["src/api/sales/[id]/updateNote.ts"],
|
||||
usedIn: ["src/api/sales/opportunities/[id]/notes/update.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.note.delete",
|
||||
description: "Delete a note from an opportunity",
|
||||
usedIn: ["src/api/sales/[id]/deleteNote.ts"],
|
||||
usedIn: ["src/api/sales/opportunities/[id]/notes/delete.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.product.update",
|
||||
description:
|
||||
"Update products (forecast items) on an opportunity, including resequencing",
|
||||
usedIn: ["src/api/sales/[id]/resequenceProducts.ts"],
|
||||
usedIn: [
|
||||
"src/api/sales/opportunities/[id]/products/resequence.ts",
|
||||
"src/api/sales/opportunities/[id]/products/update.ts",
|
||||
"src/api/sales/opportunities/[id]/products/cancel.ts",
|
||||
],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.product.delete",
|
||||
description: "Delete a product (forecast item) from an opportunity",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/products/delete.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.product.add",
|
||||
description:
|
||||
"Add a new product (forecast item) to an opportunity. Individual fields are gated by sales.opportunity.product.field.<field> permissions.",
|
||||
usedIn: ["src/api/sales/[id]/addProduct.ts"],
|
||||
usedIn: ["src/api/sales/opportunities/[id]/products/add.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
fieldLevelPermissions: [
|
||||
"sales.opportunity.product.field.catalogItem",
|
||||
@@ -477,9 +540,144 @@ export const PERMISSION_NODES = {
|
||||
node: "sales.opportunity.product.add.specialOrder",
|
||||
description:
|
||||
'Add one or more "SPECIAL ORDER" products to an opportunity via the dedicated special-order route.',
|
||||
usedIn: ["src/api/sales/[id]/addSpecialOrderProduct.ts"],
|
||||
usedIn: [
|
||||
"src/api/sales/opportunities/[id]/products/addSpecialOrder.ts",
|
||||
],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.product.add.labor",
|
||||
description:
|
||||
"Add labor products to an opportunity using the dedicated labor route with Field/Tech catalog selection and pricing inputs.",
|
||||
usedIn: [
|
||||
"src/api/sales/opportunities/[id]/products/addLabor.ts",
|
||||
"src/api/sales/opportunities/[id]/products/laborOptions.ts",
|
||||
],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.quote.fetch",
|
||||
description: "Fetch all committed quotes for an opportunity.",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchAll.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.quote.commit",
|
||||
description:
|
||||
"Generate and store a finalized quote PDF for an opportunity with regeneration metadata and creator attribution.",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/quotes/commit.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.quote.preview",
|
||||
description:
|
||||
"Generate a preview-stamped quote PDF for an opportunity without storing it.",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/quotes/preview.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.quote.download",
|
||||
description:
|
||||
"Download a committed quote PDF. Each download is recorded with timestamp and user info.",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/quotes/download.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.quote.fetch_downloads",
|
||||
description:
|
||||
"Fetch download/print history for all quotes on an opportunity. Admin-level permission.",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.view_margin",
|
||||
description:
|
||||
"View margin and markup data on opportunity products. Controls visibility of margin %, markup %, and related progress bars in the UI.",
|
||||
usedIn: [],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.view_cost",
|
||||
description:
|
||||
"View cost data on opportunity products. Controls visibility of unit cost, total cost, and recurring cost in the UI.",
|
||||
usedIn: [],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.view_profit",
|
||||
description:
|
||||
"View profit data on opportunity products. Controls visibility of profit values in the UI.",
|
||||
usedIn: [],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.finalize",
|
||||
description:
|
||||
"Finalize an opportunity as Won or Lost. Without this permission, win/lose actions route to PendingWon/PendingLost instead.",
|
||||
usedIn: [
|
||||
"src/workflows/wf.opportunity.ts",
|
||||
"src/api/sales/opportunities/[id]/workflow/dispatch.ts",
|
||||
],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.cancel",
|
||||
description:
|
||||
"Cancel an opportunity. Required to transition any eligible opportunity to the Canceled status.",
|
||||
usedIn: [
|
||||
"src/workflows/wf.opportunity.ts",
|
||||
"src/api/sales/opportunities/[id]/workflow/dispatch.ts",
|
||||
],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.review",
|
||||
description:
|
||||
"Submit an opportunity for internal review. Required to transition an opportunity into the InternalReview status.",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.send",
|
||||
description:
|
||||
"Send a quote to the customer. Required to transition an opportunity to QuoteSent (and its compound transitions like immediate won/lost/confirmed).",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.reopen",
|
||||
description:
|
||||
"Re-open a cancelled opportunity. Required to transition an opportunity from Canceled back to Active.",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.win",
|
||||
description:
|
||||
"Mark an opportunity as won (or pending won). Gates the win button in the UI. Required for finalize(won), sendQuote(won), and transitionToPending(won).",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.lose",
|
||||
description:
|
||||
"Mark an opportunity as lost (or pending lost). Gates the lose button in the UI. Required for finalize(lost), sendQuote(lost), and transitionToPending(lost).",
|
||||
usedIn: ["src/workflows/wf.opportunity.ts"],
|
||||
dependencies: ["sales.opportunity.workflow"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.workflow",
|
||||
description:
|
||||
"Execute opportunity workflow actions (status transitions, review decisions, quote sending, etc.). Base gate for the workflow dispatch endpoint.",
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -940,6 +1138,7 @@ export const PERMISSION_NODES = {
|
||||
"obj.opportunity.site",
|
||||
"obj.opportunity.customerPO",
|
||||
"obj.opportunity.totalSalesTax",
|
||||
"obj.opportunity.probability",
|
||||
"obj.opportunity.location",
|
||||
"obj.opportunity.department",
|
||||
"obj.opportunity.expectedCloseDate",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user