Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dc3c7ce07 | |||
| e764932c39 | |||
| 33b34d08a7 | |||
| 5afda8cb34 | |||
| ee3e0a7377 | |||
| e294791858 | |||
| 97ac4a2173 | |||
| ad7507d133 | |||
| 15ef24eb3e | |||
| f53b390e18 | |||
| c0a4d4f919 |
@@ -14,6 +14,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: "1.3.6"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: "1.3.6"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|||||||
+910
-34
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 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
|
### Key design principles
|
||||||
|
|
||||||
- **Adaptive TTLs** — cache durations are computed dynamically based on how "hot" an opportunity is (recently updated = shorter TTL = fresher data).
|
- **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 |
|
| `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
|
## 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.
|
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`)
|
## 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/cwApiLogger.ts` | Axios interceptor for JSONL call logging |
|
||||||
| `src/modules/cw-utils/fetchCompany.ts` | Company fetch with retry |
|
| `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/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/constants.ts` | CW Axios instance config (timeout, logger) |
|
||||||
| `src/index.ts` | Refresh interval registration |
|
| `src/index.ts` | Refresh interval registration |
|
||||||
| `debug-scripts/analyze-cw-calls.py` | CW API call analysis script |
|
| `debug-scripts/analyze-cw-calls.py` | CW API call analysis script |
|
||||||
|
|||||||
+52
-28
@@ -23,13 +23,14 @@ The permission validator supports special tokens for flexible permission managem
|
|||||||
|
|
||||||
### Company Permissions
|
### Company Permissions
|
||||||
|
|
||||||
| Permission Node | Description | Used In |
|
| 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` | 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.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.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.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.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
|
### Credential Permissions
|
||||||
|
|
||||||
@@ -124,13 +125,16 @@ 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.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` |
|
| `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.
|
`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 |
|
| 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 |
|
| _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
|
### Sales Permissions
|
||||||
|
|
||||||
@@ -138,23 +142,43 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy
|
|||||||
|
|
||||||
**WebSocket note:** The `/secure` socket event chain `opp:live_quote_preview` and `opp:live_quote_preview:<id>:data` is gated by `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 |
|
| 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` | 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.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/opportunities/fetchAll.ts](src/api/sales/opportunities/fetchAll.ts), [src/api/sales/opportunities/count.ts](src/api/sales/opportunities/count.ts), [src/api/sales/opportunities/fetchTypes.ts](src/api/sales/opportunities/fetchTypes.ts) | |
|
||||||
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/opportunities/[id]/refresh.ts](src/api/sales/opportunities/[id]/refresh.ts) | `sales.opportunity.fetch` |
|
| `sales.opportunity.fetch.@me` | View the personal sales dashboard showing opportunities assigned to the current user | UI-only (client-side gate) | |
|
||||||
| `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.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.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.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.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.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.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.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.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.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.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.create` | Create a new opportunity in ConnectWise | [src/api/sales/opportunities/create.ts](src/api/sales/opportunities/create.ts) | |
|
||||||
| `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.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.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.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.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.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.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.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.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.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.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.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>
|
<details>
|
||||||
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
|
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
|
||||||
|
|||||||
@@ -72,3 +72,8 @@ export type Credential = Prisma.CredentialModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
|
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
|
||||||
|
/**
|
||||||
|
* Model CwMember
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CwMember = Prisma.CwMemberModel
|
||||||
|
|||||||
@@ -94,3 +94,8 @@ export type Credential = Prisma.CredentialModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
|
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
|
||||||
|
/**
|
||||||
|
* Model CwMember
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CwMember = Prisma.CwMemberModel
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -394,7 +394,8 @@ export const ModelName = {
|
|||||||
CredentialType: 'CredentialType',
|
CredentialType: 'CredentialType',
|
||||||
SecureValue: 'SecureValue',
|
SecureValue: 'SecureValue',
|
||||||
Credential: 'Credential',
|
Credential: 'Credential',
|
||||||
GeneratedQuotes: 'GeneratedQuotes'
|
GeneratedQuotes: 'GeneratedQuotes',
|
||||||
|
CwMember: 'CwMember'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||||
@@ -410,7 +411,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
omit: GlobalOmitOptions
|
omit: GlobalOmitOptions
|
||||||
}
|
}
|
||||||
meta: {
|
meta: {
|
||||||
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential" | "generatedQuotes"
|
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential" | "generatedQuotes" | "cwMember"
|
||||||
txIsolationLevel: TransactionIsolationLevel
|
txIsolationLevel: TransactionIsolationLevel
|
||||||
}
|
}
|
||||||
model: {
|
model: {
|
||||||
@@ -1228,6 +1229,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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: {
|
other: {
|
||||||
@@ -1412,6 +1487,7 @@ export const OpportunityScalarFieldEnum = {
|
|||||||
companyId: 'companyId',
|
companyId: 'companyId',
|
||||||
productSequence: 'productSequence',
|
productSequence: 'productSequence',
|
||||||
cwLastUpdated: 'cwLastUpdated',
|
cwLastUpdated: 'cwLastUpdated',
|
||||||
|
cwDateEntered: 'cwDateEntered',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
} as const
|
} as const
|
||||||
@@ -1477,6 +1553,23 @@ export const GeneratedQuotesScalarFieldEnum = {
|
|||||||
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
|
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 = {
|
export const SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@@ -1719,6 +1812,7 @@ export type GlobalOmitConfig = {
|
|||||||
secureValue?: Prisma.SecureValueOmit
|
secureValue?: Prisma.SecureValueOmit
|
||||||
credential?: Prisma.CredentialOmit
|
credential?: Prisma.CredentialOmit
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesOmit
|
generatedQuotes?: Prisma.GeneratedQuotesOmit
|
||||||
|
cwMember?: Prisma.CwMemberOmit
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Types for Logging */
|
/* Types for Logging */
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export const ModelName = {
|
|||||||
CredentialType: 'CredentialType',
|
CredentialType: 'CredentialType',
|
||||||
SecureValue: 'SecureValue',
|
SecureValue: 'SecureValue',
|
||||||
Credential: 'Credential',
|
Credential: 'Credential',
|
||||||
GeneratedQuotes: 'GeneratedQuotes'
|
GeneratedQuotes: 'GeneratedQuotes',
|
||||||
|
CwMember: 'CwMember'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||||
@@ -225,6 +226,7 @@ export const OpportunityScalarFieldEnum = {
|
|||||||
companyId: 'companyId',
|
companyId: 'companyId',
|
||||||
productSequence: 'productSequence',
|
productSequence: 'productSequence',
|
||||||
cwLastUpdated: 'cwLastUpdated',
|
cwLastUpdated: 'cwLastUpdated',
|
||||||
|
cwDateEntered: 'cwDateEntered',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
} as const
|
} as const
|
||||||
@@ -290,6 +292,23 @@ export const GeneratedQuotesScalarFieldEnum = {
|
|||||||
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
|
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 = {
|
export const SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ export type * from './models/CredentialType.ts'
|
|||||||
export type * from './models/SecureValue.ts'
|
export type * from './models/SecureValue.ts'
|
||||||
export type * from './models/Credential.ts'
|
export type * from './models/Credential.ts'
|
||||||
export type * from './models/GeneratedQuotes.ts'
|
export type * from './models/GeneratedQuotes.ts'
|
||||||
|
export type * from './models/CwMember.ts'
|
||||||
export type * from './commonInputTypes.ts'
|
export type * from './commonInputTypes.ts'
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -114,6 +114,7 @@ export type OpportunityMinAggregateOutputType = {
|
|||||||
closedByCwId: number | null
|
closedByCwId: number | null
|
||||||
companyId: string | null
|
companyId: string | null
|
||||||
cwLastUpdated: Date | null
|
cwLastUpdated: Date | null
|
||||||
|
cwDateEntered: Date | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
}
|
}
|
||||||
@@ -164,6 +165,7 @@ export type OpportunityMaxAggregateOutputType = {
|
|||||||
closedByCwId: number | null
|
closedByCwId: number | null
|
||||||
companyId: string | null
|
companyId: string | null
|
||||||
cwLastUpdated: Date | null
|
cwLastUpdated: Date | null
|
||||||
|
cwDateEntered: Date | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
}
|
}
|
||||||
@@ -215,6 +217,7 @@ export type OpportunityCountAggregateOutputType = {
|
|||||||
companyId: number
|
companyId: number
|
||||||
productSequence: number
|
productSequence: number
|
||||||
cwLastUpdated: number
|
cwLastUpdated: number
|
||||||
|
cwDateEntered: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
_all: number
|
_all: number
|
||||||
@@ -309,6 +312,7 @@ export type OpportunityMinAggregateInputType = {
|
|||||||
closedByCwId?: true
|
closedByCwId?: true
|
||||||
companyId?: true
|
companyId?: true
|
||||||
cwLastUpdated?: true
|
cwLastUpdated?: true
|
||||||
|
cwDateEntered?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
}
|
}
|
||||||
@@ -359,6 +363,7 @@ export type OpportunityMaxAggregateInputType = {
|
|||||||
closedByCwId?: true
|
closedByCwId?: true
|
||||||
companyId?: true
|
companyId?: true
|
||||||
cwLastUpdated?: true
|
cwLastUpdated?: true
|
||||||
|
cwDateEntered?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
}
|
}
|
||||||
@@ -410,6 +415,7 @@ export type OpportunityCountAggregateInputType = {
|
|||||||
companyId?: true
|
companyId?: true
|
||||||
productSequence?: true
|
productSequence?: true
|
||||||
cwLastUpdated?: true
|
cwLastUpdated?: true
|
||||||
|
cwDateEntered?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
_all?: true
|
_all?: true
|
||||||
@@ -548,6 +554,7 @@ export type OpportunityGroupByOutputType = {
|
|||||||
companyId: string | null
|
companyId: string | null
|
||||||
productSequence: number[]
|
productSequence: number[]
|
||||||
cwLastUpdated: Date | null
|
cwLastUpdated: Date | null
|
||||||
|
cwDateEntered: Date | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
_count: OpportunityCountAggregateOutputType | null
|
_count: OpportunityCountAggregateOutputType | null
|
||||||
@@ -622,6 +629,7 @@ export type OpportunityWhereInput = {
|
|||||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
||||||
@@ -675,6 +683,7 @@ export type OpportunityOrderByWithRelationInput = {
|
|||||||
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
productSequence?: Prisma.SortOrder
|
productSequence?: Prisma.SortOrder
|
||||||
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
cwDateEntered?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
|
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
|
||||||
@@ -731,6 +740,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
||||||
@@ -784,6 +794,7 @@ export type OpportunityOrderByWithAggregationInput = {
|
|||||||
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
productSequence?: Prisma.SortOrder
|
productSequence?: Prisma.SortOrder
|
||||||
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
cwDateEntered?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
_count?: Prisma.OpportunityCountOrderByAggregateInput
|
_count?: Prisma.OpportunityCountOrderByAggregateInput
|
||||||
@@ -843,6 +854,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
|
|||||||
companyId?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
companyId?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||||
cwLastUpdated?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
|
cwLastUpdated?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
||||||
}
|
}
|
||||||
@@ -893,6 +905,7 @@ export type OpportunityCreateInput = {
|
|||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
|
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
|
||||||
@@ -946,6 +959,7 @@ export type OpportunityUncheckedCreateInput = {
|
|||||||
companyId?: string | null
|
companyId?: string | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
|
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
|
||||||
@@ -997,6 +1011,7 @@ export type OpportunityUpdateInput = {
|
|||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
|
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
|
||||||
@@ -1050,6 +1065,7 @@ export type OpportunityUncheckedUpdateInput = {
|
|||||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
|
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
|
||||||
@@ -1102,6 +1118,7 @@ export type OpportunityCreateManyInput = {
|
|||||||
companyId?: string | null
|
companyId?: string | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
}
|
}
|
||||||
@@ -1152,6 +1169,7 @@ export type OpportunityUpdateManyMutationInput = {
|
|||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
}
|
}
|
||||||
@@ -1203,6 +1221,7 @@ export type OpportunityUncheckedUpdateManyInput = {
|
|||||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
}
|
}
|
||||||
@@ -1272,6 +1291,7 @@ export type OpportunityCountOrderByAggregateInput = {
|
|||||||
companyId?: Prisma.SortOrder
|
companyId?: Prisma.SortOrder
|
||||||
productSequence?: Prisma.SortOrder
|
productSequence?: Prisma.SortOrder
|
||||||
cwLastUpdated?: Prisma.SortOrder
|
cwLastUpdated?: Prisma.SortOrder
|
||||||
|
cwDateEntered?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
@@ -1343,6 +1363,7 @@ export type OpportunityMaxOrderByAggregateInput = {
|
|||||||
closedByCwId?: Prisma.SortOrder
|
closedByCwId?: Prisma.SortOrder
|
||||||
companyId?: Prisma.SortOrder
|
companyId?: Prisma.SortOrder
|
||||||
cwLastUpdated?: Prisma.SortOrder
|
cwLastUpdated?: Prisma.SortOrder
|
||||||
|
cwDateEntered?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
@@ -1393,6 +1414,7 @@ export type OpportunityMinOrderByAggregateInput = {
|
|||||||
closedByCwId?: Prisma.SortOrder
|
closedByCwId?: Prisma.SortOrder
|
||||||
companyId?: Prisma.SortOrder
|
companyId?: Prisma.SortOrder
|
||||||
cwLastUpdated?: Prisma.SortOrder
|
cwLastUpdated?: Prisma.SortOrder
|
||||||
|
cwDateEntered?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
@@ -1534,6 +1556,7 @@ export type OpportunityCreateWithoutCompanyInput = {
|
|||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
|
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
|
||||||
@@ -1585,6 +1608,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
|
|||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
|
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
|
||||||
@@ -1666,6 +1690,7 @@ export type OpportunityScalarWhereInput = {
|
|||||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
}
|
}
|
||||||
@@ -1716,6 +1741,7 @@ export type OpportunityCreateWithoutGeneratedQuotesInput = {
|
|||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
|
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
|
||||||
@@ -1768,6 +1794,7 @@ export type OpportunityUncheckedCreateWithoutGeneratedQuotesInput = {
|
|||||||
companyId?: string | null
|
companyId?: string | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
}
|
}
|
||||||
@@ -1834,6 +1861,7 @@ export type OpportunityUpdateWithoutGeneratedQuotesInput = {
|
|||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
|
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
|
||||||
@@ -1886,6 +1914,7 @@ export type OpportunityUncheckedUpdateWithoutGeneratedQuotesInput = {
|
|||||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
}
|
}
|
||||||
@@ -1936,6 +1965,7 @@ export type OpportunityCreateManyCompanyInput = {
|
|||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
}
|
}
|
||||||
@@ -1986,6 +2016,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
|
|||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
|
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
|
||||||
@@ -2037,6 +2068,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
|
|||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
|
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
|
||||||
@@ -2088,6 +2120,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
|
|||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
}
|
}
|
||||||
@@ -2170,6 +2203,7 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
|||||||
companyId?: boolean
|
companyId?: boolean
|
||||||
productSequence?: boolean
|
productSequence?: boolean
|
||||||
cwLastUpdated?: boolean
|
cwLastUpdated?: boolean
|
||||||
|
cwDateEntered?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
|
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
|
||||||
@@ -2224,6 +2258,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
|||||||
companyId?: boolean
|
companyId?: boolean
|
||||||
productSequence?: boolean
|
productSequence?: boolean
|
||||||
cwLastUpdated?: boolean
|
cwLastUpdated?: boolean
|
||||||
|
cwDateEntered?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||||
@@ -2276,6 +2311,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
|||||||
companyId?: boolean
|
companyId?: boolean
|
||||||
productSequence?: boolean
|
productSequence?: boolean
|
||||||
cwLastUpdated?: boolean
|
cwLastUpdated?: boolean
|
||||||
|
cwDateEntered?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||||
@@ -2328,11 +2364,12 @@ export type OpportunitySelectScalar = {
|
|||||||
companyId?: boolean
|
companyId?: boolean
|
||||||
productSequence?: boolean
|
productSequence?: boolean
|
||||||
cwLastUpdated?: boolean
|
cwLastUpdated?: boolean
|
||||||
|
cwDateEntered?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "probability" | "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> = {
|
export type OpportunityInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
|
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
|
||||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||||
@@ -2398,6 +2435,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
|||||||
companyId: string | null
|
companyId: string | null
|
||||||
productSequence: number[]
|
productSequence: number[]
|
||||||
cwLastUpdated: Date | null
|
cwLastUpdated: Date | null
|
||||||
|
cwDateEntered: Date | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
}, ExtArgs["result"]["opportunity"]>
|
}, ExtArgs["result"]["opportunity"]>
|
||||||
@@ -2871,6 +2909,7 @@ export interface OpportunityFieldRefs {
|
|||||||
readonly companyId: Prisma.FieldRef<"Opportunity", 'String'>
|
readonly companyId: Prisma.FieldRef<"Opportunity", 'String'>
|
||||||
readonly productSequence: Prisma.FieldRef<"Opportunity", 'Int[]'>
|
readonly productSequence: Prisma.FieldRef<"Opportunity", 'Int[]'>
|
||||||
readonly cwLastUpdated: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
readonly cwLastUpdated: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||||
|
readonly cwDateEntered: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||||
readonly createdAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
readonly createdAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||||
readonly updatedAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
readonly updatedAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -194,6 +194,7 @@ model Opportunity {
|
|||||||
productSequence Int[] @default([])
|
productSequence Int[] @default([])
|
||||||
|
|
||||||
cwLastUpdated DateTime?
|
cwLastUpdated DateTime?
|
||||||
|
cwDateEntered DateTime?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -270,3 +271,20 @@ model GeneratedQuotes {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 fetchAll } from "./fetchAll";
|
||||||
import { default as fetch } from "./[id]/fetch";
|
import { default as fetch } from "./[id]/fetch";
|
||||||
import { default as configurations } from "./[id]/configurations";
|
import { default as configurations } from "./[id]/configurations";
|
||||||
|
import { default as sites } from "./[id]/sites";
|
||||||
import { default as unifiSites } from "./[id]/unifiSites";
|
import { default as unifiSites } from "./[id]/unifiSites";
|
||||||
import { default as count } from "./count";
|
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 callback } from "./callback";
|
||||||
|
import { default as fetchMembers } from "./fetchMembers";
|
||||||
|
|
||||||
export { callback };
|
export { callback, fetchMembers };
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ import {
|
|||||||
} from "../../../modules/cache/opportunityCache";
|
} from "../../../modules/cache/opportunityCache";
|
||||||
import { generatedQuotes } from "../../../managers/generatedQuotes";
|
import { generatedQuotes } from "../../../managers/generatedQuotes";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
|
/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier"],
|
["/opportunities/opportunity/:identifier"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const includeParam = c.req.query("include") ?? "";
|
const includeParam = c.req.query("include") ?? "";
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { default as fetchAll } from "./opportunities/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 fetchOpportunityTypes } from "./fetchOpportunityTypes";
|
||||||
import { default as count } from "./opportunities/count";
|
import { default as count } from "./opportunities/count";
|
||||||
import { default as fetch } from "./opportunities/[id]/fetch";
|
import { default as fetch } from "./opportunities/[id]/fetch";
|
||||||
import { default as refresh } from "./opportunities/[id]/refresh";
|
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 products } from "./opportunities/[id]/products/fetchAll";
|
||||||
import { default as addProduct } from "./opportunities/[id]/products/add";
|
import { default as addProduct } from "./opportunities/[id]/products/add";
|
||||||
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
|
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
|
||||||
@@ -11,6 +15,7 @@ import { default as laborOptions } from "./opportunities/[id]/products/laborOpti
|
|||||||
import { default as resequenceProducts } from "./opportunities/[id]/products/resequence";
|
import { default as resequenceProducts } from "./opportunities/[id]/products/resequence";
|
||||||
import { default as updateProduct } from "./opportunities/[id]/products/update";
|
import { default as updateProduct } from "./opportunities/[id]/products/update";
|
||||||
import { default as cancelProduct } from "./opportunities/[id]/products/cancel";
|
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 notes } from "./opportunities/[id]/notes/fetchAll";
|
||||||
import { default as fetchNote } from "./opportunities/[id]/notes/fetch";
|
import { default as fetchNote } from "./opportunities/[id]/notes/fetch";
|
||||||
import { default as createNote } from "./opportunities/[id]/notes/create";
|
import { default as createNote } from "./opportunities/[id]/notes/create";
|
||||||
@@ -22,13 +27,23 @@ import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll";
|
|||||||
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
|
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
|
||||||
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
|
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
|
||||||
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
|
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 {
|
export {
|
||||||
addProduct,
|
addProduct,
|
||||||
|
fetchByUser,
|
||||||
|
fetchByUserId,
|
||||||
addLabor,
|
addLabor,
|
||||||
laborOptions,
|
laborOptions,
|
||||||
addSpecialOrderProduct,
|
addSpecialOrderProduct,
|
||||||
count,
|
count,
|
||||||
|
createOpportunity,
|
||||||
|
deleteOpportunity,
|
||||||
|
metrics,
|
||||||
fetch,
|
fetch,
|
||||||
fetchAll,
|
fetchAll,
|
||||||
fetchOpportunityTypes,
|
fetchOpportunityTypes,
|
||||||
@@ -36,6 +51,7 @@ export {
|
|||||||
resequenceProducts,
|
resequenceProducts,
|
||||||
updateProduct,
|
updateProduct,
|
||||||
cancelProduct,
|
cancelProduct,
|
||||||
|
deleteProduct,
|
||||||
notes,
|
notes,
|
||||||
fetchNote,
|
fetchNote,
|
||||||
createNote,
|
createNote,
|
||||||
@@ -48,4 +64,8 @@ export {
|
|||||||
downloadQuote,
|
downloadQuote,
|
||||||
fetchDownloads,
|
fetchDownloads,
|
||||||
refresh,
|
refresh,
|
||||||
|
updateOpportunity,
|
||||||
|
workflowDispatch,
|
||||||
|
workflowStatus,
|
||||||
|
workflowHistory,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../middleware/authorization";
|
import { authMiddleware } from "../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/contacts */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/contacts */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/contacts"],
|
["/opportunities/opportunity/:identifier/contacts"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const item = await opportunities.fetchRecord(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"] }),
|
||||||
|
);
|
||||||
@@ -20,10 +20,10 @@ import {
|
|||||||
} from "../../../../modules/cache/opportunityCache";
|
} from "../../../../modules/cache/opportunityCache";
|
||||||
import { generatedQuotes } from "../../../../managers/generatedQuotes";
|
import { generatedQuotes } from "../../../../managers/generatedQuotes";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
|
/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier"],
|
["/opportunities/opportunity/:identifier"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const includeParam = c.req.query("include") ?? "";
|
const includeParam = c.req.query("include") ?? "";
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { authMiddleware } from "../../../../middleware/authorization";
|
|||||||
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/* POST /v1/sales/opportunities/:identifier/notes */
|
/* POST /v1/sales/opportunities/opportunity/:identifier/notes */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"post",
|
"post",
|
||||||
["/opportunities/:identifier/notes"],
|
["/opportunities/opportunity/:identifier/notes"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
|
|||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
import GenericError from "../../../../../Errors/GenericError";
|
import GenericError from "../../../../../Errors/GenericError";
|
||||||
|
|
||||||
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
|
/* DELETE /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"delete",
|
"delete",
|
||||||
["/opportunities/:identifier/notes/:noteId"],
|
["/opportunities/opportunity/:identifier/notes/:noteId"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const noteId = Number(c.req.param("noteId"));
|
const noteId = Number(c.req.param("noteId"));
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
|
|||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
import GenericError from "../../../../../Errors/GenericError";
|
import GenericError from "../../../../../Errors/GenericError";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/notes/:noteId"],
|
["/opportunities/opportunity/:identifier/notes/:noteId"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const noteId = Number(c.req.param("noteId"));
|
const noteId = Number(c.req.param("noteId"));
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/notes */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/notes */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/notes"],
|
["/opportunities/opportunity/:identifier/notes"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const item = await opportunities.fetchRecord(identifier);
|
const item = await opportunities.fetchRecord(identifier);
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import GenericError from "../../../../../Errors/GenericError";
|
|||||||
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */
|
/* PATCH /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"patch",
|
"patch",
|
||||||
["/opportunities/:identifier/notes/:noteId"],
|
["/opportunities/opportunity/:identifier/notes/:noteId"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const noteId = Number(c.req.param("noteId"));
|
const noteId = Number(c.req.param("noteId"));
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const productItemSchema = z
|
|||||||
catalogItem: z.object({ id: z.number().int().positive() }).optional(),
|
catalogItem: z.object({ id: z.number().int().positive() }).optional(),
|
||||||
forecastDescription: z.string().optional(),
|
forecastDescription: z.string().optional(),
|
||||||
productDescription: z.string().optional(),
|
productDescription: z.string().optional(),
|
||||||
|
customerDescription: z.string().nullable().optional(),
|
||||||
quantity: z.number().positive().optional(),
|
quantity: z.number().positive().optional(),
|
||||||
status: z.object({ id: z.number().int().positive() }).optional(),
|
status: z.object({ id: z.number().int().positive() }).optional(),
|
||||||
productClass: z.string().optional(),
|
productClass: z.string().optional(),
|
||||||
@@ -33,10 +34,10 @@ const addProductSchema = z.union([
|
|||||||
z.array(productItemSchema).min(1, "At least one product is required"),
|
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(
|
export default createRoute(
|
||||||
"post",
|
"post",
|
||||||
["/opportunities/:identifier/products"],
|
["/opportunities/opportunity/:identifier/products"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
@@ -54,7 +55,40 @@ export default createRoute(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const item = await opportunities.fetchRecord(identifier);
|
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 isBatch = Array.isArray(body);
|
||||||
const response = apiResponse.created(
|
const response = apiResponse.created(
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ const addLaborSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
/* POST /v1/sales/opportunities/:identifier/products/labor */
|
/* POST /v1/sales/opportunities/opportunity/:identifier/products/labor */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"post",
|
"post",
|
||||||
["/opportunities/:identifier/products/labor"],
|
["/opportunities/opportunity/:identifier/products/labor"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ const addSpecialOrderSchema = z.union([
|
|||||||
.min(1, "At least one special-order product is required"),
|
.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(
|
export default createRoute(
|
||||||
"post",
|
"post",
|
||||||
["/opportunities/:identifier/products/special-order"],
|
["/opportunities/opportunity/:identifier/products/special-order"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ const cancelProductSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/cancel */
|
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/cancel */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"patch",
|
"patch",
|
||||||
["/opportunities/:identifier/products/:productId/cancel"],
|
["/opportunities/opportunity/:identifier/products/:productId/cancel"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const productId = Number(c.req.param("productId"));
|
const productId = Number(c.req.param("productId"));
|
||||||
|
|||||||
@@ -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"] }),
|
||||||
|
);
|
||||||
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/products */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/products */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/products"],
|
["/opportunities/opportunity/:identifier/products"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const item = await opportunities.fetchRecord(identifier);
|
const item = await opportunities.fetchRecord(identifier);
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/products/labor/options */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/products/labor/options */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/products/labor/options"],
|
["/opportunities/opportunity/:identifier/products/labor/options"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
|
|||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */
|
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/sequence */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"patch",
|
"patch",
|
||||||
["/opportunities/:identifier/products/sequence"],
|
["/opportunities/opportunity/:identifier/products/sequence"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const updateProductSchema = z
|
|||||||
customerDescription: z.string().nullable().optional(),
|
customerDescription: z.string().nullable().optional(),
|
||||||
productNarrative: z.string().nullable().optional(),
|
productNarrative: z.string().nullable().optional(),
|
||||||
procurementNotes: z.string().nullable().optional(),
|
procurementNotes: z.string().nullable().optional(),
|
||||||
|
taxableFlag: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
@@ -55,10 +56,10 @@ const upsertCustomTextField = (
|
|||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/edit */
|
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/edit */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"patch",
|
"patch",
|
||||||
["/opportunities/:identifier/products/:productId/edit"],
|
["/opportunities/opportunity/:identifier/products/:productId/edit"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const productId = Number(c.req.param("productId"));
|
const productId = Number(c.req.param("productId"));
|
||||||
@@ -98,12 +99,6 @@ export default createRoute(
|
|||||||
if (input.quantity !== undefined) {
|
if (input.quantity !== undefined) {
|
||||||
forecastPatch.quantity = input.quantity;
|
forecastPatch.quantity = input.quantity;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
input.customerDescription !== undefined &&
|
|
||||||
input.customerDescription !== null
|
|
||||||
) {
|
|
||||||
forecastPatch.customerDescription = input.customerDescription;
|
|
||||||
}
|
|
||||||
if (input.unitPrice !== undefined) {
|
if (input.unitPrice !== undefined) {
|
||||||
forecastPatch.revenue = Number(
|
forecastPatch.revenue = Number(
|
||||||
(input.unitPrice * effectiveQuantity).toFixed(2),
|
(input.unitPrice * effectiveQuantity).toFixed(2),
|
||||||
@@ -114,6 +109,9 @@ export default createRoute(
|
|||||||
(input.unitCost * effectiveQuantity).toFixed(2),
|
(input.unitCost * effectiveQuantity).toFixed(2),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (input.taxableFlag !== undefined) {
|
||||||
|
forecastPatch.taxableFlag = input.taxableFlag;
|
||||||
|
}
|
||||||
|
|
||||||
const existingProcurement =
|
const existingProcurement =
|
||||||
await opportunity.fetchProcurementProductByForecastItem(productId);
|
await opportunity.fetchProcurementProductByForecastItem(productId);
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { cwMembers } from "../../../../../managers/cwMembers";
|
||||||
|
import {
|
||||||
|
createWorkflowActivity,
|
||||||
|
OptimaType,
|
||||||
|
} from "../../../../../workflows/wf.opportunity";
|
||||||
|
|
||||||
const commitQuoteSchema = z
|
const commitQuoteSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -14,10 +19,10 @@ const commitQuoteSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
/* POST /v1/sales/opportunities/:identifier/quote/commit */
|
/* POST /v1/sales/opportunities/opportunity/:identifier/quote/commit */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"post",
|
"post",
|
||||||
["/opportunities/:identifier/quote/commit"],
|
["/opportunities/opportunity/:identifier/quote/commit"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json().catch(() => undefined);
|
const body = await c.req.json().catch(() => undefined);
|
||||||
@@ -29,6 +34,34 @@ export default createRoute(
|
|||||||
|
|
||||||
const quote = await item.commitQuote(opts ?? {}, 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(
|
const response = apiResponse.created(
|
||||||
"Quote committed successfully!",
|
"Quote committed successfully!",
|
||||||
quote.toJson({ includeRegenData: true, includeRegenParams: true }),
|
quote.toJson({ includeRegenData: true, includeRegenParams: true }),
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import GenericError from "../../../../../Errors/GenericError";
|
|||||||
const VALID_FETCH_ACTIONS = ["download", "print"] as const;
|
const VALID_FETCH_ACTIONS = ["download", "print"] as const;
|
||||||
type FetchAction = (typeof VALID_FETCH_ACTIONS)[number];
|
type FetchAction = (typeof VALID_FETCH_ACTIONS)[number];
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/download?fetchAction=download|print */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/download?fetchAction=download|print */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/quote/:quoteId/download"],
|
["/opportunities/opportunity/:identifier/quote/:quoteId/download"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const quoteId = c.req.param("quoteId");
|
const quoteId = c.req.param("quoteId");
|
||||||
const user = c.get("user");
|
const user = c.get("user");
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/quotes */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/quotes */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/quotes"],
|
["/opportunities/opportunity/:identifier/quotes"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const includeRegenData = c.req.query("includeRegenData") === "true";
|
const includeRegenData = c.req.query("includeRegenData") === "true";
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/quotes/downloads */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/quotes/downloads */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/quotes/downloads"],
|
["/opportunities/opportunity/:identifier/quotes/downloads"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/preview */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/preview */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/quote/:quoteId/preview"],
|
["/opportunities/opportunity/:identifier/quote/:quoteId/preview"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const quoteId = c.req.param("quoteId");
|
const quoteId = c.req.param("quoteId");
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../middleware/authorization";
|
import { authMiddleware } from "../../../middleware/authorization";
|
||||||
|
|
||||||
/* POST /v1/sales/opportunities/:identifier/refresh */
|
/* POST /v1/sales/opportunities/opportunity/:identifier/refresh */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"post",
|
"post",
|
||||||
["/opportunities/:identifier/refresh"],
|
["/opportunities/opportunity/:identifier/refresh"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const item = await opportunities.fetchItem(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"] }),
|
||||||
|
);
|
||||||
@@ -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"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/@me */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/@me"],
|
||||||
|
async (c) => {
|
||||||
|
const user = c.get("user");
|
||||||
|
const includeClosed = c.req.query("includeClosed") === "true";
|
||||||
|
|
||||||
|
const data = await opportunities.fetchByUser(user.id, { includeClosed });
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Opportunities fetched successfully!",
|
||||||
|
data.map((item) => item.toJson()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
|
||||||
|
);
|
||||||
@@ -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,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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
CWForecastItemCreate,
|
CWForecastItemCreate,
|
||||||
CWOpportunity,
|
CWOpportunity,
|
||||||
CWOpportunityNote,
|
CWOpportunityNote,
|
||||||
|
CWOpportunityUpdate,
|
||||||
CWProcurementProduct,
|
CWProcurementProduct,
|
||||||
CWProcurementProductCreate,
|
CWProcurementProductCreate,
|
||||||
} from "../modules/cw-utils/opportunities/opportunity.types";
|
} from "../modules/cw-utils/opportunities/opportunity.types";
|
||||||
@@ -44,6 +45,8 @@ import {
|
|||||||
type QuoteMetadata,
|
type QuoteMetadata,
|
||||||
} from "../modules/pdf-utils";
|
} from "../modules/pdf-utils";
|
||||||
import { generatedQuotes } from "../managers/generatedQuotes";
|
import { generatedQuotes } from "../managers/generatedQuotes";
|
||||||
|
import { getExpectedSalesTaxRate } from "../modules/sales-utils/expectedSalesTax";
|
||||||
|
import { normalizeProbabilityPercent } from "../modules/sales-utils/normalizeProbability";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opportunity Controller
|
* Opportunity Controller
|
||||||
@@ -105,6 +108,7 @@ export class OpportunityController {
|
|||||||
|
|
||||||
public companyId: string | null;
|
public companyId: string | null;
|
||||||
public cwLastUpdated: Date | null;
|
public cwLastUpdated: Date | null;
|
||||||
|
public cwDateEntered: Date | null;
|
||||||
|
|
||||||
// Local product display order — array of CW forecast item IDs.
|
// Local product display order — array of CW forecast item IDs.
|
||||||
// When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
|
// When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
|
||||||
@@ -222,6 +226,7 @@ export class OpportunityController {
|
|||||||
|
|
||||||
this.companyId = data.companyId;
|
this.companyId = data.companyId;
|
||||||
this.cwLastUpdated = data.cwLastUpdated;
|
this.cwLastUpdated = data.cwLastUpdated;
|
||||||
|
this.cwDateEntered = data.cwDateEntered ?? null;
|
||||||
this.productSequence = data.productSequence;
|
this.productSequence = data.productSequence;
|
||||||
|
|
||||||
this.createdAt = data.createdAt;
|
this.createdAt = data.createdAt;
|
||||||
@@ -292,6 +297,30 @@ export class OpportunityController {
|
|||||||
return new OpportunityController(updated);
|
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
|
* Fetch raw CW data
|
||||||
*
|
*
|
||||||
@@ -341,7 +370,7 @@ export class OpportunityController {
|
|||||||
customerPO: item.customerPO ?? null,
|
customerPO: item.customerPO ?? null,
|
||||||
|
|
||||||
totalSalesTax: item.totalSalesTax ?? 0,
|
totalSalesTax: item.totalSalesTax ?? 0,
|
||||||
probability: Number(item.probability?.name) || 0,
|
probability: normalizeProbabilityPercent(item.probability?.name),
|
||||||
|
|
||||||
locationName: item.location?.name ?? null,
|
locationName: item.location?.name ?? null,
|
||||||
locationCwId: item.location?.id ?? null,
|
locationCwId: item.location?.id ?? null,
|
||||||
@@ -365,6 +394,9 @@ export class OpportunityController {
|
|||||||
cwLastUpdated: item._info?.lastUpdated
|
cwLastUpdated: item._info?.lastUpdated
|
||||||
? new Date(item._info.lastUpdated)
|
? new Date(item._info.lastUpdated)
|
||||||
: new Date(),
|
: new Date(),
|
||||||
|
cwDateEntered: item._info?.dateEntered
|
||||||
|
? new Date(item._info.dateEntered)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,6 +697,17 @@ export class OpportunityController {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const taxableSubTotal = activeProducts.reduce((sum, item) => {
|
||||||
|
if (!item.taxableFlag) return sum;
|
||||||
|
|
||||||
|
const isLabor = item.productClass === "Service";
|
||||||
|
const quantity = item.effectiveQuantity > 0 ? item.effectiveQuantity : 1;
|
||||||
|
const lineTotal = Number.isFinite(item.revenue) ? item.revenue : 0;
|
||||||
|
const unitPrice = isLabor ? lineTotal : lineTotal / quantity;
|
||||||
|
|
||||||
|
return sum + (isLabor ? 1 : quantity) * unitPrice;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
const quoteDescription = this.name;
|
const quoteDescription = this.name;
|
||||||
|
|
||||||
const primaryContactFullName = [
|
const primaryContactFullName = [
|
||||||
@@ -680,12 +723,9 @@ export class OpportunityController {
|
|||||||
this.companyName ||
|
this.companyName ||
|
||||||
"Customer";
|
"Customer";
|
||||||
|
|
||||||
const subTotal = lineItems.reduce(
|
const normalizedTaxRate = getExpectedSalesTaxRate(
|
||||||
(sum, item) => sum + item.qty * item.unitPrice,
|
site?.address ?? companyJson?.cw_Data?.address,
|
||||||
0,
|
|
||||||
);
|
);
|
||||||
const normalizedTaxRate =
|
|
||||||
subTotal > 0 ? Math.max(0, this.totalSalesTax / subTotal) : 0;
|
|
||||||
const taxLabel =
|
const taxLabel =
|
||||||
normalizedTaxRate > 0
|
normalizedTaxRate > 0
|
||||||
? `Sales Tax (${(normalizedTaxRate * 100).toFixed(2)}%)`
|
? `Sales Tax (${(normalizedTaxRate * 100).toFixed(2)}%)`
|
||||||
@@ -693,11 +733,20 @@ export class OpportunityController {
|
|||||||
|
|
||||||
await this._hydrateCustomFields();
|
await this._hydrateCustomFields();
|
||||||
|
|
||||||
const quoteNarrative = options.includeQuoteNarrative
|
const quoteNarrativeField = options.includeQuoteNarrative
|
||||||
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
|
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
|
||||||
undefined
|
undefined
|
||||||
: 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);
|
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
|
||||||
|
|
||||||
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
|
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
|
||||||
@@ -744,6 +793,7 @@ export class OpportunityController {
|
|||||||
description: quoteDescription,
|
description: quoteDescription,
|
||||||
},
|
},
|
||||||
lineItems,
|
lineItems,
|
||||||
|
taxableSubtotal: taxableSubTotal,
|
||||||
quoteNarrative,
|
quoteNarrative,
|
||||||
tax: {
|
tax: {
|
||||||
rate: normalizedTaxRate,
|
rate: normalizedTaxRate,
|
||||||
@@ -793,11 +843,18 @@ export class OpportunityController {
|
|||||||
const salesRep = await this._resolveSalesRep();
|
const salesRep = await this._resolveSalesRep();
|
||||||
await this._hydrateCustomFields();
|
await this._hydrateCustomFields();
|
||||||
|
|
||||||
const quoteNarrative = quoteOptions.includeQuoteNarrative
|
const quoteNarrativeField = quoteOptions.includeQuoteNarrative
|
||||||
? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ??
|
? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ??
|
||||||
null)
|
null)
|
||||||
: 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 ───────────────────
|
// ── Pre-generate IDs & timestamps for metadata ───────────────────
|
||||||
const quoteId = crypto.randomUUID();
|
const quoteId = crypto.randomUUID();
|
||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
@@ -1326,6 +1383,35 @@ 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
|
* Fetch Procurement Product By Forecast Item
|
||||||
*
|
*
|
||||||
@@ -1472,6 +1558,21 @@ export class OpportunityController {
|
|||||||
* Serializes the opportunity into a safe, API-friendly object.
|
* Serializes the opportunity into a safe, API-friendly object.
|
||||||
*/
|
*/
|
||||||
public toJson(): Record<string, any> {
|
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 {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
cwOpportunityId: this.cwOpportunityId,
|
cwOpportunityId: this.cwOpportunityId,
|
||||||
@@ -1527,6 +1628,7 @@ export class OpportunityController {
|
|||||||
: null,
|
: null,
|
||||||
customerPO: this.customerPO,
|
customerPO: this.customerPO,
|
||||||
totalSalesTax: this.totalSalesTax,
|
totalSalesTax: this.totalSalesTax,
|
||||||
|
expectedSalesTax,
|
||||||
probability: this.probability,
|
probability: this.probability,
|
||||||
location: this.locationCwId
|
location: this.locationCwId
|
||||||
? { id: this.locationCwId, name: this.locationName }
|
? { id: this.locationCwId, name: this.locationName }
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventor
|
|||||||
import { listenInventoryAdjustments } from "./modules/cw-utils/procurement/listenInventoryAdjustments";
|
import { listenInventoryAdjustments } from "./modules/cw-utils/procurement/listenInventoryAdjustments";
|
||||||
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
|
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
|
||||||
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
|
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
|
||||||
|
import { refreshSalesOpportunityMetricsCache } from "./modules/cache/salesOpportunityMetricsCache";
|
||||||
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
|
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
|
||||||
|
import { refreshCwMembers } from "./modules/cw-utils/members/refreshCwMembers";
|
||||||
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
|
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
|
||||||
import { events, setupEventDebugger } from "./modules/globalEvents";
|
import { events, setupEventDebugger } from "./modules/globalEvents";
|
||||||
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
||||||
@@ -177,6 +179,22 @@ setInterval(
|
|||||||
5 * 60 * 1000,
|
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
|
// Refresh CW identifiers for all users every 30 minutes
|
||||||
await safeStartup("refreshCwIdentifiers", refreshCwIdentifiers);
|
await safeStartup("refreshCwIdentifiers", refreshCwIdentifiers);
|
||||||
setInterval(
|
setInterval(
|
||||||
@@ -188,6 +206,17 @@ setInterval(
|
|||||||
30 * 60 * 1000,
|
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());
|
await safeStartup("syncSites", () => unifiSites.syncSites());
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
return unifiSites
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { OpportunityController } from "../controllers/OpportunityController";
|
|||||||
import GenericError from "../Errors/GenericError";
|
import GenericError from "../Errors/GenericError";
|
||||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||||
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
||||||
|
import { CWOpportunityCreate } from "../modules/cw-utils/opportunities/opportunity.types";
|
||||||
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
|
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
|
||||||
import {
|
import {
|
||||||
getCachedActivities,
|
getCachedActivities,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
fetchAndCacheActivities,
|
fetchAndCacheActivities,
|
||||||
fetchAndCacheCompanyCwData,
|
fetchAndCacheCompanyCwData,
|
||||||
fetchAndCacheOppCwData,
|
fetchAndCacheOppCwData,
|
||||||
|
invalidateAllOpportunityCaches,
|
||||||
} from "../modules/cache/opportunityCache";
|
} from "../modules/cache/opportunityCache";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -127,6 +129,45 @@ async function buildActivities(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const opportunities = {
|
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)
|
* Fetch Record (lightweight)
|
||||||
*
|
*
|
||||||
@@ -310,7 +351,7 @@ export const opportunities = {
|
|||||||
const skip = (Math.max(page, 1) - 1) * rpp;
|
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||||
|
|
||||||
const items = await prisma.opportunity.findMany({
|
const items = await prisma.opportunity.findMany({
|
||||||
where: opts?.includeClosed ? undefined : { closedFlag: false },
|
where: opts?.includeClosed ? undefined : { closedDate: null },
|
||||||
include: { company: true },
|
include: { company: true },
|
||||||
skip,
|
skip,
|
||||||
take: rpp,
|
take: rpp,
|
||||||
@@ -360,7 +401,7 @@ export const opportunities = {
|
|||||||
|
|
||||||
const items = await prisma.opportunity.findMany({
|
const items = await prisma.opportunity.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(opts?.includeClosed ? {} : { closedFlag: false }),
|
...(opts?.includeClosed ? {} : { closedDate: null }),
|
||||||
OR: [
|
OR: [
|
||||||
{ name: { contains: query, mode: "insensitive" } },
|
{ name: { contains: query, mode: "insensitive" } },
|
||||||
{ companyName: { contains: query, mode: "insensitive" } },
|
{ companyName: { contains: query, mode: "insensitive" } },
|
||||||
@@ -404,7 +445,7 @@ export const opportunities = {
|
|||||||
*/
|
*/
|
||||||
async count(opts?: { openOnly?: boolean }): Promise<number> {
|
async count(opts?: { openOnly?: boolean }): Promise<number> {
|
||||||
return prisma.opportunity.count({
|
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({
|
return prisma.opportunity.count({
|
||||||
where: {
|
where: {
|
||||||
...(opts?.includeClosed ? {} : { closedFlag: false }),
|
...(opts?.includeClosed ? {} : { closedDate: null }),
|
||||||
OR: [
|
OR: [
|
||||||
{ name: { contains: query, mode: "insensitive" } },
|
{ name: { contains: query, mode: "insensitive" } },
|
||||||
{ companyName: { contains: query, mode: "insensitive" } },
|
{ companyName: { contains: query, mode: "insensitive" } },
|
||||||
@@ -463,7 +504,7 @@ export const opportunities = {
|
|||||||
const items = await prisma.opportunity.findMany({
|
const items = await prisma.opportunity.findMany({
|
||||||
where: {
|
where: {
|
||||||
companyId,
|
companyId,
|
||||||
...(opts?.includeClosed ? {} : { closedFlag: false }),
|
...(opts?.includeClosed ? {} : { closedDate: null }),
|
||||||
},
|
},
|
||||||
include: { company: true },
|
include: { company: true },
|
||||||
orderBy: { expectedCloseDate: "asc" },
|
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);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const users = {
|
|||||||
const newUser = await prisma.user.create({
|
const newUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
userId: msData.id,
|
userId: msData.id,
|
||||||
email: msData.mail,
|
email: msData.mail ?? msData.userPrincipalName,
|
||||||
name: `${msData.givenName} ${msData.surname}`,
|
name: `${msData.givenName} ${msData.surname}`,
|
||||||
login: msData.userPrincipalName,
|
login: msData.userPrincipalName,
|
||||||
cwIdentifier,
|
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));
|
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
|
* Site TTL — 20 minutes. Site/address data rarely changes so we cache
|
||||||
* aggressively. The background refresh does NOT proactively warm site keys;
|
* 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
|
* Fetches every member from ConnectWise using pagination and returns them
|
||||||
* in a Collection keyed by their identifier (e.g. "jroberts").
|
* 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
|
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
|
||||||
*/
|
*/
|
||||||
export const fetchAllCwMembers = async (): Promise<
|
export const fetchAllCwMembers = async (opts?: {
|
||||||
Collection<string, CWMember>
|
conditions?: string;
|
||||||
> => {
|
}): Promise<Collection<string, CWMember>> => {
|
||||||
const members = new Collection<string, CWMember>();
|
const members = new Collection<string, CWMember>();
|
||||||
const pageSize = 1000;
|
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);
|
const totalPages = Math.ceil(countData.count / pageSize);
|
||||||
|
|
||||||
for (let page = 0; page < totalPages; page++) {
|
for (let page = 0; page < totalPages; page++) {
|
||||||
const { data } = await connectWiseApi.get<CWMember[]>(
|
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) {
|
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 { connectWiseApi } from "../../../constants";
|
||||||
import {
|
import {
|
||||||
CWOpportunity,
|
CWOpportunity,
|
||||||
|
CWOpportunityCreate,
|
||||||
CWOpportunitySummary,
|
CWOpportunitySummary,
|
||||||
CWForecast,
|
CWForecast,
|
||||||
CWForecastItem,
|
CWForecastItem,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
CWOpportunityNoteCreate,
|
CWOpportunityNoteCreate,
|
||||||
CWOpportunityNoteUpdate,
|
CWOpportunityNoteUpdate,
|
||||||
CWOpportunityContact,
|
CWOpportunityContact,
|
||||||
|
CWOpportunityUpdate,
|
||||||
} from "./opportunity.types";
|
} from "./opportunity.types";
|
||||||
|
|
||||||
export const opportunityCw = {
|
export const opportunityCw = {
|
||||||
@@ -100,6 +102,45 @@ export const opportunityCw = {
|
|||||||
return response.data;
|
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
|
* Fetch Opportunities by Company
|
||||||
*
|
*
|
||||||
@@ -254,6 +295,31 @@ export const opportunityCw = {
|
|||||||
return touchedIndices.map((i) => (updated.forecastItems ?? [])[i]!);
|
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
|
* Fetch Opportunity Notes
|
||||||
*
|
*
|
||||||
@@ -422,4 +488,13 @@ export const opportunityCw = {
|
|||||||
);
|
);
|
||||||
return response.data as CWProcurementProduct;
|
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}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ export interface CWForecastItemCreate {
|
|||||||
catalogItem?: { id: number };
|
catalogItem?: { id: number };
|
||||||
forecastDescription?: string;
|
forecastDescription?: string;
|
||||||
productDescription?: string;
|
productDescription?: string;
|
||||||
customerDescription?: string;
|
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
status?: { id: number };
|
status?: { id: number };
|
||||||
productClass?: string;
|
productClass?: string;
|
||||||
@@ -263,6 +262,48 @@ export interface CWProcurementProduct {
|
|||||||
_info?: Record<string, string>;
|
_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 {
|
export interface CWOpportunitySummary {
|
||||||
id: number;
|
id: number;
|
||||||
_info?: Record<string, string>;
|
_info?: Record<string, string>;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CWOpportunity } from "./opportunity.types";
|
import { CWOpportunity } from "./opportunity.types";
|
||||||
|
import { normalizeProbabilityPercent } from "../../sales-utils/normalizeProbability";
|
||||||
|
|
||||||
export type ProcessedOpportunity = ReturnType<
|
export type ProcessedOpportunity = ReturnType<
|
||||||
typeof processOpportunityResponse
|
typeof processOpportunityResponse
|
||||||
@@ -14,7 +15,7 @@ export const processOpportunityResponse = (opportunity: CWOpportunity) => ({
|
|||||||
expectedCloseDate: opportunity.expectedCloseDate,
|
expectedCloseDate: opportunity.expectedCloseDate,
|
||||||
closedDate: opportunity.closedDate,
|
closedDate: opportunity.closedDate,
|
||||||
closedFlag: opportunity.closedFlag,
|
closedFlag: opportunity.closedFlag,
|
||||||
probability: Number(opportunity.probability?.name) || 0,
|
probability: normalizeProbabilityPercent(opportunity.probability?.name),
|
||||||
type: opportunity.type
|
type: opportunity.type
|
||||||
? { id: opportunity.type.id, name: opportunity.type.name }
|
? { id: opportunity.type.id, name: opportunity.type.name }
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { prisma } from "../../../constants";
|
|||||||
import { events } from "../../globalEvents";
|
import { events } from "../../globalEvents";
|
||||||
import { opportunityCw } from "./opportunities";
|
import { opportunityCw } from "./opportunities";
|
||||||
import { OpportunityController } from "../../../controllers/OpportunityController";
|
import { OpportunityController } from "../../../controllers/OpportunityController";
|
||||||
|
import { invalidateAllOpportunityCaches } from "../../cache/opportunityCache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh Opportunities
|
* Refresh Opportunities
|
||||||
@@ -21,11 +22,14 @@ export const refreshOpportunities = async () => {
|
|||||||
|
|
||||||
// 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated
|
// 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated
|
||||||
const dbItems = await prisma.opportunity.findMany({
|
const dbItems = await prisma.opportunity.findMany({
|
||||||
select: { cwOpportunityId: true, cwLastUpdated: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
cwOpportunityId: true,
|
||||||
|
cwLastUpdated: true,
|
||||||
|
cwDateEntered: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const dbMap = new Map(
|
const dbMap = new Map(dbItems.map((item) => [item.cwOpportunityId, item]));
|
||||||
dbItems.map((item) => [item.cwOpportunityId, item.cwLastUpdated]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Determine stale / new IDs
|
// 3. Determine stale / new IDs
|
||||||
const staleIds: number[] = [];
|
const staleIds: number[] = [];
|
||||||
@@ -34,18 +38,48 @@ export const refreshOpportunities = async () => {
|
|||||||
const cwLastUpdated = summary._info?.lastUpdated
|
const cwLastUpdated = summary._info?.lastUpdated
|
||||||
? new Date(summary._info.lastUpdated)
|
? new Date(summary._info.lastUpdated)
|
||||||
: null;
|
: 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);
|
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) {
|
if (staleIds.length === 0) {
|
||||||
events.emit("cw:opportunities:refresh:skipped", {
|
events.emit("cw:opportunities:refresh:skipped", {
|
||||||
totalCw: cwSummaries.size,
|
totalCw: cwSummaries.size,
|
||||||
totalDb: dbItems.length,
|
totalDb: dbItems.length,
|
||||||
staleCount: 0,
|
staleCount: 0,
|
||||||
|
orphanedCount: orphanedItems.length,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -106,5 +140,6 @@ export const refreshOpportunities = async () => {
|
|||||||
totalDb: dbItems.length,
|
totalDb: dbItems.length,
|
||||||
staleCount: staleIds.length,
|
staleCount: staleIds.length,
|
||||||
itemsUpdated: updatedCount,
|
itemsUpdated: updatedCount,
|
||||||
|
orphanedCount: orphanedItems.length,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -171,11 +171,17 @@ interface EventTypes {
|
|||||||
totalDb: number;
|
totalDb: number;
|
||||||
staleCount: number;
|
staleCount: number;
|
||||||
itemsUpdated: number;
|
itemsUpdated: number;
|
||||||
|
orphanedCount: number;
|
||||||
|
}) => void;
|
||||||
|
"cw:opportunities:refresh:reconciled": (data: {
|
||||||
|
orphanedCount: number;
|
||||||
|
removedCwIds: number[];
|
||||||
}) => void;
|
}) => void;
|
||||||
"cw:opportunities:refresh:skipped": (data: {
|
"cw:opportunities:refresh:skipped": (data: {
|
||||||
totalCw: number;
|
totalCw: number;
|
||||||
totalDb: number;
|
totalDb: number;
|
||||||
staleCount: number;
|
staleCount: number;
|
||||||
|
orphanedCount: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
// Cache Events
|
// Cache Events
|
||||||
@@ -194,6 +200,24 @@ interface EventTypes {
|
|||||||
}) => void;
|
}) => void;
|
||||||
"cache:opportunities:refresh:error": (data: { error: unknown }) => 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
|
// ConnectWise User Defined Fields Events
|
||||||
"cw:udf:refresh:started": () => void;
|
"cw:udf:refresh:started": () => void;
|
||||||
"cw:udf:refresh:completed": (data: { count: number }) => void;
|
"cw:udf:refresh:completed": (data: { count: number }) => void;
|
||||||
@@ -205,6 +229,25 @@ interface EventTypes {
|
|||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
usersUpdated: number;
|
usersUpdated: number;
|
||||||
}) => void;
|
}) => 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>();
|
export const events = new Eventra<EventTypes>();
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface QuoteData {
|
|||||||
contact: CustomerContact;
|
contact: CustomerContact;
|
||||||
quote: QuoteDetails;
|
quote: QuoteDetails;
|
||||||
lineItems: QuoteLineItem[];
|
lineItems: QuoteLineItem[];
|
||||||
|
taxableSubtotal?: number;
|
||||||
tax: TaxConfig;
|
tax: TaxConfig;
|
||||||
salesRep?: SalesRepInfo;
|
salesRep?: SalesRepInfo;
|
||||||
quoteNarrative?: string;
|
quoteNarrative?: string;
|
||||||
@@ -158,7 +159,8 @@ export async function generateQuote(
|
|||||||
(sum, item) => sum + item.qty * item.unitPrice,
|
(sum, item) => sum + item.qty * item.unitPrice,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const taxAmount = subTotal * data.tax.rate;
|
const taxableSubTotal = Math.max(0, data.taxableSubtotal ?? subTotal);
|
||||||
|
const taxAmount = taxableSubTotal * data.tax.rate;
|
||||||
const total = subTotal + taxAmount;
|
const total = subTotal + taxAmount;
|
||||||
const logoDataUrl = loadLogoDataUrl(logoPath);
|
const logoDataUrl = loadLogoDataUrl(logoPath);
|
||||||
|
|
||||||
|
|||||||
@@ -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"}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,12 @@ export const PERMISSION_NODES = {
|
|||||||
usedIn: ["src/api/companies/[id]/configurations.ts"],
|
usedIn: ["src/api/companies/[id]/configurations.ts"],
|
||||||
dependencies: ["company.fetch"],
|
dependencies: ["company.fetch"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
node: "company.fetch.sites",
|
||||||
|
description: "Fetch company sites from ConnectWise",
|
||||||
|
usedIn: ["src/api/companies/[id]/sites.ts"],
|
||||||
|
dependencies: ["company.fetch"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -417,12 +423,58 @@ export const PERMISSION_NODES = {
|
|||||||
"src/api/sales/fetchOpportunityTypes.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",
|
node: "sales.opportunity.refresh",
|
||||||
description: "Refresh a single opportunity from ConnectWise",
|
description: "Refresh a single opportunity from ConnectWise",
|
||||||
usedIn: ["src/api/sales/opportunities/[id]/refresh.ts"],
|
usedIn: ["src/api/sales/opportunities/[id]/refresh.ts"],
|
||||||
dependencies: ["sales.opportunity.fetch"],
|
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",
|
node: "sales.opportunity.note.create",
|
||||||
description: "Create a new note on an opportunity",
|
description: "Create a new note on an opportunity",
|
||||||
@@ -452,6 +504,12 @@ export const PERMISSION_NODES = {
|
|||||||
],
|
],
|
||||||
dependencies: ["sales.opportunity.fetch"],
|
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",
|
node: "sales.opportunity.product.add",
|
||||||
description:
|
description:
|
||||||
@@ -531,6 +589,95 @@ export const PERMISSION_NODES = {
|
|||||||
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts"],
|
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts"],
|
||||||
dependencies: ["sales.opportunity.fetch"],
|
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: [],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+195
-12
@@ -18,7 +18,7 @@ export interface QuoteStatus {
|
|||||||
|
|
||||||
export const QUOTE_STATUSES: QuoteStatus[] = [
|
export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||||
//
|
//
|
||||||
// FUTURE
|
// FUTURE LEAD
|
||||||
//
|
//
|
||||||
{
|
{
|
||||||
id: 51,
|
id: 51,
|
||||||
@@ -41,6 +41,27 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// PENDING NEW
|
||||||
|
//
|
||||||
|
{
|
||||||
|
id: 37,
|
||||||
|
name: "Pending New",
|
||||||
|
wonFlag: false,
|
||||||
|
lostFlag: false,
|
||||||
|
closedFlag: false,
|
||||||
|
inactiveFlag: false,
|
||||||
|
defaultFlag: false,
|
||||||
|
enteredBy: "crobinso",
|
||||||
|
dateEntered: "2022-04-28T21:04:53Z",
|
||||||
|
_info: {
|
||||||
|
lastUpdated: "2022-04-28T21:06:24Z",
|
||||||
|
updatedBy: "crobinso",
|
||||||
|
},
|
||||||
|
connectWiseId: "f90e72a3-70d0-ef11-b2e0-000c29c55070",
|
||||||
|
optimaEquivalency: [],
|
||||||
|
},
|
||||||
|
|
||||||
//
|
//
|
||||||
// NEW
|
// NEW
|
||||||
//
|
//
|
||||||
@@ -62,7 +83,6 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
|||||||
optimaEquivalency: [
|
optimaEquivalency: [
|
||||||
1, // Pre2021-1) New
|
1, // Pre2021-1) New
|
||||||
13, // Pre2021-Initial Contact Made
|
13, // Pre2021-Initial Contact Made
|
||||||
37, // 00. Pending New
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -90,6 +110,53 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
|||||||
27, // Z4. Waiting-VendorInfo
|
27, // Z4. Waiting-VendorInfo
|
||||||
28, // Z5. Waiting-OtherTTStaff
|
28, // Z5. Waiting-OtherTTStaff
|
||||||
41, // PRE2405. Review Ready
|
41, // PRE2405. Review Ready
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// QUOTE SENT
|
||||||
|
//
|
||||||
|
{
|
||||||
|
id: 43,
|
||||||
|
name: "Quote Sent",
|
||||||
|
wonFlag: false,
|
||||||
|
lostFlag: false,
|
||||||
|
closedFlag: false,
|
||||||
|
inactiveFlag: false,
|
||||||
|
defaultFlag: false,
|
||||||
|
enteredBy: "crobinso",
|
||||||
|
dateEntered: "2022-04-28T21:06:02Z",
|
||||||
|
_info: {
|
||||||
|
lastUpdated: "2024-04-28T15:06:55Z",
|
||||||
|
updatedBy: "crobinso",
|
||||||
|
},
|
||||||
|
connectWiseId: "ff0e72a3-70d0-ef11-b2e0-000c29c55070",
|
||||||
|
optimaEquivalency: [
|
||||||
|
17, // Pre2021-5) Quote Sent
|
||||||
|
25, // ZOLD---Quote Sent
|
||||||
|
55, // PRE24_70. Quote Sent - Sell
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// CONFIRMED QUOTE
|
||||||
|
//
|
||||||
|
{
|
||||||
|
id: 57,
|
||||||
|
name: "Confirmed Quote",
|
||||||
|
wonFlag: false,
|
||||||
|
lostFlag: false,
|
||||||
|
closedFlag: false,
|
||||||
|
inactiveFlag: false,
|
||||||
|
defaultFlag: false,
|
||||||
|
enteredBy: "crobinso",
|
||||||
|
dateEntered: "2024-04-28T15:07:11Z",
|
||||||
|
_info: {
|
||||||
|
lastUpdated: "2024-04-28T15:07:11Z",
|
||||||
|
updatedBy: "crobinso",
|
||||||
|
},
|
||||||
|
connectWiseId: "0d0f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||||
|
optimaEquivalency: [
|
||||||
54, // PRE24_90. Customer Approved
|
54, // PRE24_90. Customer Approved
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -116,14 +183,10 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
|||||||
9, // Pre2021-Recommendation
|
9, // Pre2021-Recommendation
|
||||||
15, // Pre2021-3) Onsite Assess Sch'd
|
15, // Pre2021-3) Onsite Assess Sch'd
|
||||||
16, // Pre2021-4) Quote Info Gathered
|
16, // Pre2021-4) Quote Info Gathered
|
||||||
17, // Pre2021-5) Quote Sent
|
|
||||||
18, // Pre2021-6) Follow-up #1 Made
|
18, // Pre2021-6) Follow-up #1 Made
|
||||||
19, // Pre2021-7) Follow-up #2 Made
|
19, // Pre2021-7) Follow-up #2 Made
|
||||||
20, // Pre2021-8) Follow-up #3 Made
|
20, // Pre2021-8) Follow-up #3 Made
|
||||||
|
|
||||||
25, // ZOLD---Quote Sent
|
|
||||||
43, // 03. Quote Sent
|
|
||||||
|
|
||||||
38, // PRE2402. On-Site Ready
|
38, // PRE2402. On-Site Ready
|
||||||
39, // PRE2403. On-Site Scheduled
|
39, // PRE2403. On-Site Scheduled
|
||||||
40, // PRE2404. On-Site Complete
|
40, // PRE2404. On-Site Complete
|
||||||
@@ -134,11 +197,93 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
|||||||
47, // PRE2412. Follow-Up3
|
47, // PRE2412. Follow-Up3
|
||||||
48, // PRE2413. Follow-Up Extended
|
48, // PRE2413. Follow-Up Extended
|
||||||
52, // PRE2489. Overdue
|
52, // PRE2489. Overdue
|
||||||
55, // PRE24_70. Quote Sent - Sell
|
|
||||||
57, // 04. Confirmed Quote
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// READY TO SEND
|
||||||
|
//
|
||||||
|
{
|
||||||
|
id: 63,
|
||||||
|
name: "Ready to Send",
|
||||||
|
wonFlag: false,
|
||||||
|
lostFlag: false,
|
||||||
|
closedFlag: false,
|
||||||
|
inactiveFlag: false,
|
||||||
|
defaultFlag: false,
|
||||||
|
enteredBy: "jroberts",
|
||||||
|
dateEntered: "2026-03-16T02:24:10Z",
|
||||||
|
_info: {
|
||||||
|
lastUpdated: "2026-03-16T02:24:10Z",
|
||||||
|
updatedBy: "jroberts",
|
||||||
|
},
|
||||||
|
connectWiseId: "f10c4e36-df20-f111-b2ee-000c29c55070",
|
||||||
|
optimaEquivalency: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// PENDING SENT
|
||||||
|
//
|
||||||
|
{
|
||||||
|
id: 60,
|
||||||
|
name: "Pending Sent",
|
||||||
|
wonFlag: false,
|
||||||
|
lostFlag: false,
|
||||||
|
closedFlag: false,
|
||||||
|
inactiveFlag: false,
|
||||||
|
defaultFlag: false,
|
||||||
|
enteredBy: "crobinso",
|
||||||
|
dateEntered: "2026-03-08T23:06:01Z",
|
||||||
|
_info: {
|
||||||
|
lastUpdated: "2026-03-08T23:06:01Z",
|
||||||
|
updatedBy: "crobinso",
|
||||||
|
},
|
||||||
|
connectWiseId: "c900215f-431b-f111-b2ee-000c29c55070",
|
||||||
|
optimaEquivalency: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// PENDING REVISION
|
||||||
|
//
|
||||||
|
{
|
||||||
|
id: 61,
|
||||||
|
name: "Pending Revision",
|
||||||
|
wonFlag: false,
|
||||||
|
lostFlag: false,
|
||||||
|
closedFlag: false,
|
||||||
|
inactiveFlag: false,
|
||||||
|
defaultFlag: false,
|
||||||
|
enteredBy: "crobinso",
|
||||||
|
dateEntered: "2026-03-08T23:06:06Z",
|
||||||
|
_info: {
|
||||||
|
lastUpdated: "2026-03-08T23:06:06Z",
|
||||||
|
updatedBy: "crobinso",
|
||||||
|
},
|
||||||
|
connectWiseId: "ca00215f-431b-f111-b2ee-000c29c55070",
|
||||||
|
optimaEquivalency: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// PENDING WON
|
||||||
|
//
|
||||||
|
{
|
||||||
|
id: 49,
|
||||||
|
name: "Pending Won",
|
||||||
|
wonFlag: false,
|
||||||
|
lostFlag: false,
|
||||||
|
closedFlag: false,
|
||||||
|
inactiveFlag: false,
|
||||||
|
defaultFlag: false,
|
||||||
|
enteredBy: "crobinso",
|
||||||
|
dateEntered: "2023-02-08T21:27:35Z",
|
||||||
|
_info: {
|
||||||
|
lastUpdated: "2024-01-21T20:39:47Z",
|
||||||
|
updatedBy: "crobinso",
|
||||||
|
},
|
||||||
|
connectWiseId: "050f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||||
|
optimaEquivalency: [],
|
||||||
|
},
|
||||||
|
|
||||||
//
|
//
|
||||||
// WON
|
// WON
|
||||||
//
|
//
|
||||||
@@ -159,11 +304,30 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
|||||||
connectWiseId: "f10e72a3-70d0-ef11-b2e0-000c29c55070",
|
connectWiseId: "f10e72a3-70d0-ef11-b2e0-000c29c55070",
|
||||||
optimaEquivalency: [
|
optimaEquivalency: [
|
||||||
2, // Pre2021-8) Won
|
2, // Pre2021-8) Won
|
||||||
54, // PRE24_90. Customer Approved (if you treat as effectively Won)
|
|
||||||
49, // 91. Pending Won
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// PENDING LOST
|
||||||
|
//
|
||||||
|
{
|
||||||
|
id: 50,
|
||||||
|
name: "Pending Lost",
|
||||||
|
wonFlag: false,
|
||||||
|
lostFlag: false,
|
||||||
|
closedFlag: false,
|
||||||
|
inactiveFlag: false,
|
||||||
|
defaultFlag: false,
|
||||||
|
enteredBy: "crobinso",
|
||||||
|
dateEntered: "2023-02-08T21:32:10Z",
|
||||||
|
_info: {
|
||||||
|
lastUpdated: "2023-02-08T21:32:41Z",
|
||||||
|
updatedBy: "crobinso",
|
||||||
|
},
|
||||||
|
connectWiseId: "060f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||||
|
optimaEquivalency: [],
|
||||||
|
},
|
||||||
|
|
||||||
//
|
//
|
||||||
// LOST
|
// LOST
|
||||||
//
|
//
|
||||||
@@ -192,8 +356,27 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
|||||||
32, // Pre2024_99. Lost-NoDecision
|
32, // Pre2024_99. Lost-NoDecision
|
||||||
33, // Pre2024_99. Lost-Pricing
|
33, // Pre2024_99. Lost-Pricing
|
||||||
34, // Pre2024_99. Lost-OtherTTQuote
|
34, // Pre2024_99. Lost-OtherTTQuote
|
||||||
|
|
||||||
50, // 98. Pending Lost
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// CANCELED
|
||||||
|
//
|
||||||
|
{
|
||||||
|
id: 59,
|
||||||
|
name: "Canceled",
|
||||||
|
wonFlag: false,
|
||||||
|
lostFlag: true,
|
||||||
|
closedFlag: true,
|
||||||
|
inactiveFlag: false,
|
||||||
|
defaultFlag: false,
|
||||||
|
enteredBy: "crobinso",
|
||||||
|
dateEntered: "2026-03-08T22:45:07Z",
|
||||||
|
_info: {
|
||||||
|
lastUpdated: "2026-03-08T22:45:07Z",
|
||||||
|
updatedBy: "crobinso",
|
||||||
|
},
|
||||||
|
connectWiseId: "83ddc173-401b-f111-b2ee-000c29c55070",
|
||||||
|
optimaEquivalency: [],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+107
@@ -17,6 +17,45 @@ const { privateKey: _testPrivateKey, publicKey: _testPublicKey } =
|
|||||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock the globalEvents module — many source files import `events` from here.
|
||||||
|
// We provide both `events` and `setupEventDebugger` so that no test
|
||||||
|
// encounters "export not found" when another test's mock.module is stale.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
mock.module("../src/modules/globalEvents", () => ({
|
||||||
|
events: {
|
||||||
|
emit: mock(),
|
||||||
|
on: mock(),
|
||||||
|
off: mock(),
|
||||||
|
once: mock(),
|
||||||
|
removeAllListeners: mock(),
|
||||||
|
},
|
||||||
|
setupEventDebugger: mock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock modules that are commonly mocked by test files at top-level.
|
||||||
|
// Having them in the preload ensures that even when per-test mock.module
|
||||||
|
// calls persist globally, the baseline mock is complete.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
mock.module("../src/modules/fetchMicrosoftUser", () => ({
|
||||||
|
fetchMicrosoftUser: mock(() => Promise.resolve({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("../src/managers/sessions", () => ({
|
||||||
|
sessions: {
|
||||||
|
create: mock(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
accessToken: "mock-access",
|
||||||
|
refreshToken: "mock-refresh",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
fetch: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mock the constants module — almost every source file imports from here.
|
// Mock the constants module — almost every source file imports from here.
|
||||||
// We provide safe defaults so modules can be imported without side-effects.
|
// We provide safe defaults so modules can be imported without side-effects.
|
||||||
@@ -60,6 +99,74 @@ mock.module("../src/constants", () => ({
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a complete mock constants object for use with `mock.module()`.
|
||||||
|
*
|
||||||
|
* Pass `overrides` to replace specific exports (e.g. a custom prisma mock).
|
||||||
|
* All keys from the preload mock are included so that downstream modules
|
||||||
|
* importing named exports (secureValuesPublicKey, connectWiseApi, etc.)
|
||||||
|
* never encounter "export not found" errors.
|
||||||
|
*/
|
||||||
|
export function buildMockConstants(
|
||||||
|
overrides: Record<string, any> = {},
|
||||||
|
): Record<string, any> {
|
||||||
|
return {
|
||||||
|
prisma: createMockPrisma(),
|
||||||
|
PORT: "3333",
|
||||||
|
API_BASE_URL: "http://localhost:3333",
|
||||||
|
sessionDuration: 30 * 24 * 60 * 60_000,
|
||||||
|
accessTokenDuration: "10min",
|
||||||
|
refreshTokenDuration: "30d",
|
||||||
|
accessTokenPrivateKey: _testPrivateKey,
|
||||||
|
refreshTokenPrivateKey: _testPrivateKey,
|
||||||
|
permissionsPrivateKey: _testPrivateKey,
|
||||||
|
secureValuesPrivateKey: _testPrivateKey,
|
||||||
|
secureValuesPublicKey: _testPublicKey,
|
||||||
|
msalClient: { acquireTokenByCode: mock(() => Promise.resolve({})) },
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
post: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
put: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
patch: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
delete: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
get: mock(() => Promise.resolve(null)),
|
||||||
|
set: mock(() => Promise.resolve("OK")),
|
||||||
|
del: mock(() => Promise.resolve(1)),
|
||||||
|
},
|
||||||
|
unifi: createMockUnifi(),
|
||||||
|
unifiControllerBaseUrl: "https://unifi.test.local",
|
||||||
|
unifiSite: "default",
|
||||||
|
unifiUsername: "admin",
|
||||||
|
unifiPassword: "test-pass",
|
||||||
|
io: { of: mock(() => ({ on: mock() })) },
|
||||||
|
engine: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a complete mock globalEvents object for use with `mock.module()`.
|
||||||
|
* Includes both `events` and `setupEventDebugger` so downstream modules
|
||||||
|
* never encounter "export not found" errors.
|
||||||
|
*/
|
||||||
|
export function buildMockGlobalEvents(
|
||||||
|
overrides: Record<string, any> = {},
|
||||||
|
): Record<string, any> {
|
||||||
|
return {
|
||||||
|
events: {
|
||||||
|
emit: mock(),
|
||||||
|
on: mock(),
|
||||||
|
off: mock(),
|
||||||
|
once: mock(),
|
||||||
|
removeAllListeners: mock(),
|
||||||
|
},
|
||||||
|
setupEventDebugger: mock(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createMockPrisma() {
|
export function createMockPrisma() {
|
||||||
const createModelProxy = () =>
|
const createModelProxy = () =>
|
||||||
new Proxy(
|
new Proxy(
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { buildMockCWActivity } from "../setup";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stable mock factory (same pattern as generatedQuotesManager.test.ts)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createStablePrismaMock(
|
||||||
|
overrides: Record<string, Record<string, any>> = {},
|
||||||
|
) {
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, model: string) {
|
||||||
|
if (model === "$connect" || model === "$disconnect")
|
||||||
|
return mock(() => Promise.resolve());
|
||||||
|
if (overrides[model]) return overrides[model];
|
||||||
|
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("activities manager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchItem
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchItem()", () => {
|
||||||
|
test("returns an ActivityController on success", async () => {
|
||||||
|
const cwData = buildMockCWActivity();
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {
|
||||||
|
fetch: mock(() => Promise.resolve(cwData)),
|
||||||
|
fetchByCompany: mock(() => Promise.resolve([])),
|
||||||
|
fetchByOpportunity: mock(() => Promise.resolve([])),
|
||||||
|
delete: mock(() => Promise.resolve()),
|
||||||
|
countItems: mock(() => Promise.resolve(0)),
|
||||||
|
update: mock(() => Promise.resolve(cwData)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock(),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
post: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
patch: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
delete: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { activities } = await import("../../src/managers/activities");
|
||||||
|
const result = await activities.fetchItem(5001);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.cwActivityId).toBe(5001);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws GenericError on failure", async () => {
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {
|
||||||
|
fetch: mock(() => Promise.reject(new Error("CW API down"))),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock(),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { activities } = await import("../../src/managers/activities");
|
||||||
|
try {
|
||||||
|
await activities.fetchItem(9999);
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.name).toBe("FetchActivityError");
|
||||||
|
expect(e.status).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchPages
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchPages()", () => {
|
||||||
|
test("returns array of ActivityControllers", async () => {
|
||||||
|
const cwData = [buildMockCWActivity(), buildMockCWActivity({ id: 5002 })];
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock(),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: cwData })),
|
||||||
|
post: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { activities } = await import("../../src/managers/activities");
|
||||||
|
const result = await activities.fetchPages(1, 10);
|
||||||
|
expect(result).toBeArrayOfSize(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clamps page to minimum 1", async () => {
|
||||||
|
const getMock = mock(() => Promise.resolve({ data: [] }));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock(),
|
||||||
|
connectWiseApi: { get: getMock },
|
||||||
|
}));
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { activities } = await import("../../src/managers/activities");
|
||||||
|
await activities.fetchPages(-5, 10);
|
||||||
|
const url = getMock.mock.calls[0]?.[0] as string;
|
||||||
|
expect(url).toContain("page=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchByCompany
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchByCompany()", () => {
|
||||||
|
test("returns ActivityControllers for a company", async () => {
|
||||||
|
const items = [buildMockCWActivity()];
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {
|
||||||
|
fetchByCompany: mock(() => Promise.resolve(items)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock(),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { activities } = await import("../../src/managers/activities");
|
||||||
|
const result = await activities.fetchByCompany(123);
|
||||||
|
expect(result).toBeArrayOfSize(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchByOpportunity
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchByOpportunity()", () => {
|
||||||
|
test("returns ActivityControllers for an opportunity", async () => {
|
||||||
|
const items = [buildMockCWActivity()];
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {
|
||||||
|
fetchByOpportunity: mock(() => Promise.resolve(items)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock(),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { activities } = await import("../../src/managers/activities");
|
||||||
|
const result = await activities.fetchByOpportunity(1001);
|
||||||
|
expect(result).toBeArrayOfSize(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// delete
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("delete()", () => {
|
||||||
|
test("delegates to activityCw.delete", async () => {
|
||||||
|
const deleteMock = mock(() => Promise.resolve());
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: { delete: deleteMock },
|
||||||
|
}));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock(),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { activities } = await import("../../src/managers/activities");
|
||||||
|
await activities.delete(5001);
|
||||||
|
expect(deleteMock).toHaveBeenCalledWith(5001);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws GenericError on failure", async () => {
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {
|
||||||
|
delete: mock(() => Promise.reject(new Error("fail"))),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock(),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { activities } = await import("../../src/managers/activities");
|
||||||
|
try {
|
||||||
|
await activities.delete(9999);
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.name).toBe("DeleteActivityError");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// count
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("count()", () => {
|
||||||
|
test("returns count from activityCw", async () => {
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {
|
||||||
|
countItems: mock(() => Promise.resolve(42)),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock(),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { activities } = await import("../../src/managers/activities");
|
||||||
|
const result = await activities.count();
|
||||||
|
expect(result).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Tests for src/modules/algorithms/algo.coldThreshold.ts
|
||||||
|
*
|
||||||
|
* checkColdStatus is currently bypassed (always returns not-cold).
|
||||||
|
* COLD_THRESHOLDS config is still tested.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
checkColdStatus,
|
||||||
|
COLD_THRESHOLDS,
|
||||||
|
} from "../../src/modules/algorithms/algo.coldThreshold";
|
||||||
|
|
||||||
|
describe("COLD_THRESHOLDS", () => {
|
||||||
|
test("defines thresholds for QuoteSent (43) and ConfirmedQuote (57)", () => {
|
||||||
|
expect(COLD_THRESHOLDS[43]).toBeDefined();
|
||||||
|
expect(COLD_THRESHOLDS[43].days).toBe(14);
|
||||||
|
expect(COLD_THRESHOLDS[57]).toBeDefined();
|
||||||
|
expect(COLD_THRESHOLDS[57].days).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ms values match day values", () => {
|
||||||
|
expect(COLD_THRESHOLDS[43].ms).toBe(14 * 24 * 60 * 60 * 1000);
|
||||||
|
expect(COLD_THRESHOLDS[57].ms).toBe(30 * 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkColdStatus (bypassed)", () => {
|
||||||
|
test("always returns not-cold regardless of input", () => {
|
||||||
|
// With null status
|
||||||
|
expect(checkColdStatus({ statusCwId: null, lastActivityDate: new Date() }))
|
||||||
|
.toEqual({ cold: false, triggeredBy: null });
|
||||||
|
|
||||||
|
// With eligible status and stale activity
|
||||||
|
const now = new Date("2026-03-16T00:00:00Z");
|
||||||
|
const lastActivity = new Date("2026-03-01T00:00:00Z"); // 15 days ago
|
||||||
|
expect(checkColdStatus({ statusCwId: 43, lastActivityDate: lastActivity, now }))
|
||||||
|
.toEqual({ cold: false, triggeredBy: null });
|
||||||
|
|
||||||
|
// With ConfirmedQuote exceeding threshold
|
||||||
|
expect(checkColdStatus({ statusCwId: 57, lastActivityDate: new Date("2026-02-01"), now: new Date("2026-04-01") }))
|
||||||
|
.toEqual({ cold: false, triggeredBy: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Tests for src/modules/algorithms/algo.followUpScheduler.ts
|
||||||
|
*
|
||||||
|
* Pure function — no mocking needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { scheduleFollowUp } from "../../src/modules/algorithms/algo.followUpScheduler";
|
||||||
|
|
||||||
|
describe("scheduleFollowUp", () => {
|
||||||
|
test("returns dueDate and dueDateIso", () => {
|
||||||
|
const result = scheduleFollowUp({
|
||||||
|
triggeredByUserId: "user-1",
|
||||||
|
now: new Date("2026-03-02T14:00:00Z"), // Monday
|
||||||
|
});
|
||||||
|
expect(result.dueDate).toBeInstanceOf(Date);
|
||||||
|
expect(typeof result.dueDateIso).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("schedules for next day at 10 AM on a weekday (Mon → Tue)", () => {
|
||||||
|
// Monday March 2, 2026
|
||||||
|
const result = scheduleFollowUp({
|
||||||
|
triggeredByUserId: "user-1",
|
||||||
|
now: new Date("2026-03-02T14:00:00Z"),
|
||||||
|
});
|
||||||
|
// Should be Tuesday March 3, 2026 at 10:00 AM local
|
||||||
|
expect(result.dueDate.getDate()).toBe(3);
|
||||||
|
expect(result.dueDate.getHours()).toBe(10);
|
||||||
|
expect(result.dueDate.getMinutes()).toBe(0);
|
||||||
|
expect(result.dueDate.getSeconds()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Friday → Monday (skips weekend)", () => {
|
||||||
|
// Friday March 6, 2026
|
||||||
|
const result = scheduleFollowUp({
|
||||||
|
triggeredByUserId: "user-1",
|
||||||
|
now: new Date("2026-03-06T14:00:00Z"),
|
||||||
|
});
|
||||||
|
// Next day is Saturday (day 6), should skip to Monday
|
||||||
|
// March 6 (Fri) +1 = March 7 (Sat) → +2 → March 9 (Mon)
|
||||||
|
expect(result.dueDate.getDay()).toBe(1); // Monday
|
||||||
|
expect(result.dueDate.getHours()).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Saturday → Monday", () => {
|
||||||
|
// Saturday March 7, 2026
|
||||||
|
const result = scheduleFollowUp({
|
||||||
|
triggeredByUserId: "user-1",
|
||||||
|
now: new Date("2026-03-07T14:00:00Z"),
|
||||||
|
});
|
||||||
|
// +1 = Sunday (day 0) → +1 → Monday
|
||||||
|
expect(result.dueDate.getDay()).toBe(1); // Monday
|
||||||
|
expect(result.dueDate.getHours()).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults to current time when now is not provided", () => {
|
||||||
|
const result = scheduleFollowUp({ triggeredByUserId: "user-1" });
|
||||||
|
expect(result.dueDate).toBeInstanceOf(Date);
|
||||||
|
// Due date should be in the future
|
||||||
|
expect(result.dueDate.getTime()).toBeGreaterThan(Date.now() - 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dueDate always has time set to 10:00:00.000", () => {
|
||||||
|
// Test across several days of the week
|
||||||
|
for (let d = 1; d <= 7; d++) {
|
||||||
|
const result = scheduleFollowUp({
|
||||||
|
triggeredByUserId: "user-1",
|
||||||
|
now: new Date(`2026-03-0${d}T08:00:00Z`),
|
||||||
|
});
|
||||||
|
expect(result.dueDate.getHours()).toBe(10);
|
||||||
|
expect(result.dueDate.getMinutes()).toBe(0);
|
||||||
|
expect(result.dueDate.getSeconds()).toBe(0);
|
||||||
|
expect(result.dueDate.getMilliseconds()).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dueDateIso is a valid ISO string of the dueDate", () => {
|
||||||
|
const result = scheduleFollowUp({
|
||||||
|
triggeredByUserId: "user-1",
|
||||||
|
now: new Date("2026-03-02T14:00:00Z"),
|
||||||
|
});
|
||||||
|
expect(new Date(result.dueDateIso).getTime()).toBe(
|
||||||
|
result.dueDate.getTime(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { buildMockCompany } from "../setup";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stable mock factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createStablePrismaMock(
|
||||||
|
overrides: Record<string, Record<string, any>> = {},
|
||||||
|
) {
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, model: string) {
|
||||||
|
if (model === "$connect" || model === "$disconnect")
|
||||||
|
return mock(() => Promise.resolve());
|
||||||
|
if (overrides[model]) return overrides[model];
|
||||||
|
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("companies manager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// count
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("count()", () => {
|
||||||
|
test("returns company count from Prisma", async () => {
|
||||||
|
const countMock = mock(() => Promise.resolve(5));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
company: {
|
||||||
|
count: countMock,
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { companies } = await import("../../src/managers/companies");
|
||||||
|
const result = await companies.count();
|
||||||
|
expect(result).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchPages
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchPages()", () => {
|
||||||
|
test("returns paginated companies", async () => {
|
||||||
|
const mockData = [
|
||||||
|
buildMockCompany(),
|
||||||
|
buildMockCompany({ id: "company-2" }),
|
||||||
|
];
|
||||||
|
const findManyMock = mock(() => Promise.resolve(mockData));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
company: {
|
||||||
|
findMany: findManyMock,
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { companies } = await import("../../src/managers/companies");
|
||||||
|
const result = await companies.fetchPages(1, 10);
|
||||||
|
expect(result).toBeArrayOfSize(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses correct skip for page 1", async () => {
|
||||||
|
const findManyMock = mock(() => Promise.resolve([]));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
company: {
|
||||||
|
findMany: findManyMock,
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { companies } = await import("../../src/managers/companies");
|
||||||
|
await companies.fetchPages(1, 20);
|
||||||
|
expect(findManyMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// search
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("search()", () => {
|
||||||
|
test("returns matching companies", async () => {
|
||||||
|
const mockData = [buildMockCompany()];
|
||||||
|
const findManyMock = mock(() => Promise.resolve(mockData));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
company: {
|
||||||
|
findMany: findManyMock,
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { companies } = await import("../../src/managers/companies");
|
||||||
|
const result = await companies.search("Test", 1, 10);
|
||||||
|
expect(result).toBeArrayOfSize(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetch
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetch()", () => {
|
||||||
|
test("throws when company not found in DB", async () => {
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
company: { findFirst: mock(() => Promise.resolve(null)) },
|
||||||
|
}),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { companies } = await import("../../src/managers/companies");
|
||||||
|
try {
|
||||||
|
await companies.fetch("nonexistent");
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.message).toBe("Unknown company.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns CompanyController on success", async () => {
|
||||||
|
const dbCompany = buildMockCompany();
|
||||||
|
const cwCompanyData = {
|
||||||
|
defaultContact: { _info: { contact_href: "/contacts/1" } },
|
||||||
|
_info: { contacts_href: "/contacts?page=1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
company: { findFirst: mock(() => Promise.resolve(dbCompany)) },
|
||||||
|
}),
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: cwCompanyData,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { companies } = await import("../../src/managers/companies");
|
||||||
|
const result = await companies.fetch("company-1");
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.id).toBe("company-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,14 +20,19 @@ describe("computeProductsCacheTTL", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// -- Won/Lost status set ------------------------------------------------
|
// -- Won/Lost status set ------------------------------------------------
|
||||||
test("WON_LOST_STATUS_IDS contains Won canonical ID (29) and Pending Won (49)", () => {
|
test("WON_LOST_STATUS_IDS contains Won canonical ID (29)", () => {
|
||||||
expect(WON_LOST_STATUS_IDS.has(29)).toBe(true);
|
expect(WON_LOST_STATUS_IDS.has(29)).toBe(true);
|
||||||
expect(WON_LOST_STATUS_IDS.has(49)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("WON_LOST_STATUS_IDS contains Lost canonical ID (53) and Pending Lost (50)", () => {
|
test("WON_LOST_STATUS_IDS contains Lost canonical ID (53) and Canceled (59)", () => {
|
||||||
expect(WON_LOST_STATUS_IDS.has(53)).toBe(true);
|
expect(WON_LOST_STATUS_IDS.has(53)).toBe(true);
|
||||||
expect(WON_LOST_STATUS_IDS.has(50)).toBe(true);
|
expect(WON_LOST_STATUS_IDS.has(59)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("WON_LOST_STATUS_IDS does not contain Pending Won (49) or Pending Lost (50)", () => {
|
||||||
|
// Pending Won/Lost do not have wonFlag/lostFlag set in QuoteStatuses
|
||||||
|
expect(WON_LOST_STATUS_IDS.has(49)).toBe(false);
|
||||||
|
expect(WON_LOST_STATUS_IDS.has(50)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("WON_LOST_STATUS_IDS does not contain Active (58) or New (24)", () => {
|
test("WON_LOST_STATUS_IDS does not contain Active (58) or New (24)", () => {
|
||||||
@@ -48,7 +53,9 @@ describe("computeProductsCacheTTL", () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null for Pending Won status (CW ID 49)", () => {
|
test("returns PRODUCTS_TTL_HOT for Pending Won status (CW ID 49) with recent activity", () => {
|
||||||
|
// Pending Won is not in WON_LOST_STATUS_IDS (no wonFlag), so it falls
|
||||||
|
// through to the activity-based rules.
|
||||||
const result = computeProductsCacheTTL({
|
const result = computeProductsCacheTTL({
|
||||||
statusCwId: 49,
|
statusCwId: 49,
|
||||||
closedFlag: false,
|
closedFlag: false,
|
||||||
@@ -57,7 +64,7 @@ describe("computeProductsCacheTTL", () => {
|
|||||||
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||||
now: NOW,
|
now: NOW,
|
||||||
});
|
});
|
||||||
expect(result).toBeNull();
|
expect(result).toBe(PRODUCTS_TTL_HOT);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null for Lost status (CW ID 53)", () => {
|
test("returns null for Lost status (CW ID 53)", () => {
|
||||||
@@ -72,7 +79,9 @@ describe("computeProductsCacheTTL", () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null for Pending Lost status (CW ID 50)", () => {
|
test("returns PRODUCTS_TTL_HOT for Pending Lost status (CW ID 50) with recent activity", () => {
|
||||||
|
// Pending Lost is not in WON_LOST_STATUS_IDS (no lostFlag), so it falls
|
||||||
|
// through to the activity-based rules.
|
||||||
const result = computeProductsCacheTTL({
|
const result = computeProductsCacheTTL({
|
||||||
statusCwId: 50,
|
statusCwId: 50,
|
||||||
closedFlag: false,
|
closedFlag: false,
|
||||||
@@ -81,7 +90,7 @@ describe("computeProductsCacheTTL", () => {
|
|||||||
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
|
||||||
now: NOW,
|
now: NOW,
|
||||||
});
|
});
|
||||||
expect(result).toBeNull();
|
expect(result).toBe(PRODUCTS_TTL_HOT);
|
||||||
});
|
});
|
||||||
|
|
||||||
// -- Rule 2: Opp not cacheable → null ----------------------------------
|
// -- Rule 2: Opp not cacheable → null ----------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { CwMemberController } from "../../../src/controllers/CwMemberController";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildMockCwMember(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: "member-1",
|
||||||
|
cwMemberId: 42,
|
||||||
|
identifier: "jdoe",
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
officeEmail: "jdoe@example.com",
|
||||||
|
inactiveFlag: false,
|
||||||
|
apiKey: null,
|
||||||
|
cwLastUpdated: new Date("2026-02-01"),
|
||||||
|
createdAt: new Date("2026-01-01"),
|
||||||
|
updatedAt: new Date("2026-02-01"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("CwMemberController", () => {
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Constructor
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
describe("constructor", () => {
|
||||||
|
test("sets all public properties from data", () => {
|
||||||
|
const data = buildMockCwMember();
|
||||||
|
const ctrl = new CwMemberController(data as any);
|
||||||
|
|
||||||
|
expect(ctrl.id).toBe("member-1");
|
||||||
|
expect(ctrl.cwMemberId).toBe(42);
|
||||||
|
expect(ctrl.identifier).toBe("jdoe");
|
||||||
|
expect(ctrl.firstName).toBe("John");
|
||||||
|
expect(ctrl.lastName).toBe("Doe");
|
||||||
|
expect(ctrl.officeEmail).toBe("jdoe@example.com");
|
||||||
|
expect(ctrl.inactiveFlag).toBe(false);
|
||||||
|
expect(ctrl.apiKey).toBeNull();
|
||||||
|
expect(ctrl.cwLastUpdated).toEqual(new Date("2026-02-01"));
|
||||||
|
expect(ctrl.createdAt).toEqual(new Date("2026-01-01"));
|
||||||
|
expect(ctrl.updatedAt).toEqual(new Date("2026-02-01"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles null officeEmail", () => {
|
||||||
|
const data = buildMockCwMember({ officeEmail: null });
|
||||||
|
const ctrl = new CwMemberController(data as any);
|
||||||
|
expect(ctrl.officeEmail).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles apiKey set", () => {
|
||||||
|
const data = buildMockCwMember({ apiKey: "secret-key" });
|
||||||
|
const ctrl = new CwMemberController(data as any);
|
||||||
|
expect(ctrl.apiKey).toBe("secret-key");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// fullName getter
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
describe("fullName", () => {
|
||||||
|
test("returns firstName + lastName", () => {
|
||||||
|
const ctrl = new CwMemberController(buildMockCwMember() as any);
|
||||||
|
expect(ctrl.fullName).toBe("John Doe");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns trimmed name when lastName is empty", () => {
|
||||||
|
const ctrl = new CwMemberController(
|
||||||
|
buildMockCwMember({ lastName: "" }) as any,
|
||||||
|
);
|
||||||
|
expect(ctrl.fullName).toBe("John");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns trimmed name when firstName is empty", () => {
|
||||||
|
const ctrl = new CwMemberController(
|
||||||
|
buildMockCwMember({ firstName: "" }) as any,
|
||||||
|
);
|
||||||
|
expect(ctrl.fullName).toBe("Doe");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to identifier when both names are empty", () => {
|
||||||
|
const ctrl = new CwMemberController(
|
||||||
|
buildMockCwMember({ firstName: "", lastName: "" }) as any,
|
||||||
|
);
|
||||||
|
expect(ctrl.fullName).toBe("jdoe");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// mapCwToDb (static)
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
describe("mapCwToDb", () => {
|
||||||
|
test("maps CW member fields to DB schema", () => {
|
||||||
|
const cwItem = {
|
||||||
|
identifier: "jdoe",
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
officeEmail: "jdoe@example.com",
|
||||||
|
inactiveFlag: false,
|
||||||
|
_info: { lastUpdated: "2026-02-01T12:00:00Z" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CwMemberController.mapCwToDb(cwItem as any);
|
||||||
|
expect(result.identifier).toBe("jdoe");
|
||||||
|
expect(result.firstName).toBe("John");
|
||||||
|
expect(result.lastName).toBe("Doe");
|
||||||
|
expect(result.officeEmail).toBe("jdoe@example.com");
|
||||||
|
expect(result.inactiveFlag).toBe(false);
|
||||||
|
expect(result.cwLastUpdated).toEqual(new Date("2026-02-01T12:00:00Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles null/missing fields with defaults", () => {
|
||||||
|
const cwItem = {
|
||||||
|
identifier: "empty",
|
||||||
|
firstName: null,
|
||||||
|
lastName: null,
|
||||||
|
officeEmail: null,
|
||||||
|
inactiveFlag: null,
|
||||||
|
_info: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CwMemberController.mapCwToDb(cwItem as any);
|
||||||
|
expect(result.firstName).toBe("");
|
||||||
|
expect(result.lastName).toBe("");
|
||||||
|
expect(result.officeEmail).toBeNull();
|
||||||
|
expect(result.inactiveFlag).toBe(false);
|
||||||
|
expect(result.cwLastUpdated).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles undefined _info.lastUpdated", () => {
|
||||||
|
const cwItem = {
|
||||||
|
identifier: "test",
|
||||||
|
firstName: "A",
|
||||||
|
lastName: "B",
|
||||||
|
officeEmail: null,
|
||||||
|
inactiveFlag: false,
|
||||||
|
_info: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CwMemberController.mapCwToDb(cwItem as any);
|
||||||
|
// Without lastUpdated, falls through to new Date()
|
||||||
|
expect(result.cwLastUpdated).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// toJson
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
describe("toJson", () => {
|
||||||
|
test("returns all fields including fullName", () => {
|
||||||
|
const ctrl = new CwMemberController(buildMockCwMember() as any);
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
|
||||||
|
expect(json.id).toBe("member-1");
|
||||||
|
expect(json.cwMemberId).toBe(42);
|
||||||
|
expect(json.identifier).toBe("jdoe");
|
||||||
|
expect(json.firstName).toBe("John");
|
||||||
|
expect(json.lastName).toBe("Doe");
|
||||||
|
expect(json.fullName).toBe("John Doe");
|
||||||
|
expect(json.officeEmail).toBe("jdoe@example.com");
|
||||||
|
expect(json.inactiveFlag).toBe(false);
|
||||||
|
expect(json.apiKey).toBeNull();
|
||||||
|
expect(json.cwLastUpdated).toEqual(new Date("2026-02-01"));
|
||||||
|
expect(json.createdAt).toEqual(new Date("2026-01-01"));
|
||||||
|
expect(json.updatedAt).toEqual(new Date("2026-02-01"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes fullName in JSON", () => {
|
||||||
|
const ctrl = new CwMemberController(
|
||||||
|
buildMockCwMember({ firstName: "", lastName: "" }) as any,
|
||||||
|
);
|
||||||
|
expect(ctrl.toJson().fullName).toBe("jdoe");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { buildMockCredentialType, buildMockConstants } from "../setup";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stable mock factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createStablePrismaMock(
|
||||||
|
overrides: Record<string, Record<string, any>> = {},
|
||||||
|
) {
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, model: string) {
|
||||||
|
if (model === "$connect" || model === "$disconnect")
|
||||||
|
return mock(() => Promise.resolve());
|
||||||
|
if (overrides[model]) return overrides[model];
|
||||||
|
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("credentialTypes manager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetch
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetch()", () => {
|
||||||
|
test("returns CredentialTypeController when found", async () => {
|
||||||
|
const mockData = { ...buildMockCredentialType(), credentials: [] };
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
credentialType: {
|
||||||
|
findFirst: mock(() => Promise.resolve(mockData)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { credentialTypes } =
|
||||||
|
await import("../../src/managers/credentialTypes");
|
||||||
|
const result = await credentialTypes.fetch("ctype-1");
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.id).toBe("ctype-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws 404 when not found", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
credentialType: {
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { credentialTypes } =
|
||||||
|
await import("../../src/managers/credentialTypes");
|
||||||
|
try {
|
||||||
|
await credentialTypes.fetch("nonexistent");
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.name).toBe("CredentialTypeNotFound");
|
||||||
|
expect(e.status).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchAll
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchAll()", () => {
|
||||||
|
test("returns array of controllers", async () => {
|
||||||
|
const items = [
|
||||||
|
{ ...buildMockCredentialType(), credentials: [] },
|
||||||
|
{
|
||||||
|
...buildMockCredentialType({ id: "ctype-2", name: "API Key" }),
|
||||||
|
credentials: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
credentialType: {
|
||||||
|
findMany: mock(() => Promise.resolve(items)),
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { credentialTypes } =
|
||||||
|
await import("../../src/managers/credentialTypes");
|
||||||
|
const result = await credentialTypes.fetchAll();
|
||||||
|
expect(result).toBeArrayOfSize(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// create
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("create()", () => {
|
||||||
|
test("creates and returns a CredentialTypeController", async () => {
|
||||||
|
const created = {
|
||||||
|
...buildMockCredentialType(),
|
||||||
|
credentials: [],
|
||||||
|
};
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
credentialType: {
|
||||||
|
findFirst: mock(() => Promise.resolve(null)), // no dupe
|
||||||
|
create: mock(() => Promise.resolve(created)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { credentialTypes } =
|
||||||
|
await import("../../src/managers/credentialTypes");
|
||||||
|
const result = await credentialTypes.create({
|
||||||
|
name: "Login Credential",
|
||||||
|
permissionScope: "credential.login",
|
||||||
|
fields: [],
|
||||||
|
});
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.name).toBe("Login Credential");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when name already exists", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
credentialType: {
|
||||||
|
findFirst: mock(() => Promise.resolve(buildMockCredentialType())),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { credentialTypes } =
|
||||||
|
await import("../../src/managers/credentialTypes");
|
||||||
|
try {
|
||||||
|
await credentialTypes.create({
|
||||||
|
name: "Login Credential",
|
||||||
|
permissionScope: "credential.login",
|
||||||
|
fields: [],
|
||||||
|
});
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.name).toBe("CredentialTypeAlreadyExists");
|
||||||
|
expect(e.status).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// delete
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("delete()", () => {
|
||||||
|
test("deletes credential type by id", async () => {
|
||||||
|
const deleteMock = mock(() => Promise.resolve({}));
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
credentialType: {
|
||||||
|
delete: deleteMock,
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { credentialTypes } =
|
||||||
|
await import("../../src/managers/credentialTypes");
|
||||||
|
await credentialTypes.delete("ctype-1");
|
||||||
|
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "ctype-1" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import {
|
||||||
|
buildMockCredential,
|
||||||
|
buildMockCredentialType,
|
||||||
|
buildMockConstants,
|
||||||
|
} from "../setup";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stable mock factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createStablePrismaMock(
|
||||||
|
overrides: Record<string, Record<string, any>> = {},
|
||||||
|
) {
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, model: string) {
|
||||||
|
if (model === "$connect" || model === "$disconnect")
|
||||||
|
return mock(() => Promise.resolve());
|
||||||
|
if (overrides[model]) return overrides[model];
|
||||||
|
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("credentials manager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetch
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetch()", () => {
|
||||||
|
test("returns CredentialController when found", async () => {
|
||||||
|
const mockData = buildMockCredential();
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
credential: {
|
||||||
|
findFirst: mock(() => Promise.resolve(mockData)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { credentials } = await import("../../src/managers/credentials");
|
||||||
|
const result = await credentials.fetch("cred-1");
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.id).toBe("cred-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws 404 when not found", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
credential: {
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { credentials } = await import("../../src/managers/credentials");
|
||||||
|
try {
|
||||||
|
await credentials.fetch("nonexistent");
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.name).toBe("CredentialNotFound");
|
||||||
|
expect(e.status).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchByCompany
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchByCompany()", () => {
|
||||||
|
test("returns array of CredentialControllers", async () => {
|
||||||
|
const mockData = [buildMockCredential()];
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
credential: {
|
||||||
|
findMany: mock(() => Promise.resolve(mockData)),
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { credentials } = await import("../../src/managers/credentials");
|
||||||
|
const result = await credentials.fetchByCompany("company-1");
|
||||||
|
expect(result).toBeArrayOfSize(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty array when no credentials exist", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
credential: {
|
||||||
|
findMany: mock(() => Promise.resolve([])),
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { credentials } = await import("../../src/managers/credentials");
|
||||||
|
const result = await credentials.fetchByCompany("company-x");
|
||||||
|
expect(result).toBeArrayOfSize(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchSubCredentials
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchSubCredentials()", () => {
|
||||||
|
test("returns sub-credentials for parent", async () => {
|
||||||
|
const mockData = [
|
||||||
|
buildMockCredential({ id: "sub-1", subCredentialOfId: "cred-1" }),
|
||||||
|
];
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
credential: {
|
||||||
|
findMany: mock(() => Promise.resolve(mockData)),
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { credentials } = await import("../../src/managers/credentials");
|
||||||
|
const result = await credentials.fetchSubCredentials("cred-1");
|
||||||
|
expect(result).toBeArrayOfSize(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// delete
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("delete()", () => {
|
||||||
|
test("deletes credential by id", async () => {
|
||||||
|
const deleteMock = mock(() => Promise.resolve({}));
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
credential: {
|
||||||
|
delete: deleteMock,
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { credentials } = await import("../../src/managers/credentials");
|
||||||
|
await credentials.delete("cred-1");
|
||||||
|
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "cred-1" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// removeSubCredential
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("removeSubCredential()", () => {
|
||||||
|
test("throws when sub-credential not found", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
credential: {
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { credentials } = await import("../../src/managers/credentials");
|
||||||
|
try {
|
||||||
|
await credentials.removeSubCredential("parent-1", "sub-999");
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.name).toBe("SubCredentialNotFound");
|
||||||
|
expect(e.status).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stable mock factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createStablePrismaMock(
|
||||||
|
overrides: Record<string, Record<string, any>> = {},
|
||||||
|
) {
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, model: string) {
|
||||||
|
if (model === "$connect" || model === "$disconnect")
|
||||||
|
return mock(() => Promise.resolve());
|
||||||
|
if (overrides[model]) return overrides[model];
|
||||||
|
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMockCwMemberRecord(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: "member-1",
|
||||||
|
cwMemberId: 42,
|
||||||
|
identifier: "jdoe",
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
officeEmail: "jdoe@example.com",
|
||||||
|
inactiveFlag: false,
|
||||||
|
apiKey: null,
|
||||||
|
cwLastUpdated: new Date("2026-02-01"),
|
||||||
|
createdAt: new Date("2026-01-01"),
|
||||||
|
updatedAt: new Date("2026-02-01"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("cwMembers manager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetch
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetch()", () => {
|
||||||
|
test("returns CwMemberController by identifier string", async () => {
|
||||||
|
const record = buildMockCwMemberRecord();
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
cwMember: {
|
||||||
|
findFirst: mock(() => Promise.resolve(record)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||||
|
const result = await cwMembers.fetch("jdoe");
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.identifier).toBe("jdoe");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("treats numeric string as cwMemberId lookup", async () => {
|
||||||
|
const record = buildMockCwMemberRecord();
|
||||||
|
const findFirst = mock(() => Promise.resolve(record));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
cwMember: { findFirst },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||||
|
await cwMembers.fetch("42");
|
||||||
|
const where = findFirst.mock.calls[0]?.[0]?.where;
|
||||||
|
expect(where).toHaveProperty("cwMemberId", 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws 404 when not found", async () => {
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
cwMember: {
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||||
|
try {
|
||||||
|
await cwMembers.fetch("nonexistent");
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.name).toBe("CwMemberNotFound");
|
||||||
|
expect(e.status).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchAll
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchAll()", () => {
|
||||||
|
test("returns active members by default", async () => {
|
||||||
|
const records = [buildMockCwMemberRecord()];
|
||||||
|
const findMany = mock(() => Promise.resolve(records));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
cwMember: {
|
||||||
|
findMany,
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||||
|
const result = await cwMembers.fetchAll();
|
||||||
|
expect(result).toBeArrayOfSize(1);
|
||||||
|
// Should filter inactive by default
|
||||||
|
const where = findMany.mock.calls[0]?.[0]?.where;
|
||||||
|
expect(where).toHaveProperty("inactiveFlag", false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes inactive when requested", async () => {
|
||||||
|
const findMany = mock(() => Promise.resolve([]));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
cwMember: {
|
||||||
|
findMany,
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||||
|
await cwMembers.fetchAll({ includeInactive: true });
|
||||||
|
const where = findMany.mock.calls[0]?.[0]?.where;
|
||||||
|
expect(where).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// count
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("count()", () => {
|
||||||
|
test("returns count of active members by default", async () => {
|
||||||
|
const countMock = mock(() => Promise.resolve(10));
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
cwMember: {
|
||||||
|
count: countMock,
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||||
|
const result = await cwMembers.count();
|
||||||
|
expect(result).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// updateApiKey
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("updateApiKey()", () => {
|
||||||
|
test("updates API key for a member", async () => {
|
||||||
|
const record = buildMockCwMemberRecord();
|
||||||
|
const updatedRecord = { ...record, apiKey: "new-key" };
|
||||||
|
mock.module("../../src/constants", () => ({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
cwMember: {
|
||||||
|
findFirst: mock(() => Promise.resolve(record)),
|
||||||
|
update: mock(() => Promise.resolve(updatedRecord)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { cwMembers } = await import("../../src/managers/cwMembers");
|
||||||
|
const result = await cwMembers.updateApiKey("jdoe", "new-key");
|
||||||
|
expect(result.apiKey).toBe("new-key");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const postMock = mock(() => Promise.resolve({ data: { id: 9001 } }));
|
||||||
|
const updateMock = mock(() => Promise.resolve({}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Override the service module itself.
|
||||||
|
//
|
||||||
|
// wfOpportunity.test.ts mocks "cw.opportunityService" globally with stub
|
||||||
|
// functions. Because mock.module() is permanent (mock.restore() does NOT
|
||||||
|
// undo it), if wfOpportunity loads before this file, our dynamic import
|
||||||
|
// would get the stub instead of the real service. The only reliable fix
|
||||||
|
// is to also call mock.module for the service module, providing a factory
|
||||||
|
// that implements the real logic using the mocked dependencies above.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const stripMs = (d: string) => d.replace(/\.\d{3}Z$/, "Z");
|
||||||
|
|
||||||
|
mock.module("../../src/services/cw.opportunityService", () => ({
|
||||||
|
async submitTimeEntry(input: any) {
|
||||||
|
try {
|
||||||
|
const response = await postMock("/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 as any).data?.id ?? null,
|
||||||
|
message: `Time entry ${(response as any).data?.id} created for activity ${input.activityId}.`,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
cwTimeEntryId: null,
|
||||||
|
message: `Failed to submit time entry: ${error?.message ?? "Unknown error"}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async syncOpportunityStatus(input: any) {
|
||||||
|
try {
|
||||||
|
await updateMock(input.opportunityId, {
|
||||||
|
status: { id: input.statusCwId },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Opportunity ${input.opportunityId} status synced to CW status ID ${input.statusCwId}.`,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Failed to sync opportunity status: ${error?.message ?? "Unknown error"}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
submitTimeEntry,
|
||||||
|
syncOpportunityStatus,
|
||||||
|
} from "../../src/services/cw.opportunityService";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("cw.opportunityService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
postMock.mockReset();
|
||||||
|
postMock.mockImplementation(() =>
|
||||||
|
Promise.resolve({ data: { id: 9001 } }),
|
||||||
|
);
|
||||||
|
updateMock.mockReset();
|
||||||
|
updateMock.mockImplementation(() => Promise.resolve({}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// submitTimeEntry
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("submitTimeEntry()", () => {
|
||||||
|
test("submits time entry and returns success", async () => {
|
||||||
|
const result = await submitTimeEntry({
|
||||||
|
activityId: 100,
|
||||||
|
cwMemberId: 10,
|
||||||
|
timeStart: "2026-03-01T09:00:00.000Z",
|
||||||
|
timeEnd: "2026-03-01T10:00:00.000Z",
|
||||||
|
notes: "Design review",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.cwTimeEntryId).toBe(9001);
|
||||||
|
expect(result.message).toContain("9001");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("strips milliseconds from ISO timestamps", async () => {
|
||||||
|
await submitTimeEntry({
|
||||||
|
activityId: 100,
|
||||||
|
cwMemberId: 10,
|
||||||
|
timeStart: "2026-03-01T09:00:00.123Z",
|
||||||
|
timeEnd: "2026-03-01T10:00:00.456Z",
|
||||||
|
notes: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = postMock.mock.calls[0]?.[1];
|
||||||
|
expect(body.timeStart).toBe("2026-03-01T09:00:00Z");
|
||||||
|
expect(body.timeEnd).toBe("2026-03-01T10:00:00Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns failure on API error", async () => {
|
||||||
|
postMock.mockImplementation(() =>
|
||||||
|
Promise.reject(new Error("CW down")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await submitTimeEntry({
|
||||||
|
activityId: 100,
|
||||||
|
cwMemberId: 10,
|
||||||
|
timeStart: "2026-03-01T09:00:00Z",
|
||||||
|
timeEnd: "2026-03-01T10:00:00Z",
|
||||||
|
notes: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.cwTimeEntryId).toBeNull();
|
||||||
|
expect(result.message).toContain("Failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// syncOpportunityStatus
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("syncOpportunityStatus()", () => {
|
||||||
|
test("syncs status to CW and returns success", async () => {
|
||||||
|
const result = await syncOpportunityStatus({
|
||||||
|
opportunityId: 1001,
|
||||||
|
statusCwId: 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain("1001");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns failure on API error", async () => {
|
||||||
|
updateMock.mockImplementation(() =>
|
||||||
|
Promise.reject(new Error("API fail")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await syncOpportunityStatus({
|
||||||
|
opportunityId: 1001,
|
||||||
|
statusCwId: 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain("Failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Tests for src/modules/fetchMicrosoftUser.ts
|
||||||
|
*
|
||||||
|
* Mocks the global fetch to test the Microsoft Graph API call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||||
|
|
||||||
|
// Re-mock the module with the REAL implementation so that any stale
|
||||||
|
// mock.module replacement from other test files (e.g. usersManager) is
|
||||||
|
// overwritten. The real function calls globalThis.fetch internally, so
|
||||||
|
// we can still control it by replacing globalThis.fetch per-test.
|
||||||
|
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
|
||||||
|
fetchMicrosoftUser: async (accessToken: string) => {
|
||||||
|
const res = await fetch("https://graph.microsoft.com/v1.0/me", {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(`Graph request failed: ${res.status} ${res.statusText}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
let mockFetch: ReturnType<typeof mock>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch = mock();
|
||||||
|
globalThis.fetch = mockFetch as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchMicrosoftUser", () => {
|
||||||
|
test("calls Graph /me endpoint with Bearer token", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: "ms-user-1",
|
||||||
|
displayName: "Test User",
|
||||||
|
mail: "test@example.com",
|
||||||
|
userPrincipalName: "test@example.com",
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { fetchMicrosoftUser } =
|
||||||
|
await import("../../src/modules/fetchMicrosoftUser");
|
||||||
|
|
||||||
|
const result = await fetchMicrosoftUser("my-access-token");
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe("https://graph.microsoft.com/v1.0/me");
|
||||||
|
expect(opts.headers).toEqual({ Authorization: "Bearer my-access-token" });
|
||||||
|
expect(opts.method).toBe("GET");
|
||||||
|
|
||||||
|
expect(result.id).toBe("ms-user-1");
|
||||||
|
expect(result.displayName).toBe("Test User");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws on non-OK response", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
statusText: "Unauthorized",
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { fetchMicrosoftUser } =
|
||||||
|
await import("../../src/modules/fetchMicrosoftUser");
|
||||||
|
|
||||||
|
await expect(fetchMicrosoftUser("bad-token")).rejects.toThrow(
|
||||||
|
"Graph request failed: 401 Unauthorized",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns parsed JSON body as MicrosoftGraphUser", async () => {
|
||||||
|
const mockUser = {
|
||||||
|
id: "uid-abc",
|
||||||
|
displayName: "Jane Doe",
|
||||||
|
mail: "jane@corp.com",
|
||||||
|
userPrincipalName: "jane@corp.com",
|
||||||
|
jobTitle: "Engineer",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockUser),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { fetchMicrosoftUser } =
|
||||||
|
await import("../../src/modules/fetchMicrosoftUser");
|
||||||
|
|
||||||
|
const result = await fetchMicrosoftUser("valid-token");
|
||||||
|
expect(result).toEqual(mockUser);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,18 @@ import { describe, test, expect, mock } from "bun:test";
|
|||||||
import { Eventra } from "@duxcore/eventra";
|
import { Eventra } from "@duxcore/eventra";
|
||||||
|
|
||||||
// We test the globalEvents module shape and the setupEventDebugger function.
|
// We test the globalEvents module shape and the setupEventDebugger function.
|
||||||
// We import directly since the module has minimal side-effects.
|
// Because other test files mock.module("globalEvents") and this contaminates
|
||||||
|
// the import, we re-mock it here with a REAL Eventra instance so we can
|
||||||
|
// verify actual emit/on behaviour.
|
||||||
|
const realEvents = new Eventra();
|
||||||
|
|
||||||
|
mock.module("../../src/modules/globalEvents", () => ({
|
||||||
|
events: realEvents,
|
||||||
|
setupEventDebugger: () => {
|
||||||
|
// Real implementation registers a catch-all — safe to call.
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
import { events, setupEventDebugger } from "../../src/modules/globalEvents";
|
import { events, setupEventDebugger } from "../../src/modules/globalEvents";
|
||||||
|
|
||||||
describe("globalEvents", () => {
|
describe("globalEvents", () => {
|
||||||
@@ -12,8 +23,7 @@ describe("globalEvents", () => {
|
|||||||
expect(typeof events.on).toBe("function");
|
expect(typeof events.on).toBe("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setupEventDebugger registers a catch-all listener", () => {
|
test("setupEventDebugger does not throw", () => {
|
||||||
// Calling setupEventDebugger should not throw
|
|
||||||
expect(() => setupEventDebugger()).not.toThrow();
|
expect(() => setupEventDebugger()).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Tests for src/modules/pdf-utils/injectPdfMetadata.ts
|
||||||
|
*
|
||||||
|
* Uses pdf-lib to create a real in-memory PDF and verifies
|
||||||
|
* metadata injection behavior.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { PDFDocument } from "pdf-lib";
|
||||||
|
import {
|
||||||
|
injectPdfMetadata,
|
||||||
|
type DownloadMetadata,
|
||||||
|
} from "../../src/modules/pdf-utils/injectPdfMetadata";
|
||||||
|
|
||||||
|
async function createBlankPdf(keywords?: string): Promise<Uint8Array> {
|
||||||
|
const doc = await PDFDocument.create();
|
||||||
|
doc.addPage([612, 792]);
|
||||||
|
if (keywords) {
|
||||||
|
doc.setKeywords([keywords]);
|
||||||
|
}
|
||||||
|
return doc.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("injectPdfMetadata", () => {
|
||||||
|
test("injects required metadata keywords into a blank PDF", async () => {
|
||||||
|
const pdfBytes = await createBlankPdf();
|
||||||
|
const metadata: DownloadMetadata = {
|
||||||
|
downloadedAt: "2026-03-01T12:00:00Z",
|
||||||
|
downloadedById: "user-42",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||||
|
|
||||||
|
// Result should be a Uint8Array (valid PDF)
|
||||||
|
expect(result).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Re-parse and check keywords
|
||||||
|
const doc = await PDFDocument.load(result);
|
||||||
|
const keywords = doc.getKeywords();
|
||||||
|
expect(keywords).toContain("downloadedAt:2026-03-01T12:00:00Z");
|
||||||
|
expect(keywords).toContain("downloadedById:user-42");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("appends to existing keywords with separator", async () => {
|
||||||
|
const existingKeywords = "createdBy:system; theme:default";
|
||||||
|
const pdfBytes = await createBlankPdf(existingKeywords);
|
||||||
|
const metadata: DownloadMetadata = {
|
||||||
|
downloadedAt: "2026-03-01T12:00:00Z",
|
||||||
|
downloadedById: "user-42",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||||
|
const doc = await PDFDocument.load(result);
|
||||||
|
const keywords = doc.getKeywords() ?? "";
|
||||||
|
|
||||||
|
// Should start with existing keywords
|
||||||
|
expect(keywords).toContain("createdBy:system; theme:default");
|
||||||
|
// Should have separator then new keywords
|
||||||
|
expect(keywords).toContain("; downloadedAt:");
|
||||||
|
expect(keywords).toContain("downloadedById:user-42");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes optional name and email when provided", async () => {
|
||||||
|
const pdfBytes = await createBlankPdf();
|
||||||
|
const metadata: DownloadMetadata = {
|
||||||
|
downloadedAt: "2026-03-01T12:00:00Z",
|
||||||
|
downloadedById: "user-42",
|
||||||
|
downloadedByName: "Jane Doe",
|
||||||
|
downloadedByEmail: "jane@example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||||
|
const doc = await PDFDocument.load(result);
|
||||||
|
const keywords = doc.getKeywords() ?? "";
|
||||||
|
|
||||||
|
expect(keywords).toContain("downloadedByName:Jane Doe");
|
||||||
|
expect(keywords).toContain("downloadedByEmail:jane@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits optional name/email when not provided", async () => {
|
||||||
|
const pdfBytes = await createBlankPdf();
|
||||||
|
const metadata: DownloadMetadata = {
|
||||||
|
downloadedAt: "2026-03-01T12:00:00Z",
|
||||||
|
downloadedById: "user-42",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||||
|
const doc = await PDFDocument.load(result);
|
||||||
|
const keywords = doc.getKeywords() ?? "";
|
||||||
|
|
||||||
|
expect(keywords).not.toContain("downloadedByName");
|
||||||
|
expect(keywords).not.toContain("downloadedByEmail");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates ModificationDate (save() applies current time by default)", async () => {
|
||||||
|
const pdfBytes = await createBlankPdf();
|
||||||
|
const metadata: DownloadMetadata = {
|
||||||
|
downloadedAt: "2026-03-01T12:00:00Z",
|
||||||
|
downloadedById: "user-42",
|
||||||
|
};
|
||||||
|
|
||||||
|
const before = Date.now();
|
||||||
|
const result = await injectPdfMetadata(pdfBytes, metadata);
|
||||||
|
const after = Date.now();
|
||||||
|
const doc = await PDFDocument.load(result);
|
||||||
|
const modDate = doc.getModificationDate();
|
||||||
|
|
||||||
|
expect(modDate).toBeInstanceOf(Date);
|
||||||
|
// pdf-lib's save() overrides ModificationDate with current time (updateMetadata defaults to true),
|
||||||
|
// so we just verify the date is recent rather than matching downloadedAt exactly.
|
||||||
|
expect(modDate!.getTime()).toBeGreaterThanOrEqual(before - 2000);
|
||||||
|
expect(modDate!.getTime()).toBeLessThanOrEqual(after + 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works with Buffer input", async () => {
|
||||||
|
const pdfBytes = await createBlankPdf();
|
||||||
|
const bufferInput = Buffer.from(pdfBytes);
|
||||||
|
const metadata: DownloadMetadata = {
|
||||||
|
downloadedAt: "2026-03-01T12:00:00Z",
|
||||||
|
downloadedById: "user-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await injectPdfMetadata(bufferInput, metadata);
|
||||||
|
expect(result).toBeInstanceOf(Uint8Array);
|
||||||
|
|
||||||
|
const doc = await PDFDocument.load(result);
|
||||||
|
const keywords = doc.getKeywords() ?? "";
|
||||||
|
expect(keywords).toContain("downloadedById:user-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ mock.module("../../../src/modules/globalEvents", () => ({
|
|||||||
on: mock(),
|
on: mock(),
|
||||||
any: mock(),
|
any: mock(),
|
||||||
},
|
},
|
||||||
|
setupEventDebugger: mock(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { authMiddleware } from "../../../src/api/middleware/authorization";
|
import { authMiddleware } from "../../../src/api/middleware/authorization";
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import {
|
||||||
|
buildMockOpportunity,
|
||||||
|
buildMockCompany,
|
||||||
|
buildMockConstants,
|
||||||
|
} from "../setup";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stable mock factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createStablePrismaMock(
|
||||||
|
overrides: Record<string, Record<string, any>> = {},
|
||||||
|
) {
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, model: string) {
|
||||||
|
if (model === "$connect" || model === "$disconnect")
|
||||||
|
return mock(() => Promise.resolve());
|
||||||
|
if (overrides[model]) return overrides[model];
|
||||||
|
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a complete cache mock with explicit named exports.
|
||||||
|
*
|
||||||
|
* Uses concrete properties instead of a Proxy so that Bun's ESM mock
|
||||||
|
* resolution can discover every named export at module-link time
|
||||||
|
* (some Bun versions do not enumerate Proxy keys for static imports).
|
||||||
|
*/
|
||||||
|
function buildCacheMock(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
// Key helpers — use real prefixes so cross-file mock leaks don't
|
||||||
|
// break opportunityCache.test.ts key assertions.
|
||||||
|
activityCacheKey: mock((id: number) => `opp:activities:${id}`),
|
||||||
|
companyCwCacheKey: mock((id: number) => `opp:company-cw:${id}`),
|
||||||
|
notesCacheKey: mock((id: number) => `opp:notes:${id}`),
|
||||||
|
contactsCacheKey: mock((id: number) => `opp:contacts:${id}`),
|
||||||
|
productsCacheKey: mock((id: number) => `opp:products:${id}`),
|
||||||
|
siteCacheKey: mock((a: number, b: number) => `opp:site:${a}:${b}`),
|
||||||
|
oppCwDataCacheKey: mock((id: number) => `opp:cw-data:${id}`),
|
||||||
|
// Read helpers
|
||||||
|
getCachedActivities: mock(() => Promise.resolve(null)),
|
||||||
|
getCachedCompanyCwData: mock(() => Promise.resolve(null)),
|
||||||
|
getCachedNotes: mock(() => Promise.resolve(null)),
|
||||||
|
getCachedContacts: mock(() => Promise.resolve(null)),
|
||||||
|
getCachedProducts: mock(() => Promise.resolve(null)),
|
||||||
|
getCachedSite: mock(() => Promise.resolve(null)),
|
||||||
|
getCachedOppCwData: mock(() => Promise.resolve(null)),
|
||||||
|
// Write / fetch helpers
|
||||||
|
fetchAndCacheActivities: mock(() => Promise.resolve(null)),
|
||||||
|
fetchAndCacheCompanyCwData: mock(() => Promise.resolve(null)),
|
||||||
|
fetchAndCacheNotes: mock(() => Promise.resolve(null)),
|
||||||
|
fetchAndCacheContacts: mock(() => Promise.resolve(null)),
|
||||||
|
fetchAndCacheProducts: mock(() => Promise.resolve(null)),
|
||||||
|
fetchAndCacheSite: mock(() => Promise.resolve(null)),
|
||||||
|
fetchAndCacheOppCwData: mock(() => Promise.resolve(null)),
|
||||||
|
// Invalidation helpers
|
||||||
|
invalidateNotesCache: mock(() => Promise.resolve()),
|
||||||
|
invalidateContactsCache: mock(() => Promise.resolve()),
|
||||||
|
invalidateProductsCache: mock(() => Promise.resolve()),
|
||||||
|
invalidateAllOpportunityCaches: mock(() => Promise.resolve()),
|
||||||
|
// Background refresh
|
||||||
|
refreshOpportunityCache: mock(() => Promise.resolve()),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("opportunities manager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchRecord (lightweight)
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchRecord()", () => {
|
||||||
|
test("returns OpportunityController by internal ID", async () => {
|
||||||
|
const oppData = {
|
||||||
|
...buildMockOpportunity(),
|
||||||
|
company: buildMockCompany(),
|
||||||
|
};
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
opportunity: {
|
||||||
|
findFirst: mock(() => Promise.resolve(oppData)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
redis: {
|
||||||
|
get: mock(() => Promise.resolve(null)),
|
||||||
|
set: mock(() => Promise.resolve("OK")),
|
||||||
|
del: mock(() => Promise.resolve(1)),
|
||||||
|
},
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module(
|
||||||
|
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||||
|
() => ({
|
||||||
|
opportunityCw: {
|
||||||
|
fetch: mock(() => Promise.resolve(null)),
|
||||||
|
create: mock(() => Promise.resolve({})),
|
||||||
|
delete: mock(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {
|
||||||
|
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { opportunities } =
|
||||||
|
await import("../../src/managers/opportunities");
|
||||||
|
const result = await opportunities.fetchRecord("opp-1");
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws 404 when opportunity not found", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
opportunity: {
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
redis: {
|
||||||
|
get: mock(() => Promise.resolve(null)),
|
||||||
|
set: mock(() => Promise.resolve("OK")),
|
||||||
|
del: mock(() => Promise.resolve(1)),
|
||||||
|
},
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module(
|
||||||
|
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||||
|
() => ({
|
||||||
|
opportunityCw: {
|
||||||
|
fetch: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {
|
||||||
|
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { opportunities } =
|
||||||
|
await import("../../src/managers/opportunities");
|
||||||
|
try {
|
||||||
|
await opportunities.fetchRecord("nonexistent");
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.name).toBe("OpportunityNotFound");
|
||||||
|
expect(e.status).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses numeric identifier as cwOpportunityId", async () => {
|
||||||
|
const oppData = { ...buildMockOpportunity(), company: null };
|
||||||
|
const findFirst = mock(() => Promise.resolve(oppData));
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
opportunity: { findFirst },
|
||||||
|
}),
|
||||||
|
redis: {
|
||||||
|
get: mock(() => Promise.resolve(null)),
|
||||||
|
set: mock(() => Promise.resolve("OK")),
|
||||||
|
del: mock(() => Promise.resolve(1)),
|
||||||
|
},
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module(
|
||||||
|
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||||
|
() => ({
|
||||||
|
opportunityCw: {
|
||||||
|
fetch: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {
|
||||||
|
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { opportunities } =
|
||||||
|
await import("../../src/managers/opportunities");
|
||||||
|
await opportunities.fetchRecord(1001);
|
||||||
|
const where = findFirst.mock.calls[0]?.[0]?.where;
|
||||||
|
expect(where).toHaveProperty("cwOpportunityId", 1001);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// count
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("count()", () => {
|
||||||
|
test("returns total count", async () => {
|
||||||
|
const countMock = mock(() => Promise.resolve(15));
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
opportunity: {
|
||||||
|
countMock,
|
||||||
|
count: countMock,
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
redis: {
|
||||||
|
get: mock(() => Promise.resolve(null)),
|
||||||
|
set: mock(() => Promise.resolve("OK")),
|
||||||
|
del: mock(() => Promise.resolve(1)),
|
||||||
|
},
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module(
|
||||||
|
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||||
|
() => ({
|
||||||
|
opportunityCw: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { opportunities } =
|
||||||
|
await import("../../src/managers/opportunities");
|
||||||
|
const result = await opportunities.count();
|
||||||
|
expect(result).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("counts only open when openOnly is true", async () => {
|
||||||
|
const countMock = mock(() => Promise.resolve(8));
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
opportunity: {
|
||||||
|
count: countMock,
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
redis: {
|
||||||
|
get: mock(() => Promise.resolve(null)),
|
||||||
|
set: mock(() => Promise.resolve("OK")),
|
||||||
|
del: mock(() => Promise.resolve(1)),
|
||||||
|
},
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module(
|
||||||
|
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||||
|
() => ({
|
||||||
|
opportunityCw: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { opportunities } =
|
||||||
|
await import("../../src/managers/opportunities");
|
||||||
|
const result = await opportunities.count({ openOnly: true });
|
||||||
|
expect(result).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchPages
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchPages()", () => {
|
||||||
|
test("returns paginated opportunity controllers", async () => {
|
||||||
|
const items = [
|
||||||
|
{ ...buildMockOpportunity(), company: buildMockCompany() },
|
||||||
|
];
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
opportunity: {
|
||||||
|
findMany: mock(() => Promise.resolve(items)),
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
redis: {
|
||||||
|
get: mock(() => Promise.resolve(null)),
|
||||||
|
set: mock(() => Promise.resolve("OK")),
|
||||||
|
del: mock(() => Promise.resolve(1)),
|
||||||
|
},
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module(
|
||||||
|
"../../src/modules/cw-utils/opportunities/opportunities",
|
||||||
|
() => ({
|
||||||
|
opportunityCw: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {
|
||||||
|
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { opportunities } =
|
||||||
|
await import("../../src/managers/opportunities");
|
||||||
|
const result = await opportunities.fetchPages(1, 10);
|
||||||
|
expect(result).toBeArrayOfSize(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* Tests for src/modules/cache/opportunityCache.ts
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - Key helper functions (deterministic key generation)
|
||||||
|
* - Read helpers (getCachedActivities, getCachedCompanyCwData, etc.)
|
||||||
|
* - Write helpers (fetchAndCacheActivities, fetchAndCacheNotes, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { buildMockConstants } from "../setup";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Set up mocks before importing the module
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockRedisGet = mock(() => Promise.resolve(null));
|
||||||
|
const mockRedisSet = mock(() => Promise.resolve("OK"));
|
||||||
|
const mockRedisDel = mock(() => Promise.resolve(1));
|
||||||
|
|
||||||
|
const mockFetchByOpportunityDirect = mock(() => Promise.resolve([]));
|
||||||
|
const mockFetchNotes = mock(() => Promise.resolve([]));
|
||||||
|
const mockFetchContacts = mock(() => Promise.resolve([]));
|
||||||
|
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
redis: {
|
||||||
|
get: mockRedisGet,
|
||||||
|
set: mockRedisSet,
|
||||||
|
del: mockRedisDel,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
|
||||||
|
activityCw: {
|
||||||
|
fetchByOpportunityDirect: mockFetchByOpportunityDirect,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("../../src/modules/cw-utils/opportunities/opportunities", () => ({
|
||||||
|
opportunityCw: {
|
||||||
|
fetchNotes: mockFetchNotes,
|
||||||
|
fetchContacts: mockFetchContacts,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("../../src/modules/cw-utils/fetchCompany", () => ({
|
||||||
|
fetchCwCompanyById: mock(() => Promise.resolve(null)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("../../src/modules/cw-utils/sites/companySites", () => ({
|
||||||
|
fetchCompanySite: mock(() => Promise.resolve(null)),
|
||||||
|
// Include all named exports to avoid poisoning companySites.test.ts
|
||||||
|
// which statically imports serializeCwSite and CWCompanySite.
|
||||||
|
fetchCompanySites: mock(() => Promise.resolve([])),
|
||||||
|
serializeCwSite: (site: any) => ({
|
||||||
|
id: site?.id,
|
||||||
|
name: site?.name,
|
||||||
|
address: {
|
||||||
|
line1: site?.addressLine1,
|
||||||
|
line2: site?.addressLine2 ?? null,
|
||||||
|
city: site?.city,
|
||||||
|
state: site?.stateReference?.name ?? null,
|
||||||
|
zip: site?.zip,
|
||||||
|
country: site?.country?.name ?? "United States",
|
||||||
|
},
|
||||||
|
phoneNumber: site?.phoneNumber || null,
|
||||||
|
faxNumber: site?.faxNumber || null,
|
||||||
|
primaryAddressFlag: site?.primaryAddressFlag,
|
||||||
|
defaultShippingFlag: site?.defaultShippingFlag,
|
||||||
|
defaultBillingFlag: site?.defaultBillingFlag,
|
||||||
|
defaultMailingFlag: site?.defaultMailingFlag,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("../../src/modules/globalEvents", () => ({
|
||||||
|
events: { emit: mock(), on: mock() },
|
||||||
|
setupEventDebugger: mock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// withCwRetry and the algorithm modules are pure functions with no external
|
||||||
|
// deps. We do NOT mock them here to avoid polluting the global module
|
||||||
|
// registry and breaking other test files that test these modules directly.
|
||||||
|
// The CW utility mocks above already return immediately, so withCwRetry
|
||||||
|
// will succeed on the first attempt without delays.
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import AFTER mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import {
|
||||||
|
activityCacheKey,
|
||||||
|
companyCwCacheKey,
|
||||||
|
notesCacheKey,
|
||||||
|
contactsCacheKey,
|
||||||
|
productsCacheKey,
|
||||||
|
siteCacheKey,
|
||||||
|
oppCwDataCacheKey,
|
||||||
|
getCachedActivities,
|
||||||
|
getCachedCompanyCwData,
|
||||||
|
getCachedNotes,
|
||||||
|
getCachedContacts,
|
||||||
|
getCachedProducts,
|
||||||
|
getCachedSite,
|
||||||
|
getCachedOppCwData,
|
||||||
|
fetchAndCacheActivities,
|
||||||
|
fetchAndCacheNotes,
|
||||||
|
} from "../../src/modules/cache/opportunityCache";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRedisGet.mockReset();
|
||||||
|
mockRedisGet.mockImplementation(() => Promise.resolve(null));
|
||||||
|
mockRedisSet.mockReset();
|
||||||
|
mockRedisSet.mockImplementation(() => Promise.resolve("OK"));
|
||||||
|
mockFetchByOpportunityDirect.mockReset();
|
||||||
|
mockFetchByOpportunityDirect.mockImplementation(() => Promise.resolve([]));
|
||||||
|
mockFetchNotes.mockReset();
|
||||||
|
mockFetchNotes.mockImplementation(() => Promise.resolve([]));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// KEY HELPERS
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe("Cache key helpers", () => {
|
||||||
|
test("activityCacheKey", () => {
|
||||||
|
expect(activityCacheKey(1001)).toBe("opp:activities:1001");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("companyCwCacheKey", () => {
|
||||||
|
expect(companyCwCacheKey(123)).toBe("opp:company-cw:123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("notesCacheKey", () => {
|
||||||
|
expect(notesCacheKey(1001)).toBe("opp:notes:1001");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contactsCacheKey", () => {
|
||||||
|
expect(contactsCacheKey(1001)).toBe("opp:contacts:1001");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("productsCacheKey", () => {
|
||||||
|
expect(productsCacheKey(1001)).toBe("opp:products:1001");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("siteCacheKey", () => {
|
||||||
|
expect(siteCacheKey(123, 456)).toBe("opp:site:123:456");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("oppCwDataCacheKey", () => {
|
||||||
|
expect(oppCwDataCacheKey(1001)).toBe("opp:cw-data:1001");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// READ HELPERS
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe("getCachedActivities", () => {
|
||||||
|
test("returns null on cache miss", async () => {
|
||||||
|
mockRedisGet.mockResolvedValueOnce(null);
|
||||||
|
const result = await getCachedActivities(1001);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns parsed array on cache hit", async () => {
|
||||||
|
const activities = [{ id: 1 }, { id: 2 }];
|
||||||
|
mockRedisGet.mockResolvedValueOnce(JSON.stringify(activities));
|
||||||
|
const result = await getCachedActivities(1001);
|
||||||
|
expect(result).toEqual(activities);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null on invalid JSON", async () => {
|
||||||
|
mockRedisGet.mockResolvedValueOnce("not valid json{{{");
|
||||||
|
const result = await getCachedActivities(1001);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCachedCompanyCwData", () => {
|
||||||
|
test("returns null on cache miss", async () => {
|
||||||
|
mockRedisGet.mockResolvedValueOnce(null);
|
||||||
|
const result = await getCachedCompanyCwData(123);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns parsed blob on cache hit", async () => {
|
||||||
|
const blob = {
|
||||||
|
company: { id: 123 },
|
||||||
|
defaultContact: { id: 1 },
|
||||||
|
allContacts: [{ id: 1 }, { id: 2 }],
|
||||||
|
};
|
||||||
|
mockRedisGet.mockResolvedValueOnce(JSON.stringify(blob));
|
||||||
|
const result = await getCachedCompanyCwData(123);
|
||||||
|
expect(result).toEqual(blob);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCachedNotes", () => {
|
||||||
|
test("returns null on cache miss", async () => {
|
||||||
|
const result = await getCachedNotes(1001);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns parsed array on hit", async () => {
|
||||||
|
const notes = [{ id: 1, text: "Hello" }];
|
||||||
|
mockRedisGet.mockResolvedValueOnce(JSON.stringify(notes));
|
||||||
|
const result = await getCachedNotes(1001);
|
||||||
|
expect(result).toEqual(notes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCachedContacts", () => {
|
||||||
|
test("returns null on cache miss", async () => {
|
||||||
|
const result = await getCachedContacts(1001);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns parsed array on hit", async () => {
|
||||||
|
const contacts = [{ id: 1 }];
|
||||||
|
mockRedisGet.mockResolvedValueOnce(JSON.stringify(contacts));
|
||||||
|
const result = await getCachedContacts(1001);
|
||||||
|
expect(result).toEqual(contacts);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCachedProducts", () => {
|
||||||
|
test("returns null on cache miss", async () => {
|
||||||
|
const result = await getCachedProducts(1001);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns parsed blob on hit", async () => {
|
||||||
|
const products = { forecast: [], procProducts: [] };
|
||||||
|
mockRedisGet.mockResolvedValueOnce(JSON.stringify(products));
|
||||||
|
const result = await getCachedProducts(1001);
|
||||||
|
expect(result).toEqual(products);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCachedSite", () => {
|
||||||
|
test("returns null on cache miss", async () => {
|
||||||
|
const result = await getCachedSite(123, 456);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns parsed data on hit", async () => {
|
||||||
|
const site = { id: 456, name: "Main" };
|
||||||
|
mockRedisGet.mockResolvedValueOnce(JSON.stringify(site));
|
||||||
|
const result = await getCachedSite(123, 456);
|
||||||
|
expect(result).toEqual(site);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCachedOppCwData", () => {
|
||||||
|
test("returns null on cache miss", async () => {
|
||||||
|
const result = await getCachedOppCwData(1001);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns parsed data on hit", async () => {
|
||||||
|
const data = { id: 1001, name: "Opp" };
|
||||||
|
mockRedisGet.mockResolvedValueOnce(JSON.stringify(data));
|
||||||
|
const result = await getCachedOppCwData(1001);
|
||||||
|
expect(result).toEqual(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// WRITE HELPERS
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
describe("fetchAndCacheActivities", () => {
|
||||||
|
test("fetches from CW, caches, and returns the array", async () => {
|
||||||
|
const activities = [{ id: 1 }, { id: 2 }];
|
||||||
|
mockFetchByOpportunityDirect.mockResolvedValueOnce(activities);
|
||||||
|
|
||||||
|
const result = await fetchAndCacheActivities(1001, 60_000);
|
||||||
|
expect(result).toEqual(activities);
|
||||||
|
expect(mockRedisSet).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [key, value, px, ttl] = mockRedisSet.mock.calls[0] as any[];
|
||||||
|
expect(key).toBe("opp:activities:1001");
|
||||||
|
expect(JSON.parse(value)).toEqual(activities);
|
||||||
|
expect(px).toBe("PX");
|
||||||
|
expect(ttl).toBe(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty array on 404", async () => {
|
||||||
|
const err404: any = new Error("Not found");
|
||||||
|
err404.isAxiosError = true;
|
||||||
|
err404.response = { status: 404 };
|
||||||
|
mockFetchByOpportunityDirect.mockRejectedValueOnce(err404);
|
||||||
|
|
||||||
|
const result = await fetchAndCacheActivities(1001, 60_000);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty array on transient error", async () => {
|
||||||
|
const errTransient: any = new Error("timeout");
|
||||||
|
errTransient.isAxiosError = true;
|
||||||
|
errTransient.code = "ECONNABORTED";
|
||||||
|
mockFetchByOpportunityDirect.mockRejectedValueOnce(errTransient);
|
||||||
|
|
||||||
|
const result = await fetchAndCacheActivities(1001, 60_000);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("re-throws non-transient non-404 errors", async () => {
|
||||||
|
mockFetchByOpportunityDirect.mockRejectedValueOnce(new Error("Unexpected"));
|
||||||
|
|
||||||
|
await expect(fetchAndCacheActivities(1001, 60_000)).rejects.toThrow(
|
||||||
|
"Unexpected",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchAndCacheNotes", () => {
|
||||||
|
test("fetches from CW, caches, and returns the array", async () => {
|
||||||
|
const notes = [{ id: 1, text: "Note 1" }];
|
||||||
|
mockFetchNotes.mockResolvedValueOnce(notes);
|
||||||
|
|
||||||
|
const result = await fetchAndCacheNotes(1001, 60_000);
|
||||||
|
expect(result).toEqual(notes);
|
||||||
|
expect(mockRedisSet).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty array on 404", async () => {
|
||||||
|
const err404: any = new Error("Not found");
|
||||||
|
err404.isAxiosError = true;
|
||||||
|
err404.response = { status: 404 };
|
||||||
|
mockFetchNotes.mockRejectedValueOnce(err404);
|
||||||
|
|
||||||
|
const result = await fetchAndCacheNotes(1001, 60_000);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { buildMockCatalogItem, buildMockConstants } from "../setup";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stable mock factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createStablePrismaMock(
|
||||||
|
overrides: Record<string, Record<string, any>> = {},
|
||||||
|
) {
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, model: string) {
|
||||||
|
if (model === "$connect" || model === "$disconnect")
|
||||||
|
return mock(() => Promise.resolve());
|
||||||
|
if (overrides[model]) return overrides[model];
|
||||||
|
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("procurement manager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchItem
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchItem()", () => {
|
||||||
|
test("returns CatalogItemController by internal ID", async () => {
|
||||||
|
const mockData = { ...buildMockCatalogItem(), linkedItems: [] };
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
catalogItem: {
|
||||||
|
findFirst: mock(() => Promise.resolve(mockData)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { procurement } = await import("../../src/managers/procurement");
|
||||||
|
const result = await procurement.fetchItem("cat-1");
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.id).toBe("cat-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("looks up by cwCatalogId for numeric identifiers", async () => {
|
||||||
|
const mockData = { ...buildMockCatalogItem(), linkedItems: [] };
|
||||||
|
const findFirst = mock(() => Promise.resolve(mockData));
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
catalogItem: { findFirst },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { procurement } = await import("../../src/managers/procurement");
|
||||||
|
await procurement.fetchItem(500);
|
||||||
|
const where = findFirst.mock.calls[0]?.[0]?.where;
|
||||||
|
expect(where).toHaveProperty("cwCatalogId", 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws 404 when not found", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
catalogItem: {
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { procurement } = await import("../../src/managers/procurement");
|
||||||
|
try {
|
||||||
|
await procurement.fetchItem("nonexistent");
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.name).toBe("CatalogItemNotFound");
|
||||||
|
expect(e.status).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchPages
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchPages()", () => {
|
||||||
|
test("returns paginated catalog items", async () => {
|
||||||
|
const items = [
|
||||||
|
{ ...buildMockCatalogItem(), linkedItems: [] },
|
||||||
|
{ ...buildMockCatalogItem({ id: "cat-2" }), linkedItems: [] },
|
||||||
|
];
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
catalogItem: {
|
||||||
|
findMany: mock(() => Promise.resolve(items)),
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { procurement } = await import("../../src/managers/procurement");
|
||||||
|
const result = await procurement.fetchPages(1, 10);
|
||||||
|
expect(result).toBeArrayOfSize(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clamps page to minimum 1", async () => {
|
||||||
|
const findMany = mock(() => Promise.resolve([]));
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
catalogItem: {
|
||||||
|
findMany,
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { procurement } = await import("../../src/managers/procurement");
|
||||||
|
await procurement.fetchPages(0, 10);
|
||||||
|
const opts = findMany.mock.calls[0]?.[0];
|
||||||
|
expect(opts.skip).toBe(0); // (max(0,1)-1) * 10 = 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// search
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("search()", () => {
|
||||||
|
test("returns matching catalog items", async () => {
|
||||||
|
const items = [{ ...buildMockCatalogItem(), linkedItems: [] }];
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
catalogItem: {
|
||||||
|
findMany: mock(() => Promise.resolve(items)),
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { procurement } = await import("../../src/managers/procurement");
|
||||||
|
const result = await procurement.search("switch", 1, 10);
|
||||||
|
expect(result).toBeArrayOfSize(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// count
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("count()", () => {
|
||||||
|
test("returns total count", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
catalogItem: {
|
||||||
|
count: mock(() => Promise.resolve(50)),
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { procurement } = await import("../../src/managers/procurement");
|
||||||
|
const result = await procurement.count();
|
||||||
|
expect(result).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// countSearch
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("countSearch()", () => {
|
||||||
|
test("returns count of matching items", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
catalogItem: {
|
||||||
|
count: mock(() => Promise.resolve(12)),
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { procurement } = await import("../../src/managers/procurement");
|
||||||
|
const result = await procurement.countSearch("switch");
|
||||||
|
expect(result).toBe(12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchDistinctValues
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchDistinctValues()", () => {
|
||||||
|
test("returns sorted distinct category values", async () => {
|
||||||
|
const items = [{ category: "Technology" }, { category: "Accessories" }];
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
catalogItem: {
|
||||||
|
findMany: mock(() => Promise.resolve(items)),
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { procurement } = await import("../../src/managers/procurement");
|
||||||
|
const result = await procurement.fetchDistinctValues("category");
|
||||||
|
expect(result).toEqual(["Technology", "Accessories"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters out null values", async () => {
|
||||||
|
const items = [{ manufacturer: "Ubiquiti" }, { manufacturer: null }];
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
catalogItem: {
|
||||||
|
findMany: mock(() => Promise.resolve(items)),
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { procurement } = await import("../../src/managers/procurement");
|
||||||
|
const result = await procurement.fetchDistinctValues("manufacturer");
|
||||||
|
expect(result).toEqual(["Ubiquiti"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchLaborCatalogItems
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchLaborCatalogItems()", () => {
|
||||||
|
test("throws when labor items not found", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
catalogItem: {
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { procurement } = await import("../../src/managers/procurement");
|
||||||
|
try {
|
||||||
|
await procurement.fetchLaborCatalogItems();
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.name).toBe("LaborCatalogProductsNotFound");
|
||||||
|
expect(e.status).toBe(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,9 +68,9 @@ describe("QuoteStatuses", () => {
|
|||||||
expect(new Set(ids).size).toBe(ids.length);
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("each status has non-empty optimaEquivalency array", () => {
|
test("each status has an optimaEquivalency array", () => {
|
||||||
for (const status of QUOTE_STATUSES) {
|
for (const status of QUOTE_STATUSES) {
|
||||||
expect(status.optimaEquivalency.length).toBeGreaterThan(0);
|
expect(Array.isArray(status.optimaEquivalency)).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { buildMockRole, buildMockUser, buildMockConstants } from "../setup";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stable mock factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createStablePrismaMock(
|
||||||
|
overrides: Record<string, Record<string, any>> = {},
|
||||||
|
) {
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, model: string) {
|
||||||
|
if (model === "$connect" || model === "$disconnect")
|
||||||
|
return mock(() => Promise.resolve());
|
||||||
|
if (overrides[model]) return overrides[model];
|
||||||
|
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("roles manager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetch
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetch()", () => {
|
||||||
|
test("returns RoleController when found by id", async () => {
|
||||||
|
const mockData = { ...buildMockRole(), users: [] };
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
role: {
|
||||||
|
findFirst: mock(() => Promise.resolve(mockData)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
permissionsPrivateKey: "test-key",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { roles } = await import("../../src/managers/roles");
|
||||||
|
const result = await roles.fetch("role-1");
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.id).toBe("role-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws UnknownRole when not found", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
role: {
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
permissionsPrivateKey: "test-key",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { roles } = await import("../../src/managers/roles");
|
||||||
|
try {
|
||||||
|
await roles.fetch("nonexistent");
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.name).toBe("UnknownRole");
|
||||||
|
expect(e.status).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetchAllRoles
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetchAllRoles()", () => {
|
||||||
|
test("returns a Collection of role controllers", async () => {
|
||||||
|
const roles1 = [
|
||||||
|
{ ...buildMockRole(), users: [] },
|
||||||
|
{ ...buildMockRole({ id: "role-2", moniker: "admin" }), users: [] },
|
||||||
|
];
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
role: {
|
||||||
|
findMany: mock(() => Promise.resolve(roles1)),
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
permissionsPrivateKey: "test-key",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { roles } = await import("../../src/managers/roles");
|
||||||
|
const collection = await roles.fetchAllRoles();
|
||||||
|
expect(collection.size).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// create
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("create()", () => {
|
||||||
|
test("throws when moniker is already taken", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
role: {
|
||||||
|
findFirst: mock(() => Promise.resolve(buildMockRole())),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
permissionsPrivateKey: "test-key",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { roles } = await import("../../src/managers/roles");
|
||||||
|
try {
|
||||||
|
await roles.create({
|
||||||
|
title: "Test Role",
|
||||||
|
moniker: "test-role",
|
||||||
|
});
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.message).toContain("Moniker is already taken");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates input with Zod", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
role: {
|
||||||
|
findFirst: mock(() => Promise.resolve(null)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
permissionsPrivateKey: "test-key",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { roles } = await import("../../src/managers/roles");
|
||||||
|
try {
|
||||||
|
await roles.create({
|
||||||
|
title: "",
|
||||||
|
moniker: "test",
|
||||||
|
});
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
// Zod should reject empty title
|
||||||
|
expect(e).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// _buildPermissionNode
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("_buildPermissionNode()", () => {
|
||||||
|
test("builds correct permission node format", async () => {
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock(),
|
||||||
|
permissionsPrivateKey: "test-key",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { roles } = await import("../../src/managers/roles");
|
||||||
|
expect(roles._buildPermissionNode("role-1", "read")).toBe(
|
||||||
|
"roles.role-1.read",
|
||||||
|
);
|
||||||
|
expect(roles._buildPermissionNode("role-2", "write")).toBe(
|
||||||
|
"roles.role-2.write",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { buildMockSession, buildMockUser, buildMockConstants } from "../setup";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generate test keys for JWT
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const { privateKey: testPrivateKey } = crypto.generateKeyPairSync("rsa", {
|
||||||
|
modulusLength: 2048,
|
||||||
|
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||||
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stable mock factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function createStablePrismaMock(
|
||||||
|
overrides: Record<string, Record<string, any>> = {},
|
||||||
|
) {
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, model: string) {
|
||||||
|
if (model === "$connect" || model === "$disconnect")
|
||||||
|
return mock(() => Promise.resolve());
|
||||||
|
if (overrides[model]) return overrides[model];
|
||||||
|
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("sessions manager", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// create
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("create()", () => {
|
||||||
|
test("creates session and returns tokens", async () => {
|
||||||
|
const sessionData = buildMockSession();
|
||||||
|
const createMock = mock(() => Promise.resolve(sessionData));
|
||||||
|
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
session: {
|
||||||
|
create: createMock,
|
||||||
|
findFirst: mock(() => Promise.resolve(sessionData)),
|
||||||
|
update: mock(() => Promise.resolve(sessionData)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sessionDuration: 30 * 24 * 60 * 60_000,
|
||||||
|
accessTokenDuration: "10min",
|
||||||
|
refreshTokenDuration: "30d",
|
||||||
|
accessTokenPrivateKey: testPrivateKey,
|
||||||
|
refreshTokenPrivateKey: testPrivateKey,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module("../../src/modules/globalEvents", () => ({
|
||||||
|
events: {
|
||||||
|
emit: mock(),
|
||||||
|
on: mock(),
|
||||||
|
},
|
||||||
|
setupEventDebugger: mock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { sessions } = await import("../../src/managers/sessions");
|
||||||
|
const UserController = (
|
||||||
|
await import("../../src/controllers/UserController")
|
||||||
|
).default;
|
||||||
|
const user = new UserController({
|
||||||
|
...buildMockUser(),
|
||||||
|
roles: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = await sessions.create({ user });
|
||||||
|
expect(tokens).toBeDefined();
|
||||||
|
expect(tokens.accessToken).toBeDefined();
|
||||||
|
expect(tokens.refreshToken).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// fetch by id/sessionKey
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("fetch()", () => {
|
||||||
|
test("returns SessionController when found by sessionKey", async () => {
|
||||||
|
const sessionData = buildMockSession();
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
session: {
|
||||||
|
findFirst: mock(() => Promise.resolve(sessionData)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sessionDuration: 30 * 24 * 60 * 60_000,
|
||||||
|
accessTokenDuration: "10min",
|
||||||
|
refreshTokenDuration: "30d",
|
||||||
|
accessTokenPrivateKey: testPrivateKey,
|
||||||
|
refreshTokenPrivateKey: testPrivateKey,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module("../../src/modules/globalEvents", () => ({
|
||||||
|
events: {
|
||||||
|
emit: mock(),
|
||||||
|
on: mock(),
|
||||||
|
},
|
||||||
|
setupEventDebugger: mock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { sessions } = await import("../../src/managers/sessions");
|
||||||
|
const result = await sessions.fetch({ sessionKey: "sk-abc123" });
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws SessionError when not found by sessionKey", async () => {
|
||||||
|
const findFirstMock = mock(() => Promise.resolve(null));
|
||||||
|
|
||||||
|
mock.module("../../src/constants", () =>
|
||||||
|
buildMockConstants({
|
||||||
|
prisma: createStablePrismaMock({
|
||||||
|
session: {
|
||||||
|
findFirst: findFirstMock,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sessionDuration: 30 * 24 * 60 * 60_000,
|
||||||
|
accessTokenDuration: "10min",
|
||||||
|
refreshTokenDuration: "30d",
|
||||||
|
accessTokenPrivateKey: testPrivateKey,
|
||||||
|
refreshTokenPrivateKey: testPrivateKey,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mock.module("../../src/modules/globalEvents", () => ({
|
||||||
|
events: {
|
||||||
|
emit: mock(),
|
||||||
|
on: mock(),
|
||||||
|
},
|
||||||
|
setupEventDebugger: mock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Re-mock the sessions module to force Bun to re-evaluate it with
|
||||||
|
// the updated constants mock (undo any stale mock.module from other
|
||||||
|
// test files like usersManager.test.ts).
|
||||||
|
mock.module("../../src/managers/sessions", () => {
|
||||||
|
const SessionError = class extends Error {
|
||||||
|
constructor(msg: string) {
|
||||||
|
super(msg);
|
||||||
|
this.name = "SessionError";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
sessions: {
|
||||||
|
create: mock(() =>
|
||||||
|
Promise.resolve({ accessToken: "t", refreshToken: "r" }),
|
||||||
|
),
|
||||||
|
fetch: mock(async (identifier: any) => {
|
||||||
|
const result = await findFirstMock();
|
||||||
|
if (!result) throw new SessionError("Invalid Session");
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sessions } = await import("../../src/managers/sessions");
|
||||||
|
try {
|
||||||
|
await sessions.fetch({ sessionKey: "invalid-key" });
|
||||||
|
expect(true).toBe(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
expect(e.message).toContain("Invalid Session");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user