Compare commits

...

17 Commits

Author SHA1 Message Date
HoloPanio 1dc3c7ce07 test: align review approval status with ReadyToSend 2026-03-15 23:43:16 -05:00
HoloPanio e764932c39 feat: expand sales opportunity workflow and metrics APIs 2026-03-15 23:38:56 -05:00
HoloPanio 33b34d08a7 Add migration for CwMember table
Creates the CwMember table migration that was missing from the migration history
(previously applied locally via db push but never migrated for production).
2026-03-09 17:59:17 -05:00
HoloPanio 5afda8cb34 Add taxableFlag to product updates, QUO-Narrative quote fallback, and orphan reconciliation
- Add taxableFlag boolean field to product update schema and forecast patch
- Fall back to QUO-Narrative product customerDescription for quote narrative
- Reconcile orphaned local opportunity records not found in CW during refresh
- Invalidate caches for removed orphaned opportunities
- Add reconciled event and orphanedCount to refresh events
- Update API_ROUTES.md with taxableFlag field documentation
2026-03-09 17:48:47 -05:00
HoloPanio ee3e0a7377 bypass checkColdStatus — always returns not-cold until feature ready 2026-03-09 03:33:59 -05:00
HoloPanio e294791858 fix: provide real checkColdStatus in wfOpportunity mock, remove stray closing brace 2026-03-09 03:29:00 -05:00
HoloPanio 97ac4a2173 fix: eliminate cross-file mock.module pollution — complete exports for all mocked modules 2026-03-09 03:26:22 -05:00
HoloPanio ad7507d133 fix: use real cache key prefixes in mock and dynamic imports for CI compatibility 2026-03-09 03:08:15 -05:00
HoloPanio 15ef24eb3e fix: resolve CI test failures — explicit cache mock exports, hoisted service mocks, pinned Bun 1.3.6 2026-03-09 03:03:06 -05:00
HoloPanio f53b390e18 feat: add opportunity workflows, delete routes, company sites, algorithms, and expanded test coverage 2026-03-09 02:56:08 -05:00
HoloPanio c0a4d4f919 feat: add CW members, opportunity create/update, and integrator interceptor 2026-03-07 18:15:17 -06:00
HoloPanio 0ce1eda606 fix: add missing GeneratedQuotes columns migration 2026-03-07 00:14:26 -06:00
HoloPanio 6c310ed753 fix: add missing probability column migration for Opportunity 2026-03-07 00:07:10 -06:00
HoloPanio 1907bb433b feat: restructure sales, add PDF quote generation and WebSocket support 2026-03-06 23:25:37 -06:00
HoloPanio 4efca6cc53 Add sales item labor/product route updates and permission docs 2026-03-04 18:43:54 -06:00
HoloPanio d5c22c8eff Add special-order product flow for sales opportunities 2026-03-04 00:11:40 -06:00
HoloPanio a048e1e824 feat: add CW callback route and optimize cache refresh workflows 2026-03-03 19:46:48 -06:00
153 changed files with 27354 additions and 497 deletions
+2
View File
@@ -14,6 +14,8 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.6"
- name: Install dependencies
run: bun install --frozen-lockfile
+2
View File
@@ -14,6 +14,8 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.6"
- name: Install dependencies
run: bun install --frozen-lockfile
+1696 -19
View File
File diff suppressed because it is too large Load Diff
+104 -17
View File
@@ -8,10 +8,12 @@ This document describes the caching layer used in the Optima API, covering the R
The API caches expensive ConnectWise (CW) API responses in **Redis** to reduce latency and avoid CW rate limits. The primary cache layer is the **opportunity cache** (`src/modules/cache/opportunityCache.ts`), which proactively warms data for all non-closed opportunities on a background interval.
The API also maintains a Redis-backed **sales member metrics cache** (`src/modules/cache/salesOpportunityMetricsCache.ts`) refreshed every 5 minutes. It precomputes per-member dashboard/reporting figures (pipeline revenue, won/lost counts, win rate, avg days to close, and related metrics) for fast reads from `/v1/sales/opportunities/metrics`.
### Key design principles
- **Adaptive TTLs** — cache durations are computed dynamically based on how "hot" an opportunity is (recently updated = shorter TTL = fresher data).
- **Background refresh** — a 30-second interval scans all open opportunities and re-fetches only expired cache keys.
- **Background refresh** — a 20-minute interval scans all open opportunities and re-fetches only expired cache keys.
- **Bounded concurrency** — CW API calls are throttled via thunk-based batching to prevent overwhelming the upstream API.
- **Graceful degradation** — transient CW errors (timeouts, network failures) are caught, logged, and retried on the next cycle rather than crashing the process.
- **Priority ordering** — most recently updated opportunities are refreshed first so active deals get fresh data before stale ones.
@@ -23,7 +25,7 @@ The API caches expensive ConnectWise (CW) API responses in **Redis** to reduce l
Each non-closed opportunity can have up to 7 cached payloads in Redis:
| Cache Key Pattern | Data | Source |
|---|---|---|
| ----------------------------------- | ------------------------------------ | --------------------------------------------------------------------- |
| `opp:cw-data:{cwOpportunityId}` | Raw CW opportunity response | `GET /sales/opportunities/:id` |
| `opp:activities:{cwOpportunityId}` | CW activities array | `GET /sales/activities?conditions=opportunity/id=:id` |
| `opp:notes:{cwOpportunityId}` | CW notes array | `GET /sales/opportunities/:id/notes` |
@@ -32,6 +34,20 @@ Each non-closed opportunity can have up to 7 cached payloads in Redis:
| `opp:company-cw:{cw_CompanyId}` | Hydrated company + contacts blob | `GET /company/companies/:id` + contacts endpoints |
| `opp:site:{cwCompanyId}:{cwSiteId}` | Company site data | `GET /company/companies/:id/sites/:siteId` |
Inventory-adjustment-driven catalog sync adds a targeted product cache:
| Cache Key Pattern | Data | Source |
| ------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `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
@@ -50,7 +66,7 @@ Three algorithms compute cache TTLs. All share the same input signals:
Used for: opportunity CW data, activities, company CW data.
| # | Condition | TTL | Human |
|---|---|---|---|
| --- | ------------------------------------------------------- | ---------- | ------------ |
| 1a | Closed > 30 days ago | `null` | Do not cache |
| 1b | Closed within 30 days | 900,000 ms | 15 minutes |
| 2 | `expectedCloseDate` or `lastUpdated` within **5 days** | 30,000 ms | 30 seconds |
@@ -66,7 +82,7 @@ Rules are evaluated top-to-bottom; first match wins.
Used for: notes, contacts.
| # | Condition | TTL | Human |
|---|---|---|---|
| --- | --------------------- | ---------- | ------------ |
| 1a | Closed > 30 days ago | `null` | Do not cache |
| 1b | Closed within 30 days | 300,000 ms | 5 minutes |
| 2 | Within **5 days** | 60,000 ms | 60 seconds |
@@ -80,17 +96,17 @@ Used for: notes, contacts.
Used for: forecast + procurement products.
| # | Condition | TTL | Human |
|---|---|---|---|
| --- | ------------------------------------------- | ------------ | ---------- |
| 1 | Status is Won/Lost/Pending Won/Pending Lost | `null` | No cache |
| 2 | Main cache TTL is `null` | `null` | No cache |
| 3 | `lastUpdated` within **3 days** | 15,000 ms | 15 seconds |
| 4 | Everything else | 1,800,000 ms | 30 minutes |
| 4 | Everything else | 1,200,000 ms | 20 minutes |
Products on terminal-status opportunities are never proactively cached. Non-hot products use a **lazy on-demand** cache — they're fetched when requested and cached for 30 minutes.
Products on terminal-status opportunities are never proactively cached. Non-hot products use a **lazy on-demand** cache — they're fetched when requested and cached for 20 minutes.
### Site TTL
Sites use a fixed TTL of **30 minutes** (1,800,000 ms). Site/address data rarely changes. Sites are **not** proactively warmed by the background refresh — they are populated lazily on the first detail-view request.
Sites use a fixed TTL of **20 minutes** (1,200,000 ms). Site/address data rarely changes. Sites are **not** proactively warmed by the background refresh — they are populated lazily on the first detail-view request.
---
@@ -98,7 +114,7 @@ Sites use a fixed TTL of **30 minutes** (1,800,000 ms). Site/address data rarely
**Function:** `refreshOpportunityCache()` in `src/modules/cache/opportunityCache.ts`
**Interval:** Every 30 seconds, triggered from `src/index.ts`.
**Interval:** Every 20 minutes, triggered from `src/index.ts`.
### Refresh cycle
@@ -108,6 +124,50 @@ Sites use a fixed TTL of **30 minutes** (1,800,000 ms). Site/address data rarely
4. **Execute with bounded concurrency** — process thunks in batches of `CONCURRENCY` (currently **6**), with a `BATCH_DELAY_MS` (currently **250ms**) pause between batches. Each thunk is only invoked inside the batch loop.
5. **Emit events**`cache:opportunities:refresh:started` and `cache:opportunities:refresh:completed` events are emitted for the event debugger.
### Inventory-adjustment listener cycle
**Function:** `listenInventoryAdjustments()` in `src/modules/cw-utils/procurement/listenInventoryAdjustments.ts`
**Interval:** Every 60 seconds, triggered from `src/index.ts`.
1. Fetch `GET /procurement/adjustments?pageSize=1000`.
2. Build a normalized snapshot of tracked inventory rows (`cwCatalogId`, `onHand`, `inventory`) per adjustment.
3. Compare to previous snapshot; extract only changed product IDs.
4. For each changed product ID, fetch fresh CW catalog item + current on-hand.
5. Upsert `CatalogItem` in Postgres and write Redis key `catalog:item:cw:{cwId}` with a 20-minute TTL.
Guardrails to prevent request storms:
- Diffing is computed at **product state** level (grouped by `cwCatalogId`), not raw adjustment-row churn.
- Per-cycle syncs are capped (`CW_ADJUSTMENT_SYNC_MAX_PER_CYCLE`, default `50`).
- Product resync cooldown is enforced (`CW_ADJUSTMENT_SYNC_COOLDOWN_MS`, default `600000` ms / 10 min).
This avoids full-catalog sweeps for small inventory movements and updates only the products implicated by adjustments.
### Full procurement catalog refresh
**Function:** `refreshCatalog()` in `src/modules/cw-utils/procurement/refreshCatalog.ts`
**Interval:** Every 30 minutes, triggered from `src/index.ts`.
The full catalog cache/DB sync uses the same slow-parallel thunk strategy as opportunity cache refreshes:
- Build arrays of thunk tasks (`() => Promise<void>`) for CW item fetches, inventory fetches, and DB upserts.
- Execute with bounded concurrency (`CONCURRENCY=6`).
- Pause between batches (`BATCH_DELAY_MS=250`) to avoid CW burst pressure.
- Log task failures and retry naturally on the next cycle.
This keeps full-catalog refresh conservative while inventory-adjustment listener handles near-real-time targeted updates.
### Full inventory sweep fallback
`refreshInventory()` remains as a safety net but is intentionally infrequent:
- Runs every **6 hours** from `src/index.ts` (no startup-time full sweep).
- Uses the same slow-parallel pattern (`CONCURRENCY=6`, `BATCH_DELAY_MS=250`) to avoid burst traffic.
Most on-hand freshness now comes from the 60-second adjustment listener plus 30-minute full catalog refresh.
### Concurrency control
The thunk pattern is critical. Previously, tasks were pushed as already-executing promises (`refreshTasks.push(fetchAndCache(...))`), which meant all HTTP requests fired simultaneously regardless of the batching loop. The fix was changing the array type from `Promise<void>[]` to `(() => Promise<void>)[]` so requests only start when explicitly invoked: `batch.map((fn) => fn())`.
@@ -115,13 +175,38 @@ The thunk pattern is critical. Previously, tasks were pushed as already-executin
### Current tuning
| Parameter | Value | Effect |
|---|---|---|
| ---------------- | ---------- | ------------------------------------------ |
| `CONCURRENCY` | 6 | Max simultaneous CW API requests per batch |
| `BATCH_DELAY_MS` | 250 | Milliseconds between batches |
| Refresh interval | 30 seconds | How often the full sweep runs |
| Refresh interval | 20 minutes | How often the full sweep runs |
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`)
@@ -143,7 +228,7 @@ Wraps CW API calls with exponential backoff retry on transient errors.
### Default configuration
| Parameter | Default | Description |
|---|---|---|
| ------------- | ------- | ----------------------------------------------------------- |
| `maxAttempts` | 3 | Total attempts including the first |
| `baseDelayMs` | 1,000 | Delay before first retry (doubles each retry: 1s → 2s → 4s) |
| `label` | — | Optional tag for log messages |
@@ -182,7 +267,7 @@ LOG_CW_API=1 bun run dev
### Log entry fields
| Field | Type | Description |
|---|---|---|
| ------------ | ----------------- | ----------------------------------- |
| `timestamp` | string (ISO-8601) | When the request completed |
| `method` | string | HTTP method |
| `url` | string | Request URL (relative or absolute) |
@@ -230,7 +315,7 @@ rm -rf cw-api-logs/
Mutation endpoints invalidate the relevant cache keys so the next read fetches fresh data from CW:
| Mutation | Cache invalidated |
|---|---|
| ------------------------------ | ---------------------------------------------------------------- |
| Create/update/delete note | `opp:notes:{cwOpportunityId}` via `invalidateNotesCache()` |
| Create/update/delete contact | `opp:contacts:{cwOpportunityId}` via `invalidateContactsCache()` |
| Add/update/resequence products | `opp:products:{cwOpportunityId}` via `invalidateProductsCache()` |
@@ -243,7 +328,7 @@ Mutation endpoints invalidate the relevant cache keys so the next read fetches f
The shared Axios instance (`connectWiseApi`) is configured in `src/constants.ts`:
| Setting | Value | Purpose |
|---|---|---|
| --------- | ---------------------------------------------------- | ------------------------------ |
| `baseURL` | `https://ttscw.totaltech.net/v4_6_release/apis/3.0/` | CW API base |
| `timeout` | 30,000 ms (30s) | Per-request timeout |
| Logger | `attachCwApiLogger()` | Writes to `cw-api-calls.jsonl` |
@@ -255,7 +340,7 @@ The shared Axios instance (`connectWiseApi`) is configured in `src/constants.ts`
```
src/index.ts
├─ setInterval(refreshOpportunityCache, 30s)
├─ setInterval(refreshOpportunityCache, 20m)
└─► src/modules/cache/opportunityCache.ts
@@ -284,7 +369,7 @@ src/index.ts
## File reference
| File | Purpose |
|---|---|
| ---------------------------------------------------------------- | ------------------------------------------------------------- |
| `src/modules/cache/opportunityCache.ts` | Cache read/write helpers, background refresh logic |
| `src/modules/algorithms/computeCacheTTL.ts` | Primary adaptive TTL algorithm |
| `src/modules/algorithms/computeSubResourceCacheTTL.ts` | Sub-resource (notes, contacts) TTL algorithm |
@@ -292,6 +377,8 @@ src/index.ts
| `src/modules/cw-utils/withCwRetry.ts` | Retry wrapper with exponential backoff |
| `src/modules/cw-utils/cwApiLogger.ts` | Axios interceptor for JSONL call logging |
| `src/modules/cw-utils/fetchCompany.ts` | Company fetch with retry |
| `src/modules/cw-utils/procurement/listenInventoryAdjustments.ts` | Adjustment listener for targeted catalog-item cache + DB sync |
| `src/modules/cache/salesOpportunityMetricsCache.ts` | 5-minute active-member opportunity metrics cache |
| `src/constants.ts` | CW Axios instance config (timeout, logger) |
| `src/index.ts` | Refresh interval registration |
| `debug-scripts/analyze-cw-calls.py` | CW API call analysis script |
+52 -10
View File
@@ -24,12 +24,13 @@ The permission validator supports special tokens for flexible permission managem
### Company Permissions
| Permission Node | Description | Used In |
| ------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| ------------------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `company.fetch` | Fetch a single company | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
| `company.fetch.address` | View company address information (requires `company.fetch` as well) | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
| `company.fetch.contacts` | View all company contacts (requires `company.fetch` as well) | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
| `company.fetch.many` | Fetch multiple companies | [src/api/companies/fetchAll.ts](src/api/companies/fetchAll.ts) |
| `company.fetch.configurations` | Fetch company configurations (requires `company.fetch` as well) | [src/api/companies/[id]/configurations.ts](src/api/companies/[id]/configurations.ts) |
| `company.fetch.sites` | Fetch company sites from ConnectWise (requires `company.fetch` as well) | [src/api/companies/[id]/sites.ts](src/api/companies/[id]/sites.ts) |
### Credential Permissions
@@ -124,20 +125,60 @@ Admin-specific UI permissions that control visibility and data loading for admin
| `procurement.catalog.inventory.refresh` | Refresh on-hand inventory for a catalog item from ConnectWise | [src/api/procurement/[id]/refreshInventory.ts](src/api/procurement/[id]/refreshInventory.ts) | `procurement.catalog.fetch` |
| `procurement.catalog.link` | Link or unlink catalog items to each other | [src/api/procurement/[id]/link.ts](src/api/procurement/[id]/link.ts), [src/api/procurement/[id]/unlink.ts](src/api/procurement/[id]/unlink.ts) | `procurement.catalog.fetch` |
### ConnectWise Routes
`GET /v1/cw/members` requires only authentication (any logged-in user) and does **not** require a specific permission node.
`POST /v1/cw/callback/:secret/:resource` is intentionally unauthenticated for inbound ConnectWise callbacks and does **not** require a permission node.
| Permission Node | Description | Used In | Dependencies |
| --------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------ |
| _None_ | Fetch CW members (auth only) | [src/api/cw/fetchMembers.ts](src/api/cw/fetchMembers.ts) | N/A |
| _None_ | Inbound callback route; secured operationally (network controls / source trust) | [src/api/cw/callback.ts](src/api/cw/callback.ts) | N/A |
### Sales Permissions
Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (products, notes, contacts) are fetched live from CW.
**WebSocket note:** The `/secure` socket event chain `opp:live_quote_preview` and `opp:live_quote_preview:<id>:data` is gated by `sales.opportunity.fetch`.
| Permission Node | Description | Used In | Dependencies |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/products.ts](src/api/sales/[id]/products.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/fetchNote.ts](src/api/sales/[id]/fetchNote.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | |
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.ts) | |
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/[id]/createNote.ts](src/api/sales/[id]/createNote.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/[id]/updateNote.ts](src/api/sales/[id]/updateNote.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<field>` permissions. | [src/api/sales/[id]/addProduct.ts](src/api/sales/[id]/addProduct.ts) | `sales.opportunity.fetch` |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------ |
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/opportunities/[id]/fetch.ts](src/api/sales/opportunities/[id]/fetch.ts), [src/api/sales/opportunities/[id]/products/fetchAll.ts](src/api/sales/opportunities/[id]/products/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetchAll.ts](src/api/sales/opportunities/[id]/notes/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetch.ts](src/api/sales/opportunities/[id]/notes/fetch.ts), [src/api/sales/opportunities/[id]/contacts/fetchAll.ts](src/api/sales/opportunities/[id]/contacts/fetchAll.ts), [src/api/sockets/events/liveQuotePreview.ts](src/api/sockets/events/liveQuotePreview.ts) | |
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/opportunities/fetchAll.ts](src/api/sales/opportunities/fetchAll.ts), [src/api/sales/opportunities/count.ts](src/api/sales/opportunities/count.ts), [src/api/sales/opportunities/fetchTypes.ts](src/api/sales/opportunities/fetchTypes.ts) | |
| `sales.opportunity.fetch.@me` | View the personal sales dashboard showing opportunities assigned to the current user | UI-only (client-side gate) | |
| `sales.opportunity.fetch.all` | View all opportunities across all users (All Opportunities tab and View All button in the sales dashboard) | UI-only (client-side gate) | `sales.opportunity.fetch.many` |
| `sales.opportunity.metrics.all` | Allow `scope=all` on sales opportunity metrics endpoint to read cached metrics for all active members | [src/api/sales/opportunities/metrics.ts](src/api/sales/opportunities/metrics.ts) | `sales.opportunity.fetch.many` |
| `sales.opportunity.metrics.identifier.override` | Allow `identifier=<cwIdentifier>` override on sales opportunity metrics endpoint for querying another member | [src/api/sales/opportunities/metrics.ts](src/api/sales/opportunities/metrics.ts) | `sales.opportunity.fetch.many` |
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/opportunities/[id]/refresh.ts](src/api/sales/opportunities/[id]/refresh.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.update` | Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise | [src/api/sales/opportunities/[id]/update.ts](src/api/sales/opportunities/[id]/update.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.create` | Create a new opportunity in ConnectWise | [src/api/sales/opportunities/create.ts](src/api/sales/opportunities/create.ts) | |
| `sales.opportunity.delete` | Delete an opportunity from ConnectWise and the local database | [src/api/sales/opportunities/[id]/delete.ts](src/api/sales/opportunities/[id]/delete.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/opportunities/[id]/notes/create.ts](src/api/sales/opportunities/[id]/notes/create.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/opportunities/[id]/notes/update.ts](src/api/sales/opportunities/[id]/notes/update.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/opportunities/[id]/notes/delete.ts](src/api/sales/opportunities/[id]/notes/delete.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/opportunities/[id]/products/resequence.ts](src/api/sales/opportunities/[id]/products/resequence.ts), [src/api/sales/opportunities/[id]/products/update.ts](src/api/sales/opportunities/[id]/products/update.ts), [src/api/sales/opportunities/[id]/products/cancel.ts](src/api/sales/opportunities/[id]/products/cancel.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.delete` | Delete a product (forecast item) from an opportunity | [src/api/sales/opportunities/[id]/products/delete.ts](src/api/sales/opportunities/[id]/products/delete.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<field>` permissions. | [src/api/sales/opportunities/[id]/products/add.ts](src/api/sales/opportunities/[id]/products/add.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add.specialOrder` | Add one or more "SPECIAL ORDER" products via the dedicated special-order route. | [src/api/sales/opportunities/[id]/products/addSpecialOrder.ts](src/api/sales/opportunities/[id]/products/addSpecialOrder.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add.labor` | Add labor products via the dedicated labor route with Field/Tech catalog selection and labor pricing inputs. | [src/api/sales/opportunities/[id]/products/addLabor.ts](src/api/sales/opportunities/[id]/products/addLabor.ts), [src/api/sales/opportunities/[id]/products/laborOptions.ts](src/api/sales/opportunities/[id]/products/laborOptions.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.fetch` | Fetch all committed quotes for an opportunity. | [src/api/sales/opportunities/[id]/quotes/fetchAll.ts](src/api/sales/opportunities/[id]/quotes/fetchAll.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.commit` | Generate and store a finalized quote PDF for an opportunity with regeneration metadata and creator attribution. | [src/api/sales/opportunities/[id]/quotes/commit.ts](src/api/sales/opportunities/[id]/quotes/commit.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.preview` | Generate a preview-stamped quote PDF for an opportunity without storing it. | [src/api/sales/opportunities/[id]/quotes/preview.ts](src/api/sales/opportunities/[id]/quotes/preview.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.download` | Download a committed quote PDF. Each download is recorded with timestamp and user info. | [src/api/sales/opportunities/[id]/quotes/download.ts](src/api/sales/opportunities/[id]/quotes/download.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.fetch_downloads` | Fetch download/print history for all quotes on an opportunity. Admin-level permission. | [src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts](src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.view_margin` | View margin and markup data on opportunity products. Controls visibility of margin %, markup %, and related progress bars in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` |
| `sales.opportunity.view_cost` | View cost data on opportunity products. Controls visibility of unit cost, total cost, and recurring cost in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` |
| `sales.opportunity.view_profit` | View profit data on opportunity products. Controls visibility of profit values in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` |
| `sales.opportunity.workflow` | Execute opportunity workflow actions (status transitions, review decisions, quote sending, etc.). Base gate for the workflow dispatch endpoint. | [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.finalize` | Finalize an opportunity as Won or Lost. Without this permission, win/lose actions route to PendingWon/PendingLost instead. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts), [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.workflow` |
| `sales.opportunity.cancel` | Cancel an opportunity. Required to transition any eligible opportunity to the Canceled status. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts), [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.workflow` |
| `sales.opportunity.review` | Submit an opportunity for internal review. Required to transition an opportunity into the InternalReview status. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
| `sales.opportunity.send` | Send a quote to the customer. Required to transition an opportunity to QuoteSent (and compound transitions like immediate won/lost/confirmed). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
| `sales.opportunity.reopen` | Re-open a cancelled opportunity. Required to transition an opportunity from Canceled back to Active. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
| `sales.opportunity.win` | Mark an opportunity as won (or pending won). Gates the win button in the UI. Required for finalize(won), sendQuote(won), and transitionToPending(won). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
| `sales.opportunity.lose` | Mark an opportunity as lost (or pending lost). Gates the lose button in the UI. Required for finalize(lost), sendQuote(lost), and transitionToPending(lost). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
| `sales.isRepresentative` | Designates the user as a sales representative; used for reporting and filtering purposes. | _(not yet used in routes)_ | |
<details>
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
@@ -339,6 +380,7 @@ All fetch and fetchAll routes gate response object keys using `processObjectValu
| `obj.opportunity.site` | View site |
| `obj.opportunity.customerPO` | View customer PO |
| `obj.opportunity.totalSalesTax` | View total sales tax |
| `obj.opportunity.probability` | View probability percentage |
| `obj.opportunity.location` | View location |
| `obj.opportunity.department` | View department |
| `obj.opportunity.expectedCloseDate` | View expected close date |
+58
View File
@@ -19,6 +19,8 @@
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"keypair": "^1.0.4",
"pdf-lib": "^1.17.1",
"pdfmake": "^0.3.5",
"prisma": "^7.3.0",
"socket.io": "^4.8.3",
"zod": "^4.3.6",
@@ -62,6 +64,10 @@
"@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="],
"@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="],
"@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="],
"@prisma/adapter-pg": ["@prisma/adapter-pg@7.3.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.3.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-iuYQMbIPO6i9O45Fv8TB7vWu00BXhCaNAShenqF7gLExGDbnGp5BfFB4yz1K59zQ59jF6tQ9YHrg0P6/J3OoLg=="],
"@prisma/client": ["@prisma/client@7.3.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.3.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-FXBIxirqQfdC6b6HnNgxGmU7ydCPEPk7maHMOduJJfnTP+MuOGa15X4omjR/zpPUUpm8ef/mEFQjJudOGkXFcQ=="],
@@ -96,6 +102,8 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
@@ -116,10 +124,14 @@
"axios": ["axios@1.13.3", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g=="],
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
"blakets": ["blakets@0.1.12", "", { "dependencies": { "@prokopschield/argv": "^0.1.0-2" }, "bin": { "blake": "lib/demo.js", "blake2b": "lib/cli.js", "blake2s": "lib/cli.js", "blakejs": "lib/demo.js", "blakets": "lib/demo.js" } }, "sha512-ReOnLTDRlbExlTXbJZoA2xkvhzauJ7ldpvhKnb1cUNw8gdAHWHWOWG8XMjwpxQmmEZCDAR7VZiM5BYTUSOLVrw=="],
"brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
@@ -134,6 +146,8 @@
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
@@ -148,6 +162,8 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"cuid": ["cuid@3.0.0", "", {}, "sha512-WZYYkHdIDnaxdeP8Misq3Lah5vFjJwGuItJuV+tvMafosMzw0nF297T7mrm8IOWiPJkV6gc7sa8pzx27+w25Zg=="],
@@ -164,6 +180,8 @@
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -190,8 +208,12 @@
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
@@ -236,6 +258,8 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jpeg-exif": ["jpeg-exif@1.1.4", "", {}, "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ=="],
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
@@ -246,6 +270,8 @@
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
@@ -292,10 +318,18 @@
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="],
"pdfkit": ["pdfkit@0.17.2", "", { "dependencies": { "crypto-js": "^4.2.0", "fontkit": "^2.0.4", "jpeg-exif": "^1.1.4", "linebreak": "^1.1.0", "png-js": "^1.0.0" } }, "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw=="],
"pdfmake": ["pdfmake@0.3.5", "", { "dependencies": { "linebreak": "^1.1.0", "pdfkit": "^0.17.2", "xmldoc": "^2.0.3" } }, "sha512-DR7jRrK4lk7UiRT6pi+NeWhW1ToTsL2Y8CH+bFKNYz3M7agIVgeCtwARveEORhCAqoG3AUDrN318xU/lkOr1Bg=="],
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"pg": ["pg@8.17.2", "", { "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw=="],
@@ -316,6 +350,8 @@
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
"png-js": ["png-js@1.0.0", "", {}, "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="],
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
"postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
@@ -350,12 +386,16 @@
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="],
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -382,12 +422,20 @@
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="],
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
@@ -398,6 +446,8 @@
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"xmldoc": ["xmldoc@2.0.3", "", { "dependencies": { "sax": "^1.4.3" } }, "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="],
@@ -414,10 +464,18 @@
"@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="],
"@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="],
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
}
}
+441
View File
@@ -0,0 +1,441 @@
#!/usr/bin/env python3
import argparse
import json
from collections import Counter, defaultdict
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def parse_iso(value: str | None) -> datetime | None:
if not value:
return None
normalized = value.replace("Z", "+00:00")
try:
return datetime.fromisoformat(normalized)
except ValueError:
return None
def first_non_empty(*values: Any) -> str:
for value in values:
if value is None:
continue
if isinstance(value, str) and value.strip() == "":
continue
return str(value)
return "unknown"
def top_lines(counter: Counter[str], limit: int) -> list[str]:
return [f"{k}: {v}" for k, v in counter.most_common(limit)]
def fmt_pct(part: int, total: int) -> str:
if total == 0:
return "0.0%"
return f"{(part / total) * 100:.1f}%"
def human_duration(start: datetime | None, end: datetime | None) -> str:
if start is None or end is None:
return "unknown"
delta = end - start
total_seconds = int(delta.total_seconds())
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{hours}h {minutes}m {seconds}s"
def truncate(value: str, max_len: int = 90) -> str:
if len(value) <= max_len:
return value
return value[: max_len - 1] + ""
def add_section(lines: list[str], title: str) -> None:
lines.append("")
lines.append(title)
lines.append("-" * len(title))
def supports_color(enabled: bool) -> bool:
return enabled
def paint(text: str, code: str, use_color: bool) -> str:
if not use_color:
return text
return f"\033[{code}m{text}\033[0m"
def good_bad_neutral(value: str, state: str, use_color: bool) -> str:
if state == "good":
return paint(value, "32", use_color)
if state == "bad":
return paint(value, "31", use_color)
return paint(value, "36", use_color)
def add_ranked_counter(
lines: list[str],
title: str,
counter: Counter[str],
top_n: int,
total: int,
truncate_labels: bool = False,
) -> None:
lines.append(f"{title}")
items = counter.most_common(top_n)
if not items:
lines.append(" (no data)")
return
for index, (key, count) in enumerate(items, start=1):
label = truncate(key) if truncate_labels else key
lines.append(f" {index:>2}. {label:<90} {count:>4} {fmt_pct(count, total):>6}")
def stream_row_summary(row: dict[str, Any], use_color: bool, max_path: int) -> str:
request = row.get("request") or {}
response = row.get("response") or {}
body_parsed = request.get("bodyParsed") or {}
entity_parsed = request.get("entityParsed") or {}
summary = request.get("summary") or {}
timestamp = parse_iso(row.get("timestamp"))
time_label = timestamp.astimezone(timezone.utc).strftime("%H:%M:%S") if timestamp else "--:--:--"
method = first_non_empty(request.get("method"))
path = first_non_empty(request.get("path"))
endpoint = path.split("?", 1)[0]
status_code = first_non_empty(response.get("status"))
event_type = first_non_empty(body_parsed.get("Type"), summary.get("type"))
action = first_non_empty(
body_parsed.get("Action"),
summary.get("action"),
request.get("query", {}).get("params", {}).get("action"),
)
item_id = first_non_empty(body_parsed.get("ID"), summary.get("id"), request.get("query", {}).get("inferredId"))
actor = first_non_empty(
request.get("query", {}).get("params", {}).get("memberId"),
summary.get("entityUpdatedBy"),
entity_parsed.get("UpdatedBy"),
)
entity_status = first_non_empty(entity_parsed.get("StatusName"), summary.get("entityStatus"))
endpoint_label = truncate(endpoint, max_path)
status_state = "good" if status_code.startswith("2") else "bad"
status_colored = good_bad_neutral(status_code, status_state, use_color)
event_colored = paint(f"{event_type}.{action}", "36", use_color)
endpoint_colored = paint(endpoint_label, "94", use_color)
return (
f"[{time_label}] {method:<4} {endpoint_colored:<20} "
f"{status_colored:>3} {event_colored:<22} "
f"id={item_id:<7} actor={truncate(actor, 16):<16} status={truncate(entity_status, 22)}"
)
def endpoint_stream_summary(log_path: Path, use_color: bool, max_path: int) -> str:
lines: list[str] = []
lines.append(paint("ENDPOINT STREAM (chronological)", "1;95", use_color))
lines.append(paint("────────────────────────────────────────────────────────────────────────────────────────────", "90", use_color))
count = 0
invalid = 0
with log_path.open("r", encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
invalid += 1
continue
lines.append(stream_row_summary(row, use_color=use_color, max_path=max_path))
count += 1
lines.append(paint("────────────────────────────────────────────────────────────────────────────────────────────", "90", use_color))
lines.append(
f"events={count} invalid={good_bad_neutral(str(invalid), 'good' if invalid == 0 else 'bad', use_color)}"
)
return "\n".join(lines)
@dataclass
class LogStats:
total_rows: int = 0
parsed_rows: int = 0
invalid_rows: int = 0
earliest: datetime | None = None
latest: datetime | None = None
methods: Counter[str] = None # type: ignore[assignment]
paths: Counter[str] = None # type: ignore[assignment]
endpoint_roots: Counter[str] = None # type: ignore[assignment]
response_statuses: Counter[str] = None # type: ignore[assignment]
event_types: Counter[str] = None # type: ignore[assignment]
actions: Counter[str] = None # type: ignore[assignment]
type_action_combo: Counter[str] = None # type: ignore[assignment]
company_ids: Counter[str] = None # type: ignore[assignment]
source_members: Counter[str] = None # type: ignore[assignment]
actor_members: Counter[str] = None # type: ignore[assignment]
entity_updated_by: Counter[str] = None # type: ignore[assignment]
requests_by_hour: Counter[str] = None # type: ignore[assignment]
requests_by_minute: Counter[str] = None # type: ignore[assignment]
endpoint_by_hour: dict[str, Counter[str]] = None # type: ignore[assignment]
def __post_init__(self) -> None:
self.methods = Counter()
self.paths = Counter()
self.endpoint_roots = Counter()
self.response_statuses = Counter()
self.event_types = Counter()
self.actions = Counter()
self.type_action_combo = Counter()
self.company_ids = Counter()
self.source_members = Counter()
self.actor_members = Counter()
self.entity_updated_by = Counter()
self.requests_by_hour = Counter()
self.requests_by_minute = Counter()
self.endpoint_by_hour = defaultdict(Counter)
def add_timestamp(self, timestamp: datetime | None) -> None:
if timestamp is None:
return
self.earliest = timestamp if self.earliest is None else min(self.earliest, timestamp)
self.latest = timestamp if self.latest is None else max(self.latest, timestamp)
hour_bucket = timestamp.astimezone(timezone.utc).strftime("%Y-%m-%d %H:00 UTC")
minute_bucket = timestamp.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
self.requests_by_hour[hour_bucket] += 1
self.requests_by_minute[minute_bucket] += 1
def summarize(self, top_n: int, busiest_n: int, use_color: bool) -> str:
duration_line = human_duration(self.earliest, self.latest)
time_range_line = "unknown"
if self.earliest and self.latest:
time_range_line = f"{self.earliest.isoformat()}{self.latest.isoformat()}"
total_requests = self.parsed_rows
success_count = self.response_statuses.get("200", 0)
success_pct = fmt_pct(success_count, sum(self.response_statuses.values()))
invalid_state = "good" if self.invalid_rows == 0 else "bad"
top_endpoints = self.endpoint_roots.most_common(2)
top_users = self.actor_members.most_common(3)
top_minutes = self.requests_by_minute.most_common(busiest_n)
lines: list[str] = []
lines.append(paint("WEBHOOK SNAPSHOT", "1;95", use_color))
lines.append(paint("────────────────────────────────────────────────────────", "90", use_color))
lines.append(
" "
+ paint("Rows", "1;97", use_color)
+ f": {self.total_rows:<4} "
+ paint("Parsed", "1;97", use_color)
+ f": {self.parsed_rows:<4} "
+ paint("Invalid", "1;97", use_color)
+ f": {good_bad_neutral(str(self.invalid_rows), invalid_state, use_color)}"
)
lines.append(
" "
+ paint("Window", "1;97", use_color)
+ f": {duration_line:<12} "
+ paint("Success", "1;97", use_color)
+ f": {good_bad_neutral(success_pct, 'good' if success_count else 'neutral', use_color)}"
)
lines.append(" " + paint("UTC Range", "1;97", use_color) + f": {time_range_line}")
lines.append("")
lines.append(paint("Top Endpoints", "1;94", use_color))
if top_endpoints:
for endpoint, count in top_endpoints:
lines.append(f"{endpoint:<14} {count:>4} ({fmt_pct(count, total_requests)})")
if not top_endpoints:
lines.append(" • (no data)")
lines.append("")
lines.append(paint("Most Active Users (query memberId)", "1;94", use_color))
if top_users:
for user, count in top_users:
lines.append(f"{user:<18} {count:>4} ({fmt_pct(count, total_requests)})")
if not top_users:
lines.append(" • (no data)")
lines.append("")
lines.append(paint("Busiest Minutes", "1;94", use_color))
if top_minutes:
for minute, count in top_minutes:
lines.append(f"{minute:<22} {count:>3}")
if not top_minutes:
lines.append(" • (no data)")
lines.append("")
lines.append(paint("Request Mix", "1;94", use_color))
method_line = ", ".join([f"{k}:{v}" for k, v in self.methods.most_common(3)]) or "(no data)"
event_line = ", ".join([f"{k}:{v}" for k, v in self.event_types.most_common(3)]) or "(no data)"
action_line = ", ".join([f"{k}:{v}" for k, v in self.actions.most_common(3)]) or "(no data)"
lines.append(f" • Methods : {method_line}")
lines.append(f" • Types : {event_line}")
lines.append(f" • Actions : {action_line}")
lines.append("")
lines.append(paint("Status Codes", "1;94", use_color))
if self.response_statuses:
status_total = sum(self.response_statuses.values())
for status, count in self.response_statuses.most_common(5):
state = "good" if status.startswith("2") else "bad"
status_label = good_bad_neutral(status, state, use_color)
lines.append(f"{status_label}: {count} ({fmt_pct(count, status_total)})")
if not self.response_statuses:
lines.append(" • (no data)")
return "\n".join(lines)
def update_stats(stats: LogStats, row: dict[str, Any]) -> None:
timestamp = parse_iso(row.get("timestamp"))
stats.add_timestamp(timestamp)
request = row.get("request") or {}
response = row.get("response") or {}
body_parsed = request.get("bodyParsed") or {}
entity_parsed = request.get("entityParsed") or {}
method = first_non_empty(request.get("method"))
path = first_non_empty(request.get("path"))
endpoint_root = path.split("?", 1)[0]
status = first_non_empty(response.get("status"))
event_type = first_non_empty(
body_parsed.get("Type"),
request.get("summary", {}).get("type"),
)
action = first_non_empty(
body_parsed.get("Action"),
request.get("summary", {}).get("action"),
request.get("query", {}).get("params", {}).get("action"),
)
combo = f"{event_type}:{action}"
source_member = first_non_empty(
body_parsed.get("MemberId"),
request.get("summary", {}).get("memberId"),
)
actor_member = first_non_empty(
request.get("query", {}).get("params", {}).get("memberId"),
request.get("summary", {}).get("entityUpdatedBy"),
)
updated_by = first_non_empty(
entity_parsed.get("UpdatedBy"),
request.get("summary", {}).get("entityUpdatedBy"),
)
company_id = first_non_empty(body_parsed.get("CompanyId"), request.get("headers", {}).get("companyname"))
stats.methods[method] += 1
stats.paths[path] += 1
stats.endpoint_roots[endpoint_root] += 1
stats.response_statuses[status] += 1
stats.event_types[event_type] += 1
stats.actions[action] += 1
stats.type_action_combo[combo] += 1
stats.company_ids[company_id] += 1
stats.source_members[source_member] += 1
stats.actor_members[actor_member] += 1
stats.entity_updated_by[updated_by] += 1
if timestamp:
bucket = timestamp.astimezone(timezone.utc).strftime("%Y-%m-%d %H:00 UTC")
stats.endpoint_by_hour[endpoint_root][bucket] += 1
def analyze_file(log_path: Path) -> LogStats:
stats = LogStats()
with log_path.open("r", encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line:
continue
stats.total_rows += 1
try:
row = json.loads(line)
except json.JSONDecodeError:
stats.invalid_rows += 1
continue
stats.parsed_rows += 1
update_stats(stats, row)
return stats
def main() -> None:
parser = argparse.ArgumentParser(
description="Analyze webhook JSONL logs by users, time, and request types."
)
parser.add_argument("log_file", help="Path to JSONL log file")
parser.add_argument("--top", type=int, default=10, help="Top N entries per section (default: 10)")
parser.add_argument(
"--busiest-minutes",
type=int,
default=5,
help="How many top minute buckets to show (default: 5)",
)
parser.add_argument(
"--no-color",
action="store_true",
help="Disable ANSI colors",
)
parser.add_argument(
"--endpoint-stream",
action="store_true",
help="Show chronological one-line summary per webhook, similar to live test webserver logs",
)
parser.add_argument(
"--max-path",
type=int,
default=18,
help="Max endpoint width in stream mode before truncation (default: 18)",
)
args = parser.parse_args()
log_path = Path(args.log_file)
if not log_path.exists() or not log_path.is_file():
raise SystemExit(f"Log file not found: {log_path}")
use_color = supports_color(not args.no_color)
if args.endpoint_stream:
print(endpoint_stream_summary(log_path, use_color=use_color, max_path=max(args.max_path, 10)))
return
stats = analyze_file(log_path)
print(
stats.summarize(
top_n=max(args.top, 1),
busiest_n=max(args.busiest_minutes, 1),
use_color=use_color,
)
)
if __name__ == "__main__":
main()
+900
View File
@@ -0,0 +1,900 @@
#!/usr/bin/env python3
"""
Generate a print-friendly PDF report from the latest test-webserver log file.
Usage:
python3 generate_log_report.py [optional_log_file_path]
If no path is given, the script finds the latest test-webserver-*.jsonl
file in ../cw-api-logs/.
"""
import json
import os
import sys
import glob
from datetime import datetime, timezone
from collections import Counter, defaultdict
from reportlab.lib import colors
from reportlab.lib.pagesizes import LETTER
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.platypus import (
SimpleDocTemplate,
Paragraph,
Spacer,
Table,
TableStyle,
PageBreak,
HRFlowable,
KeepTogether,
)
from reportlab.graphics.shapes import Drawing, String
from reportlab.graphics.charts.piecharts import Pie
from reportlab.graphics.charts.barcharts import VerticalBarChart
# ─── Print-friendly color palette ─────────────────────────────────────────────
# Minimal ink: white backgrounds, thin borders, dark text, subtle accents
HEADER_BG = colors.HexColor("#2c3e50") # Dark header (used sparingly)
ACCENT = colors.HexColor("#2980b9") # Muted blue
ACCENT_2 = colors.HexColor("#27ae60") # Muted green
ACCENT_3 = colors.HexColor("#8e44ad") # Muted purple
WHITE = colors.white
GRAY_50 = colors.HexColor("#fafafa")
GRAY_100 = colors.HexColor("#f5f5f5")
GRAY_200 = colors.HexColor("#e0e0e0")
GRAY_400 = colors.HexColor("#bdbdbd")
GRAY_600 = colors.HexColor("#757575")
GRAY_800 = colors.HexColor("#424242")
GRAY_900 = colors.HexColor("#212121")
# Pie/chart fills — muted, distinguishable in B&W too
PIE_COLORS = [
colors.HexColor("#5b9bd5"), # steel blue
colors.HexColor("#ed7d31"), # soft orange
colors.HexColor("#a5a5a5"), # gray
colors.HexColor("#ffc000"), # amber
colors.HexColor("#70ad47"), # olive green
colors.HexColor("#4472c4"), # darker blue
colors.HexColor("#c55a11"), # brown
colors.HexColor("#7030a0"), # purple
]
# ─── Helpers ──────────────────────────────────────────────────────────────────
def find_latest_log(base_dir):
pattern = os.path.join(base_dir, "test-webserver-*.jsonl")
files = sorted(glob.glob(pattern))
if not files:
raise FileNotFoundError(f"No test-webserver log files found in {base_dir}")
return files[-1]
def parse_log(path):
entries = []
with open(path, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
return entries
def fmt_ts(iso_str):
try:
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
except Exception:
return str(iso_str)
def duration_str(seconds):
if seconds < 60:
return f"{seconds:.1f}s"
minutes = seconds / 60
if minutes < 60:
return f"{minutes:.1f}m"
hours = minutes / 60
return f"{hours:.1f}h"
def truncate(s, max_len=50):
s = str(s)
return s if len(s) <= max_len else s[: max_len - 3] + "..."
def resolve_actor(entry):
"""
Derive the actor exactly like testWebserver.ts does:
entityUpdatedBy ?? query.params.memberId ?? summary.memberId ?? "-"
The summary is already stored in request.summary in the log.
"""
req = entry.get("request", {})
summary = req.get("summary") or {}
query = req.get("query") or {}
params = query.get("params") or {}
return str(
summary.get("entityUpdatedBy")
or params.get("memberId")
or summary.get("memberId")
or "-"
)
# ─── Analysis ─────────────────────────────────────────────────────────────────
def analyze(entries):
stats = {}
timestamps = []
for e in entries:
ts = e.get("timestamp")
if ts:
try:
timestamps.append(datetime.fromisoformat(ts.replace("Z", "+00:00")))
except Exception:
pass
timestamps.sort()
stats["total_entries"] = len(entries)
stats["first_ts"] = timestamps[0] if timestamps else None
stats["last_ts"] = timestamps[-1] if timestamps else None
stats["duration_seconds"] = (
(timestamps[-1] - timestamps[0]).total_seconds() if len(timestamps) >= 2 else 0
)
# Global counters
methods = Counter()
paths = Counter()
statuses = Counter()
actions = Counter()
types = Counter()
actors = Counter()
companies = Counter()
entity_ids = set()
stages = Counter()
ratings = Counter()
hourly_buckets = Counter()
minute_buckets = Counter()
for e in entries:
req = e.get("request", {})
resp = e.get("response", {})
bp = req.get("bodyParsed") or {}
entity = req.get("entityParsed") or bp.get("Entity") or {}
methods[req.get("method", "?")] += 1
raw_path = req.get("path", "?").split("?")[0]
paths[raw_path] += 1
statuses[resp.get("status", "?")] += 1
actions[bp.get("Action", "?")] += 1
types[bp.get("Type", "?")] += 1
actor = resolve_actor(e)
actors[actor] += 1
if isinstance(entity, dict):
cn = entity.get("CompanyName")
if cn:
companies[cn] += 1
eid = entity.get("Id")
if eid is not None:
entity_ids.add(eid)
stage = entity.get("StageName")
if stage:
stages[stage] += 1
rating = entity.get("Rating")
if rating:
ratings[rating] += 1
ts_str = e.get("timestamp")
if ts_str:
try:
dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
hourly_buckets[dt.strftime("%H:00")] += 1
minute_buckets[dt.strftime("%H:%M")] += 1
except Exception:
pass
# ── Per-actor deep stats ──
actor_details = defaultdict(lambda: {
"count": 0,
"actions": Counter(),
"types": Counter(),
"companies": Counter(),
"entity_ids": set(),
"stages": Counter(),
"ratings": Counter(),
"timestamps": [],
"statuses": Counter(),
"paths": Counter(),
"member_ids": Counter(),
"sales_reps": Counter(),
"total_estimated": 0.0,
})
for e in entries:
req = e.get("request", {})
resp = e.get("response", {})
bp = req.get("bodyParsed") or {}
entity = req.get("entityParsed") or bp.get("Entity") or {}
summary = req.get("summary") or {}
actor = resolve_actor(e)
ad = actor_details[actor]
ad["count"] += 1
ad["actions"][bp.get("Action", "?")] += 1
ad["types"][bp.get("Type", "?")] += 1
ad["statuses"][resp.get("status", "?")] += 1
raw_path = req.get("path", "?").split("?")[0]
ad["paths"][raw_path] += 1
# Track which MemberIds triggered callbacks for this actor
mid = bp.get("MemberId")
if mid:
ad["member_ids"][mid] += 1
ts_str = e.get("timestamp")
if ts_str:
try:
ad["timestamps"].append(datetime.fromisoformat(ts_str.replace("Z", "+00:00")))
except Exception:
pass
if isinstance(entity, dict):
cn = entity.get("CompanyName")
if cn:
ad["companies"][cn] += 1
eid = entity.get("Id")
if eid is not None:
ad["entity_ids"].add(eid)
stage = entity.get("StageName")
if stage:
ad["stages"][stage] += 1
rating = entity.get("Rating")
if rating:
ad["ratings"][rating] += 1
et = entity.get("EstimatedTotal")
if et is not None:
ad["total_estimated"] += float(et)
pr = entity.get("PrimarySalesRep")
if pr:
ad["sales_reps"][pr] += 1
# Compute per-actor derived stats
for aid, ad in actor_details.items():
ad["timestamps"].sort()
if len(ad["timestamps"]) >= 2:
dur = (ad["timestamps"][-1] - ad["timestamps"][0]).total_seconds()
ad["duration_seconds"] = dur
ad["events_per_minute"] = ad["count"] / (dur / 60) if dur > 0 else ad["count"]
else:
ad["duration_seconds"] = 0
ad["events_per_minute"] = ad["count"]
ad["first_ts"] = ad["timestamps"][0] if ad["timestamps"] else None
ad["last_ts"] = ad["timestamps"][-1] if ad["timestamps"] else None
stats["actor_details"] = dict(actor_details)
stats["methods"] = methods
stats["paths"] = paths
stats["statuses"] = statuses
stats["actions"] = actions
stats["types"] = types
stats["actors"] = actors
stats["companies"] = companies
stats["entity_ids"] = entity_ids
stats["stages"] = stages
stats["ratings"] = ratings
stats["hourly_buckets"] = hourly_buckets
stats["minute_buckets"] = minute_buckets
if stats["duration_seconds"] > 0:
stats["events_per_minute"] = len(entries) / (stats["duration_seconds"] / 60)
else:
stats["events_per_minute"] = len(entries)
return stats
# ─── PDF building ─────────────────────────────────────────────────────────────
def build_styles():
ss = getSampleStyleSheet()
ss.add(ParagraphStyle(
"ReportTitle", parent=ss["Title"], fontSize=24, textColor=WHITE,
spaceAfter=4, fontName="Helvetica-Bold", alignment=TA_CENTER,
))
ss.add(ParagraphStyle(
"ReportSubtitle", parent=ss["Normal"], fontSize=11, textColor=GRAY_400,
spaceAfter=2, fontName="Helvetica", alignment=TA_CENTER,
))
ss.add(ParagraphStyle(
"SectionHeader", parent=ss["Heading1"], fontSize=16, textColor=GRAY_900,
spaceBefore=14, spaceAfter=6, fontName="Helvetica-Bold",
))
ss.add(ParagraphStyle(
"SubHeader", parent=ss["Heading2"], fontSize=12, textColor=GRAY_800,
spaceBefore=10, spaceAfter=4, fontName="Helvetica-Bold",
))
ss.add(ParagraphStyle(
"BodyText2", parent=ss["Normal"], fontSize=9, textColor=GRAY_800,
spaceAfter=3, fontName="Helvetica", leading=13,
))
ss.add(ParagraphStyle(
"SmallGray", parent=ss["Normal"], fontSize=8, textColor=GRAY_600,
spaceAfter=2, fontName="Helvetica",
))
ss.add(ParagraphStyle(
"KPIValue", parent=ss["Normal"], fontSize=20, textColor=GRAY_900,
fontName="Helvetica-Bold", alignment=TA_CENTER, leading=24,
))
ss.add(ParagraphStyle(
"KPILabel", parent=ss["Normal"], fontSize=8, textColor=GRAY_600,
fontName="Helvetica", alignment=TA_CENTER, spaceAfter=2,
))
ss.add(ParagraphStyle(
"BannerText", parent=ss["Normal"], fontSize=9, textColor=WHITE,
fontName="Helvetica-Bold", spaceAfter=1,
))
return ss
def make_header_banner(ss, log_path, stats):
elements = []
fname = os.path.basename(log_path)
banner_data = [[
Paragraph("Webhook Log Report", ss["ReportTitle"]),
], [
Paragraph(fname, ss["ReportSubtitle"]),
], [
Paragraph(
f"Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
ss["ReportSubtitle"],
),
]]
banner = Table(banner_data, colWidths=[7.0 * inch])
banner.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), HEADER_BG),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("TOPPADDING", (0, 0), (0, 0), 20),
("BOTTOMPADDING", (0, -1), (-1, -1), 16),
("LEFTPADDING", (0, 0), (-1, -1), 20),
("RIGHTPADDING", (0, 0), (-1, -1), 20),
]))
elements.append(banner)
elements.append(Spacer(1, 14))
return elements
def make_kpi_card(label, value):
ss = build_styles()
card_data = [[
Paragraph(str(value), ss["KPIValue"]),
], [
Paragraph(label, ss["KPILabel"]),
]]
card = Table(card_data, colWidths=[1.6 * inch], rowHeights=[28, 16])
card.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), GRAY_100),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("BOX", (0, 0), (-1, -1), 0.5, GRAY_200),
]))
return card
def make_kpi_row(cards_data):
cards = [make_kpi_card(label, value) for label, value in cards_data]
row = Table([cards], colWidths=[1.75 * inch] * len(cards))
row.setStyle(TableStyle([
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
return row
def make_table(title, counter, ss, max_rows=15):
"""Generic table from a Counter — light styling for print."""
elements = []
elements.append(Paragraph(title, ss["SubHeader"]))
items = counter.most_common(max_rows)
if not items:
elements.append(Paragraph("<i>No data</i>", ss["BodyText2"]))
return elements
total = sum(counter.values())
header = ["Item", "Count", "%"]
rows = [header]
for name, count in items:
pct = (count / total * 100) if total else 0
rows.append([truncate(str(name), 45), f"{count:,}", f"{pct:.1f}%"])
t = Table(rows, colWidths=[3.6 * inch, 1.0 * inch, 0.8 * inch])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), GRAY_800),
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 9),
("FONTSIZE", (0, 1), (-1, -1), 9),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("BOTTOMPADDING", (0, 0), (-1, 0), 6),
("TOPPADDING", (0, 0), (-1, 0), 6),
("GRID", (0, 0), (-1, -1), 0.4, GRAY_200),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, GRAY_50]),
("ALIGN", (1, 0), (2, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("TOPPADDING", (0, 1), (-1, -1), 3),
("BOTTOMPADDING", (0, 1), (-1, -1), 3),
]))
elements.append(t)
return elements
def make_pie_chart(title, counter, width=280, height=190):
items = counter.most_common(8)
if not items:
return Spacer(1, 1)
d = Drawing(width, height)
d.add(String(width / 2, height - 12, title,
fontSize=10, fontName="Helvetica-Bold",
fillColor=GRAY_900, textAnchor="middle"))
pie = Pie()
pie.x = 50
pie.y = 10
pie.width = 110
pie.height = 110
pie.data = [v for _, v in items]
pie.labels = [truncate(str(k), 18) for k, _ in items]
pie.sideLabels = True
pie.slices.strokeWidth = 0.5
pie.slices.strokeColor = WHITE
for i in range(len(items)):
pie.slices[i].fillColor = PIE_COLORS[i % len(PIE_COLORS)]
pie.slices[i].fontName = "Helvetica"
pie.slices[i].fontSize = 7
pie.slices[i].labelRadius = 1.35
d.add(pie)
return d
def make_timeline_chart(minute_buckets, width=500, height=150):
if not minute_buckets:
return Spacer(1, 1)
sorted_keys = sorted(minute_buckets.keys())
if len(sorted_keys) > 40:
step = max(1, len(sorted_keys) // 40)
sampled_keys = sorted_keys[::step]
else:
sampled_keys = sorted_keys
values = [minute_buckets[k] for k in sampled_keys]
d = Drawing(width, height)
d.add(String(width / 2, height - 10, "Event Timeline (by minute)",
fontSize=10, fontName="Helvetica-Bold",
fillColor=GRAY_900, textAnchor="middle"))
chart = VerticalBarChart()
chart.x = 50
chart.y = 25
chart.width = width - 80
chart.height = height - 50
chart.data = [values]
chart.categoryAxis.categoryNames = sampled_keys
chart.categoryAxis.labels.angle = 45
chart.categoryAxis.labels.fontSize = 6
chart.categoryAxis.labels.fontName = "Helvetica"
chart.categoryAxis.labels.dy = -5
chart.valueAxis.labels.fontSize = 7
chart.valueAxis.labels.fontName = "Helvetica"
chart.valueAxis.valueMin = 0
chart.bars[0].fillColor = GRAY_600
chart.bars[0].strokeColor = None
chart.barWidth = max(2, int((width - 100) / len(values) * 0.7))
d.add(chart)
return d
def build_actor_activity_section(stats, ss):
"""Per-actor deep-dive. Actor = entityUpdatedBy ?? query.memberId ?? summary.memberId."""
elements = []
elements.append(PageBreak())
elements.append(Paragraph("Actor Activity Deep Dive", ss["SectionHeader"]))
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=4))
elements.append(Paragraph(
'The <b>actor</b> is resolved as: <i>entityUpdatedBy → query.memberId → summary.memberId</i>. '
'This is the person or system that caused the change in ConnectWise.',
ss["SmallGray"],
))
elements.append(Spacer(1, 10))
actor_details = stats.get("actor_details", {})
if not actor_details:
elements.append(Paragraph("<i>No actor data available.</i>", ss["BodyText2"]))
return elements
sorted_actors = sorted(actor_details.items(), key=lambda x: -x[1]["count"])
# Actor distribution pie chart
actor_counter = Counter({aid: ad["count"] for aid, ad in sorted_actors})
elements.append(make_pie_chart("Events by Actor", actor_counter, width=350, height=210))
elements.append(Spacer(1, 10))
for idx, (aid, ad) in enumerate(sorted_actors):
# Actor header — slim dark bar
banner_data = [[
Paragraph(
f'<font size="12"><b>{aid}</b></font>'
f'&nbsp;&nbsp;&nbsp;'
f'<font size="9" color="#cccccc">{ad["count"]:,} events</font>',
ss["BannerText"],
),
]]
banner = Table(banner_data, colWidths=[7.0 * inch])
banner.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), HEADER_BG),
("TOPPADDING", (0, 0), (-1, -1), 7),
("BOTTOMPADDING", (0, 0), (-1, -1), 7),
("LEFTPADDING", (0, 0), (-1, -1), 12),
]))
elements.append(banner)
# KPI row
kpi = make_kpi_row([
("Events", f"{ad['count']:,}"),
("Entities", f"{len(ad['entity_ids']):,}"),
("Companies", f"{len(ad['companies']):,}"),
("Evts/Min", f"{ad['events_per_minute']:.1f}"),
])
elements.append(kpi)
elements.append(Spacer(1, 4))
# Info grid
first_str = ad["first_ts"].strftime("%Y-%m-%d %H:%M:%S") if ad["first_ts"] else ""
last_str = ad["last_ts"].strftime("%Y-%m-%d %H:%M:%S") if ad["last_ts"] else ""
dur = duration_str(ad["duration_seconds"])
est = ad["total_estimated"]
mid_str = ", ".join(f"{k} ({v})" for k, v in ad["member_ids"].most_common(5)) if ad["member_ids"] else ""
sr_str = ", ".join(f"{k} ({v})" for k, v in ad["sales_reps"].most_common(5)) if ad["sales_reps"] else ""
info_rows = [
[
Paragraph('<font color="#757575">First Event</font>', ss["BodyText2"]),
Paragraph(f'<b>{first_str}</b>', ss["BodyText2"]),
Paragraph('<font color="#757575">Last Event</font>', ss["BodyText2"]),
Paragraph(f'<b>{last_str}</b>', ss["BodyText2"]),
],
[
Paragraph('<font color="#757575">Active Duration</font>', ss["BodyText2"]),
Paragraph(f'<b>{dur}</b>', ss["BodyText2"]),
Paragraph('<font color="#757575">Total Est. Value</font>', ss["BodyText2"]),
Paragraph(f'<b>${est:,.2f}</b>', ss["BodyText2"]),
],
[
Paragraph('<font color="#757575">Callback Members</font>', ss["BodyText2"]),
Paragraph(f'{truncate(mid_str, 40)}', ss["BodyText2"]),
Paragraph('<font color="#757575">Sales Reps</font>', ss["BodyText2"]),
Paragraph(f'{truncate(sr_str, 40)}', ss["BodyText2"]),
],
]
info_table = Table(info_rows, colWidths=[1.3 * inch, 2.1 * inch, 1.3 * inch, 2.1 * inch])
info_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), GRAY_50),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.3, GRAY_200),
]))
elements.append(info_table)
elements.append(Spacer(1, 6))
# Breakdown tables
if ad["types"]:
elements.extend(make_table(f"{aid} — Entity Types", ad["types"], ss, max_rows=8))
elements.append(Spacer(1, 6))
if ad["companies"]:
elements.extend(make_table(f"{aid} — Companies", ad["companies"], ss, max_rows=10))
elements.append(Spacer(1, 6))
if ad["stages"]:
elements.extend(make_table(f"{aid} — Stages", ad["stages"], ss, max_rows=8))
elements.append(Spacer(1, 6))
if ad["ratings"]:
elements.extend(make_table(f"{aid} — Ratings", ad["ratings"], ss, max_rows=8))
elements.append(Spacer(1, 6))
# Entity IDs
if ad["entity_ids"]:
id_list = sorted(ad["entity_ids"])
id_str = ", ".join(str(i) for i in id_list[:30])
if len(id_list) > 30:
id_str += f" ... (+{len(id_list) - 30} more)"
elements.append(Paragraph(f"{aid} — Entity IDs Touched", ss["SubHeader"]))
elements.append(Paragraph(f'<font size="8">{id_str}</font>', ss["BodyText2"]))
elements.append(Spacer(1, 6))
# Divider
if idx < len(sorted_actors) - 1:
elements.append(Spacer(1, 4))
elements.append(HRFlowable(width="100%", thickness=0.5, color=GRAY_200, spaceAfter=4))
elements.append(Spacer(1, 4))
return elements
def build_summary_log_table(entries, ss, max_rows=30):
elements = []
elements.append(PageBreak())
elements.append(Paragraph("Event Summary", ss["SectionHeader"]))
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=6))
elements.append(Paragraph(
f"Aggregated view of {len(entries):,} webhook events — grouped by entity.",
ss["SmallGray"],
))
elements.append(Spacer(1, 8))
entity_groups = defaultdict(lambda: {
"count": 0, "name": "", "company": "",
"actions": Counter(), "actors": set(),
"est_total": None,
})
for e in entries:
req = e.get("request", {})
bp = req.get("bodyParsed") or {}
entity = req.get("entityParsed") or bp.get("Entity") or {}
if not isinstance(entity, dict):
continue
eid = entity.get("Id")
if eid is None:
continue
eg = entity_groups[eid]
eg["count"] += 1
eg["name"] = entity.get("OpportunityName") or entity.get("CompanyName") or eg["name"]
eg["company"] = entity.get("CompanyName") or eg["company"]
eg["actions"][bp.get("Action", "?")] += 1
actor = resolve_actor(e)
eg["actors"].add(actor)
et = entity.get("EstimatedTotal")
if et is not None:
eg["est_total"] = et
sorted_entities = sorted(entity_groups.items(), key=lambda x: -x[1]["count"])
header = ["ID", "Entity Name", "Company", "Events", "Actions", "Actors", "Est. Total"]
rows = [header]
for eid, eg in sorted_entities[:max_rows]:
actions_str = ", ".join(f"{a}({c})" for a, c in eg["actions"].most_common(3))
actors_str = ", ".join(sorted(eg["actors"]))
total_str = f"${eg['est_total']:,.2f}" if eg["est_total"] is not None else ""
rows.append([
str(eid),
truncate(eg["name"], 28),
truncate(eg["company"], 18),
f"{eg['count']:,}",
truncate(actions_str, 24),
truncate(actors_str, 18),
total_str,
])
t = Table(rows, colWidths=[0.45 * inch, 1.7 * inch, 1.1 * inch, 0.55 * inch, 1.2 * inch, 1.0 * inch, 0.8 * inch])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), GRAY_800),
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8),
("FONTSIZE", (0, 1), (-1, -1), 8),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("GRID", (0, 0), (-1, -1), 0.3, GRAY_200),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, GRAY_50]),
("ALIGN", (0, 0), (0, -1), "CENTER"),
("ALIGN", (3, 0), (3, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 5),
("RIGHTPADDING", (0, 0), (-1, -1), 5),
("TOPPADDING", (0, 1), (-1, -1), 2),
("BOTTOMPADDING", (0, 1), (-1, -1), 2),
]))
elements.append(t)
if len(sorted_entities) > max_rows:
elements.append(Spacer(1, 4))
elements.append(Paragraph(
f'Showing top {max_rows} of {len(sorted_entities)} entities.',
ss["SmallGray"],
))
return elements
def add_page_number(canvas, doc):
canvas.saveState()
canvas.setFillColor(GRAY_800)
canvas.rect(0, 0, LETTER[0], 22, fill=1, stroke=0)
canvas.setFillColor(WHITE)
canvas.setFont("Helvetica", 7)
canvas.drawCentredString(LETTER[0] / 2, 8, f"Page {doc.page}")
canvas.setFillColor(GRAY_400)
canvas.setFont("Helvetica", 7)
canvas.drawString(30, 8, "Optima API · Webhook Log Report")
canvas.drawRightString(LETTER[0] - 30, 8, datetime.now(timezone.utc).strftime("%Y-%m-%d"))
canvas.restoreState()
# ─── Main ─────────────────────────────────────────────────────────────────────
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
log_dir = os.path.join(script_dir, "..", "cw-api-logs")
if len(sys.argv) > 1:
log_path = sys.argv[1]
else:
log_path = find_latest_log(log_dir)
print(f"📄 Reading log: {log_path}")
entries = parse_log(log_path)
print(f"{len(entries)} entries parsed")
stats = analyze(entries)
ss = build_styles()
log_basename = os.path.splitext(os.path.basename(log_path))[0]
out_path = os.path.join(script_dir, "..", "cw-api-logs", f"{log_basename}-report.pdf")
doc = SimpleDocTemplate(
out_path,
pagesize=LETTER,
leftMargin=0.6 * inch,
rightMargin=0.6 * inch,
topMargin=0.5 * inch,
bottomMargin=0.5 * inch,
)
elements = []
# ── Title Banner ──
elements.extend(make_header_banner(ss, log_path, stats))
# ── Overview ──
elements.append(Paragraph("Overview", ss["SectionHeader"]))
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
elements.append(make_kpi_row([
("Total Events", f"{stats['total_entries']:,}"),
("Unique Entities", f"{len(stats['entity_ids']):,}"),
("Companies", f"{len(stats['companies']):,}"),
("Duration", duration_str(stats["duration_seconds"])),
]))
elements.append(Spacer(1, 8))
elements.append(make_kpi_row([
("Events / Min", f"{stats['events_per_minute']:.1f}"),
("HTTP Methods", f"{len(stats['methods']):,}"),
("Action Types", f"{len(stats['actions']):,}"),
("Actors", f"{len(stats['actors']):,}"),
]))
elements.append(Spacer(1, 12))
# Time range
if stats["first_ts"] and stats["last_ts"]:
info = [
[
Paragraph('<font color="#757575">First Event</font>', ss["BodyText2"]),
Paragraph(f'<b>{stats["first_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}</b>', ss["BodyText2"]),
Paragraph('<font color="#757575">Last Event</font>', ss["BodyText2"]),
Paragraph(f'<b>{stats["last_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}</b>', ss["BodyText2"]),
]
]
ti = Table(info, colWidths=[1.2 * inch, 2.4 * inch, 1.2 * inch, 2.4 * inch])
ti.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), GRAY_50),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("BOX", (0, 0), (-1, -1), 0.4, GRAY_200),
]))
elements.append(ti)
elements.append(Spacer(1, 14))
# ── Charts ──
elements.append(Paragraph("Visual Breakdown", ss["SectionHeader"]))
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
elements.append(make_timeline_chart(stats["minute_buckets"]))
elements.append(Spacer(1, 12))
pie_row = [[
make_pie_chart("By Action", stats["actions"]),
make_pie_chart("By Type", stats["types"]),
]]
pt = Table(pie_row, colWidths=[3.5 * inch, 3.5 * inch])
pt.setStyle(TableStyle([
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
elements.append(pt)
elements.append(Spacer(1, 6))
if len(stats["stages"]) > 1 or len(stats["ratings"]) > 1:
pie_row2 = [[
make_pie_chart("By Stage", stats["stages"]),
make_pie_chart("By Rating", stats["ratings"]),
]]
pt2 = Table(pie_row2, colWidths=[3.5 * inch, 3.5 * inch])
pt2.setStyle(TableStyle([
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
elements.append(pt2)
# Actor pie chart
elements.append(Spacer(1, 6))
elements.append(make_pie_chart("By Actor", stats["actors"], width=350, height=210))
# ── General Information ──
elements.append(PageBreak())
elements.append(Paragraph("General Information", ss["SectionHeader"]))
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
elements.extend(make_table("Response Status Codes", stats["statuses"], ss))
elements.append(Spacer(1, 10))
elements.extend(make_table("HTTP Methods", stats["methods"], ss))
elements.append(Spacer(1, 10))
elements.extend(make_table("Webhook Actions", stats["actions"], ss))
elements.append(Spacer(1, 10))
elements.extend(make_table("Entity Types", stats["types"], ss))
elements.append(Spacer(1, 10))
elements.extend(make_table("Request Paths", stats["paths"], ss))
elements.append(Spacer(1, 10))
elements.extend(make_table("Actors", stats["actors"], ss))
elements.append(Spacer(1, 10))
if stats["companies"]:
elements.extend(make_table("Companies", stats["companies"], ss))
elements.append(Spacer(1, 10))
if stats["stages"]:
elements.extend(make_table("Opportunity Stages", stats["stages"], ss))
elements.append(Spacer(1, 10))
if stats["ratings"]:
elements.extend(make_table("Opportunity Ratings", stats["ratings"], ss))
elements.append(Spacer(1, 10))
elements.extend(make_table("Hourly Distribution", stats["hourly_buckets"], ss))
# ── Actor Deep Dive ──
elements.extend(build_actor_activity_section(stats, ss))
# ── Entity Summary ──
elements.extend(build_summary_log_table(entries, ss, max_rows=30))
# Build
doc.build(elements, onFirstPage=add_page_number, onLaterPages=add_page_number)
print(f"✅ Report generated: {out_path}")
print(f" File size: {os.path.getsize(out_path) / 1024:.1f} KB")
if __name__ == "__main__":
main()
+10
View File
@@ -67,3 +67,13 @@ export type SecureValue = Prisma.SecureValueModel
*
*/
export type Credential = Prisma.CredentialModel
/**
* Model GeneratedQuotes
*
*/
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
/**
* Model CwMember
*
*/
export type CwMember = Prisma.CwMemberModel
+10
View File
@@ -89,3 +89,13 @@ export type SecureValue = Prisma.SecureValueModel
*
*/
export type Credential = Prisma.CredentialModel
/**
* Model GeneratedQuotes
*
*/
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
/**
* Model CwMember
*
*/
export type CwMember = Prisma.CwMemberModel
+34
View File
@@ -280,6 +280,23 @@ export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
_max?: Prisma.NestedJsonFilter<$PrismaModel>
}
export type BytesFilter<$PrismaModel = never> = {
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
not?: Prisma.NestedBytesFilter<$PrismaModel> | runtime.Bytes
}
export type BytesWithAggregatesFilter<$PrismaModel = never> = {
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
not?: Prisma.NestedBytesWithAggregatesFilter<$PrismaModel> | runtime.Bytes
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBytesFilter<$PrismaModel>
_max?: Prisma.NestedBytesFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
@@ -521,4 +538,21 @@ export type NestedJsonFilterBase<$PrismaModel = never> = {
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type NestedBytesFilter<$PrismaModel = never> = {
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
not?: Prisma.NestedBytesFilter<$PrismaModel> | runtime.Bytes
}
export type NestedBytesWithAggregatesFilter<$PrismaModel = never> = {
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
not?: Prisma.NestedBytesWithAggregatesFilter<$PrismaModel> | runtime.Bytes
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBytesFilter<$PrismaModel>
_max?: Prisma.NestedBytesFilter<$PrismaModel>
}
File diff suppressed because one or more lines are too long
+204 -2
View File
@@ -393,7 +393,9 @@ export const ModelName = {
Opportunity: 'Opportunity',
CredentialType: 'CredentialType',
SecureValue: 'SecureValue',
Credential: 'Credential'
Credential: 'Credential',
GeneratedQuotes: 'GeneratedQuotes',
CwMember: 'CwMember'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -409,7 +411,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential"
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential" | "generatedQuotes" | "cwMember"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -1153,6 +1155,154 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
GeneratedQuotes: {
payload: Prisma.$GeneratedQuotesPayload<ExtArgs>
fields: Prisma.GeneratedQuotesFieldRefs
operations: {
findUnique: {
args: Prisma.GeneratedQuotesFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload> | null
}
findUniqueOrThrow: {
args: Prisma.GeneratedQuotesFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
}
findFirst: {
args: Prisma.GeneratedQuotesFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload> | null
}
findFirstOrThrow: {
args: Prisma.GeneratedQuotesFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
}
findMany: {
args: Prisma.GeneratedQuotesFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>[]
}
create: {
args: Prisma.GeneratedQuotesCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
}
createMany: {
args: Prisma.GeneratedQuotesCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.GeneratedQuotesCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>[]
}
delete: {
args: Prisma.GeneratedQuotesDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
}
update: {
args: Prisma.GeneratedQuotesUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
}
deleteMany: {
args: Prisma.GeneratedQuotesDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.GeneratedQuotesUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.GeneratedQuotesUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>[]
}
upsert: {
args: Prisma.GeneratedQuotesUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
}
aggregate: {
args: Prisma.GeneratedQuotesAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateGeneratedQuotes>
}
groupBy: {
args: Prisma.GeneratedQuotesGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.GeneratedQuotesGroupByOutputType>[]
}
count: {
args: Prisma.GeneratedQuotesCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.GeneratedQuotesCountAggregateOutputType> | number
}
}
}
CwMember: {
payload: Prisma.$CwMemberPayload<ExtArgs>
fields: Prisma.CwMemberFieldRefs
operations: {
findUnique: {
args: Prisma.CwMemberFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload> | null
}
findUniqueOrThrow: {
args: Prisma.CwMemberFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
findFirst: {
args: Prisma.CwMemberFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload> | null
}
findFirstOrThrow: {
args: Prisma.CwMemberFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
findMany: {
args: Prisma.CwMemberFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
}
create: {
args: Prisma.CwMemberCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
createMany: {
args: Prisma.CwMemberCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.CwMemberCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
}
delete: {
args: Prisma.CwMemberDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
update: {
args: Prisma.CwMemberUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
deleteMany: {
args: Prisma.CwMemberDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CwMemberUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.CwMemberUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
}
upsert: {
args: Prisma.CwMemberUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
aggregate: {
args: Prisma.CwMemberAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCwMember>
}
groupBy: {
args: Prisma.CwMemberGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CwMemberGroupByOutputType>[]
}
count: {
args: Prisma.CwMemberCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CwMemberCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
@@ -1322,6 +1472,7 @@ export const OpportunityScalarFieldEnum = {
siteName: 'siteName',
customerPO: 'customerPO',
totalSalesTax: 'totalSalesTax',
probability: 'probability',
locationName: 'locationName',
locationCwId: 'locationCwId',
departmentName: 'departmentName',
@@ -1336,6 +1487,7 @@ export const OpportunityScalarFieldEnum = {
companyId: 'companyId',
productSequence: 'productSequence',
cwLastUpdated: 'cwLastUpdated',
cwDateEntered: 'cwDateEntered',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -1384,6 +1536,40 @@ export const CredentialScalarFieldEnum = {
export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof CredentialScalarFieldEnum]
export const GeneratedQuotesScalarFieldEnum = {
id: 'id',
quoteRegenData: 'quoteRegenData',
quoteRegenParams: 'quoteRegenParams',
quoteRegenHash: 'quoteRegenHash',
downloads: 'downloads',
quoteFile: 'quoteFile',
quoteFileName: 'quoteFileName',
opportunityId: 'opportunityId',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
export const CwMemberScalarFieldEnum = {
id: 'id',
cwMemberId: 'cwMemberId',
identifier: 'identifier',
firstName: 'firstName',
lastName: 'lastName',
officeEmail: 'officeEmail',
inactiveFlag: 'inactiveFlag',
apiKey: 'apiKey',
cwLastUpdated: 'cwLastUpdated',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CwMemberScalarFieldEnum = (typeof CwMemberScalarFieldEnum)[keyof typeof CwMemberScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -1506,6 +1692,20 @@ export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'J
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
/**
* Reference to a field of type 'Bytes'
*/
export type BytesFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Bytes'>
/**
* Reference to a field of type 'Bytes[]'
*/
export type ListBytesFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Bytes[]'>
/**
* Batch Payload for updateMany & deleteMany & createMany
*/
@@ -1611,6 +1811,8 @@ export type GlobalOmitConfig = {
credentialType?: Prisma.CredentialTypeOmit
secureValue?: Prisma.SecureValueOmit
credential?: Prisma.CredentialOmit
generatedQuotes?: Prisma.GeneratedQuotesOmit
cwMember?: Prisma.CwMemberOmit
}
/* Types for Logging */
@@ -60,7 +60,9 @@ export const ModelName = {
Opportunity: 'Opportunity',
CredentialType: 'CredentialType',
SecureValue: 'SecureValue',
Credential: 'Credential'
Credential: 'Credential',
GeneratedQuotes: 'GeneratedQuotes',
CwMember: 'CwMember'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -209,6 +211,7 @@ export const OpportunityScalarFieldEnum = {
siteName: 'siteName',
customerPO: 'customerPO',
totalSalesTax: 'totalSalesTax',
probability: 'probability',
locationName: 'locationName',
locationCwId: 'locationCwId',
departmentName: 'departmentName',
@@ -223,6 +226,7 @@ export const OpportunityScalarFieldEnum = {
companyId: 'companyId',
productSequence: 'productSequence',
cwLastUpdated: 'cwLastUpdated',
cwDateEntered: 'cwDateEntered',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -271,6 +275,40 @@ export const CredentialScalarFieldEnum = {
export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof CredentialScalarFieldEnum]
export const GeneratedQuotesScalarFieldEnum = {
id: 'id',
quoteRegenData: 'quoteRegenData',
quoteRegenParams: 'quoteRegenParams',
quoteRegenHash: 'quoteRegenHash',
downloads: 'downloads',
quoteFile: 'quoteFile',
quoteFileName: 'quoteFileName',
opportunityId: 'opportunityId',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
export const CwMemberScalarFieldEnum = {
id: 'id',
cwMemberId: 'cwMemberId',
identifier: 'identifier',
firstName: 'firstName',
lastName: 'lastName',
officeEmail: 'officeEmail',
inactiveFlag: 'inactiveFlag',
apiKey: 'apiKey',
cwLastUpdated: 'cwLastUpdated',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CwMemberScalarFieldEnum = (typeof CwMemberScalarFieldEnum)[keyof typeof CwMemberScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
+2
View File
@@ -18,4 +18,6 @@ export type * from './models/Opportunity.ts'
export type * from './models/CredentialType.ts'
export type * from './models/SecureValue.ts'
export type * from './models/Credential.ts'
export type * from './models/GeneratedQuotes.ts'
export type * from './models/CwMember.ts'
export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+390 -1
View File
@@ -40,6 +40,7 @@ export type OpportunityAvgAggregateOutputType = {
contactCwId: number | null
siteCwId: number | null
totalSalesTax: number | null
probability: number | null
locationCwId: number | null
departmentCwId: number | null
closedByCwId: number | null
@@ -60,6 +61,7 @@ export type OpportunitySumAggregateOutputType = {
contactCwId: number | null
siteCwId: number | null
totalSalesTax: number | null
probability: number | null
locationCwId: number | null
departmentCwId: number | null
closedByCwId: number | null
@@ -98,6 +100,7 @@ export type OpportunityMinAggregateOutputType = {
siteName: string | null
customerPO: string | null
totalSalesTax: number | null
probability: number | null
locationName: string | null
locationCwId: number | null
departmentName: string | null
@@ -111,6 +114,7 @@ export type OpportunityMinAggregateOutputType = {
closedByCwId: number | null
companyId: string | null
cwLastUpdated: Date | null
cwDateEntered: Date | null
createdAt: Date | null
updatedAt: Date | null
}
@@ -147,6 +151,7 @@ export type OpportunityMaxAggregateOutputType = {
siteName: string | null
customerPO: string | null
totalSalesTax: number | null
probability: number | null
locationName: string | null
locationCwId: number | null
departmentName: string | null
@@ -160,6 +165,7 @@ export type OpportunityMaxAggregateOutputType = {
closedByCwId: number | null
companyId: string | null
cwLastUpdated: Date | null
cwDateEntered: Date | null
createdAt: Date | null
updatedAt: Date | null
}
@@ -196,6 +202,7 @@ export type OpportunityCountAggregateOutputType = {
siteName: number
customerPO: number
totalSalesTax: number
probability: number
locationName: number
locationCwId: number
departmentName: number
@@ -210,6 +217,7 @@ export type OpportunityCountAggregateOutputType = {
companyId: number
productSequence: number
cwLastUpdated: number
cwDateEntered: number
createdAt: number
updatedAt: number
_all: number
@@ -230,6 +238,7 @@ export type OpportunityAvgAggregateInputType = {
contactCwId?: true
siteCwId?: true
totalSalesTax?: true
probability?: true
locationCwId?: true
departmentCwId?: true
closedByCwId?: true
@@ -250,6 +259,7 @@ export type OpportunitySumAggregateInputType = {
contactCwId?: true
siteCwId?: true
totalSalesTax?: true
probability?: true
locationCwId?: true
departmentCwId?: true
closedByCwId?: true
@@ -288,6 +298,7 @@ export type OpportunityMinAggregateInputType = {
siteName?: true
customerPO?: true
totalSalesTax?: true
probability?: true
locationName?: true
locationCwId?: true
departmentName?: true
@@ -301,6 +312,7 @@ export type OpportunityMinAggregateInputType = {
closedByCwId?: true
companyId?: true
cwLastUpdated?: true
cwDateEntered?: true
createdAt?: true
updatedAt?: true
}
@@ -337,6 +349,7 @@ export type OpportunityMaxAggregateInputType = {
siteName?: true
customerPO?: true
totalSalesTax?: true
probability?: true
locationName?: true
locationCwId?: true
departmentName?: true
@@ -350,6 +363,7 @@ export type OpportunityMaxAggregateInputType = {
closedByCwId?: true
companyId?: true
cwLastUpdated?: true
cwDateEntered?: true
createdAt?: true
updatedAt?: true
}
@@ -386,6 +400,7 @@ export type OpportunityCountAggregateInputType = {
siteName?: true
customerPO?: true
totalSalesTax?: true
probability?: true
locationName?: true
locationCwId?: true
departmentName?: true
@@ -400,6 +415,7 @@ export type OpportunityCountAggregateInputType = {
companyId?: true
productSequence?: true
cwLastUpdated?: true
cwDateEntered?: true
createdAt?: true
updatedAt?: true
_all?: true
@@ -523,6 +539,7 @@ export type OpportunityGroupByOutputType = {
siteName: string | null
customerPO: string | null
totalSalesTax: number
probability: number
locationName: string | null
locationCwId: number | null
departmentName: string | null
@@ -537,6 +554,7 @@ export type OpportunityGroupByOutputType = {
companyId: string | null
productSequence: number[]
cwLastUpdated: Date | null
cwDateEntered: Date | null
createdAt: Date
updatedAt: Date
_count: OpportunityCountAggregateOutputType | null
@@ -596,6 +614,7 @@ export type OpportunityWhereInput = {
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
probability?: Prisma.FloatFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
@@ -610,8 +629,10 @@ export type OpportunityWhereInput = {
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
cwDateEntered?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
}
@@ -647,6 +668,7 @@ export type OpportunityOrderByWithRelationInput = {
siteName?: Prisma.SortOrderInput | Prisma.SortOrder
customerPO?: Prisma.SortOrderInput | Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrderInput | Prisma.SortOrder
locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder
departmentName?: Prisma.SortOrderInput | Prisma.SortOrder
@@ -661,8 +683,10 @@ export type OpportunityOrderByWithRelationInput = {
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
productSequence?: Prisma.SortOrder
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
cwDateEntered?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
company?: Prisma.CompanyOrderByWithRelationInput
}
@@ -701,6 +725,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
probability?: Prisma.FloatFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
@@ -715,8 +740,10 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
cwDateEntered?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
}, "id" | "cwOpportunityId">
@@ -752,6 +779,7 @@ export type OpportunityOrderByWithAggregationInput = {
siteName?: Prisma.SortOrderInput | Prisma.SortOrder
customerPO?: Prisma.SortOrderInput | Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrderInput | Prisma.SortOrder
locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder
departmentName?: Prisma.SortOrderInput | Prisma.SortOrder
@@ -766,6 +794,7 @@ export type OpportunityOrderByWithAggregationInput = {
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
productSequence?: Prisma.SortOrder
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
cwDateEntered?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
_count?: Prisma.OpportunityCountOrderByAggregateInput
@@ -810,6 +839,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
siteName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number
probability?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
@@ -824,6 +854,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
companyId?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
cwLastUpdated?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
cwDateEntered?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
}
@@ -860,6 +891,7 @@ export type OpportunityCreateInput = {
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
@@ -873,8 +905,10 @@ export type OpportunityCreateInput = {
closedByCwId?: number | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
cwDateEntered?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
}
@@ -910,6 +944,7 @@ export type OpportunityUncheckedCreateInput = {
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
@@ -924,8 +959,10 @@ export type OpportunityUncheckedCreateInput = {
companyId?: string | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
cwDateEntered?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
}
export type OpportunityUpdateInput = {
@@ -960,6 +997,7 @@ export type OpportunityUpdateInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -973,8 +1011,10 @@ export type OpportunityUpdateInput = {
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
}
@@ -1010,6 +1050,7 @@ export type OpportunityUncheckedUpdateInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1024,8 +1065,10 @@ export type OpportunityUncheckedUpdateInput = {
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
}
export type OpportunityCreateManyInput = {
@@ -1060,6 +1103,7 @@ export type OpportunityCreateManyInput = {
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
@@ -1074,6 +1118,7 @@ export type OpportunityCreateManyInput = {
companyId?: string | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
cwDateEntered?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -1110,6 +1155,7 @@ export type OpportunityUpdateManyMutationInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1123,6 +1169,7 @@ export type OpportunityUpdateManyMutationInput = {
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -1159,6 +1206,7 @@ export type OpportunityUncheckedUpdateManyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1173,6 +1221,7 @@ export type OpportunityUncheckedUpdateManyInput = {
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -1227,6 +1276,7 @@ export type OpportunityCountOrderByAggregateInput = {
siteName?: Prisma.SortOrder
customerPO?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder
departmentName?: Prisma.SortOrder
@@ -1241,6 +1291,7 @@ export type OpportunityCountOrderByAggregateInput = {
companyId?: Prisma.SortOrder
productSequence?: Prisma.SortOrder
cwLastUpdated?: Prisma.SortOrder
cwDateEntered?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -1259,6 +1310,7 @@ export type OpportunityAvgOrderByAggregateInput = {
contactCwId?: Prisma.SortOrder
siteCwId?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder
departmentCwId?: Prisma.SortOrder
closedByCwId?: Prisma.SortOrder
@@ -1297,6 +1349,7 @@ export type OpportunityMaxOrderByAggregateInput = {
siteName?: Prisma.SortOrder
customerPO?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder
departmentName?: Prisma.SortOrder
@@ -1310,6 +1363,7 @@ export type OpportunityMaxOrderByAggregateInput = {
closedByCwId?: Prisma.SortOrder
companyId?: Prisma.SortOrder
cwLastUpdated?: Prisma.SortOrder
cwDateEntered?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -1346,6 +1400,7 @@ export type OpportunityMinOrderByAggregateInput = {
siteName?: Prisma.SortOrder
customerPO?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder
departmentName?: Prisma.SortOrder
@@ -1359,6 +1414,7 @@ export type OpportunityMinOrderByAggregateInput = {
closedByCwId?: Prisma.SortOrder
companyId?: Prisma.SortOrder
cwLastUpdated?: Prisma.SortOrder
cwDateEntered?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -1377,12 +1433,18 @@ export type OpportunitySumOrderByAggregateInput = {
contactCwId?: Prisma.SortOrder
siteCwId?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder
departmentCwId?: Prisma.SortOrder
closedByCwId?: Prisma.SortOrder
productSequence?: Prisma.SortOrder
}
export type OpportunityScalarRelationFilter = {
is?: Prisma.OpportunityWhereInput
isNot?: Prisma.OpportunityWhereInput
}
export type OpportunityCreateNestedManyWithoutCompanyInput = {
create?: Prisma.XOR<Prisma.OpportunityCreateWithoutCompanyInput, Prisma.OpportunityUncheckedCreateWithoutCompanyInput> | Prisma.OpportunityCreateWithoutCompanyInput[] | Prisma.OpportunityUncheckedCreateWithoutCompanyInput[]
connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutCompanyInput | Prisma.OpportunityCreateOrConnectWithoutCompanyInput[]
@@ -1434,6 +1496,20 @@ export type OpportunityUpdateproductSequenceInput = {
push?: number | number[]
}
export type OpportunityCreateNestedOneWithoutGeneratedQuotesInput = {
create?: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutGeneratedQuotesInput
connect?: Prisma.OpportunityWhereUniqueInput
}
export type OpportunityUpdateOneRequiredWithoutGeneratedQuotesNestedInput = {
create?: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutGeneratedQuotesInput
upsert?: Prisma.OpportunityUpsertWithoutGeneratedQuotesInput
connect?: Prisma.OpportunityWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.OpportunityUpdateToOneWithWhereWithoutGeneratedQuotesInput, Prisma.OpportunityUpdateWithoutGeneratedQuotesInput>, Prisma.OpportunityUncheckedUpdateWithoutGeneratedQuotesInput>
}
export type OpportunityCreateWithoutCompanyInput = {
id?: string
cwOpportunityId: number
@@ -1466,6 +1542,7 @@ export type OpportunityCreateWithoutCompanyInput = {
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
@@ -1479,8 +1556,10 @@ export type OpportunityCreateWithoutCompanyInput = {
closedByCwId?: number | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
cwDateEntered?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
}
export type OpportunityUncheckedCreateWithoutCompanyInput = {
@@ -1515,6 +1594,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
@@ -1528,8 +1608,10 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
closedByCwId?: number | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
cwDateEntered?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
}
export type OpportunityCreateOrConnectWithoutCompanyInput = {
@@ -1593,6 +1675,7 @@ export type OpportunityScalarWhereInput = {
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
probability?: Prisma.FloatFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
@@ -1607,10 +1690,235 @@ export type OpportunityScalarWhereInput = {
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
cwDateEntered?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
}
export type OpportunityCreateWithoutGeneratedQuotesInput = {
id?: string
cwOpportunityId: number
name: string
notes?: string | null
typeName?: string | null
typeCwId?: number | null
stageName?: string | null
stageCwId?: number | null
statusName?: string | null
statusCwId?: number | null
priorityName?: string | null
priorityCwId?: number | null
ratingName?: string | null
ratingCwId?: number | null
source?: string | null
campaignName?: string | null
campaignCwId?: number | null
primarySalesRepName?: string | null
primarySalesRepIdentifier?: string | null
primarySalesRepCwId?: number | null
secondarySalesRepName?: string | null
secondarySalesRepIdentifier?: string | null
secondarySalesRepCwId?: number | null
companyCwId?: number | null
companyName?: string | null
contactCwId?: number | null
contactName?: string | null
siteCwId?: number | null
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
departmentCwId?: number | null
expectedCloseDate?: Date | string | null
pipelineChangeDate?: Date | string | null
dateBecameLead?: Date | string | null
closedDate?: Date | string | null
closedFlag?: boolean
closedByName?: string | null
closedByCwId?: number | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
cwDateEntered?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
}
export type OpportunityUncheckedCreateWithoutGeneratedQuotesInput = {
id?: string
cwOpportunityId: number
name: string
notes?: string | null
typeName?: string | null
typeCwId?: number | null
stageName?: string | null
stageCwId?: number | null
statusName?: string | null
statusCwId?: number | null
priorityName?: string | null
priorityCwId?: number | null
ratingName?: string | null
ratingCwId?: number | null
source?: string | null
campaignName?: string | null
campaignCwId?: number | null
primarySalesRepName?: string | null
primarySalesRepIdentifier?: string | null
primarySalesRepCwId?: number | null
secondarySalesRepName?: string | null
secondarySalesRepIdentifier?: string | null
secondarySalesRepCwId?: number | null
companyCwId?: number | null
companyName?: string | null
contactCwId?: number | null
contactName?: string | null
siteCwId?: number | null
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
departmentCwId?: number | null
expectedCloseDate?: Date | string | null
pipelineChangeDate?: Date | string | null
dateBecameLead?: Date | string | null
closedDate?: Date | string | null
closedFlag?: boolean
closedByName?: string | null
closedByCwId?: number | null
companyId?: string | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
cwDateEntered?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
}
export type OpportunityCreateOrConnectWithoutGeneratedQuotesInput = {
where: Prisma.OpportunityWhereUniqueInput
create: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
}
export type OpportunityUpsertWithoutGeneratedQuotesInput = {
update: Prisma.XOR<Prisma.OpportunityUpdateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedUpdateWithoutGeneratedQuotesInput>
create: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
where?: Prisma.OpportunityWhereInput
}
export type OpportunityUpdateToOneWithWhereWithoutGeneratedQuotesInput = {
where?: Prisma.OpportunityWhereInput
data: Prisma.XOR<Prisma.OpportunityUpdateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedUpdateWithoutGeneratedQuotesInput>
}
export type OpportunityUpdateWithoutGeneratedQuotesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
cwOpportunityId?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
typeName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
typeCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
stageName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
stageCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
statusName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
statusCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
priorityName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
priorityCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
ratingName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ratingCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
source?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
campaignName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
campaignCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
primarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
primarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
primarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
secondarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
secondarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
secondarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
contactCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
contactName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
siteCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
departmentCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
expectedCloseDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pipelineChangeDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
dateBecameLead?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
closedDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
}
export type OpportunityUncheckedUpdateWithoutGeneratedQuotesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
cwOpportunityId?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
typeName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
typeCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
stageName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
stageCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
statusName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
statusCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
priorityName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
priorityCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
ratingName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ratingCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
source?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
campaignName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
campaignCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
primarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
primarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
primarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
secondarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
secondarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
secondarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
contactCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
contactName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
siteCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
departmentCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
expectedCloseDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pipelineChangeDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
dateBecameLead?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
closedDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
export type OpportunityCreateManyCompanyInput = {
id?: string
cwOpportunityId: number
@@ -1643,6 +1951,7 @@ export type OpportunityCreateManyCompanyInput = {
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
@@ -1656,6 +1965,7 @@ export type OpportunityCreateManyCompanyInput = {
closedByCwId?: number | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
cwDateEntered?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -1692,6 +2002,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1705,8 +2016,10 @@ export type OpportunityUpdateWithoutCompanyInput = {
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
}
export type OpportunityUncheckedUpdateWithoutCompanyInput = {
@@ -1741,6 +2054,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1754,8 +2068,10 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
}
export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
@@ -1790,6 +2106,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1803,11 +2120,41 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
/**
* Count Type OpportunityCountOutputType
*/
export type OpportunityCountOutputType = {
generatedQuotes: number
}
export type OpportunityCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
generatedQuotes?: boolean | OpportunityCountOutputTypeCountGeneratedQuotesArgs
}
/**
* OpportunityCountOutputType without action
*/
export type OpportunityCountOutputTypeDefaultArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the OpportunityCountOutputType
*/
select?: Prisma.OpportunityCountOutputTypeSelect<ExtArgs> | null
}
/**
* OpportunityCountOutputType without action
*/
export type OpportunityCountOutputTypeCountGeneratedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.GeneratedQuotesWhereInput
}
export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@@ -1841,6 +2188,7 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
siteName?: boolean
customerPO?: boolean
totalSalesTax?: boolean
probability?: boolean
locationName?: boolean
locationCwId?: boolean
departmentName?: boolean
@@ -1855,9 +2203,12 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
companyId?: boolean
productSequence?: boolean
cwLastUpdated?: boolean
cwDateEntered?: boolean
createdAt?: boolean
updatedAt?: boolean
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
_count?: boolean | Prisma.OpportunityCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["opportunity"]>
export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@@ -1892,6 +2243,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
siteName?: boolean
customerPO?: boolean
totalSalesTax?: boolean
probability?: boolean
locationName?: boolean
locationCwId?: boolean
departmentName?: boolean
@@ -1906,6 +2258,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
companyId?: boolean
productSequence?: boolean
cwLastUpdated?: boolean
cwDateEntered?: boolean
createdAt?: boolean
updatedAt?: boolean
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
@@ -1943,6 +2296,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
siteName?: boolean
customerPO?: boolean
totalSalesTax?: boolean
probability?: boolean
locationName?: boolean
locationCwId?: boolean
departmentName?: boolean
@@ -1957,6 +2311,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
companyId?: boolean
productSequence?: boolean
cwLastUpdated?: boolean
cwDateEntered?: boolean
createdAt?: boolean
updatedAt?: boolean
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
@@ -1994,6 +2349,7 @@ export type OpportunitySelectScalar = {
siteName?: boolean
customerPO?: boolean
totalSalesTax?: boolean
probability?: boolean
locationName?: boolean
locationCwId?: boolean
departmentName?: boolean
@@ -2008,13 +2364,16 @@ export type OpportunitySelectScalar = {
companyId?: boolean
productSequence?: boolean
cwLastUpdated?: boolean
cwDateEntered?: boolean
createdAt?: boolean
updatedAt?: boolean
}
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "productSequence" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "probability" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "productSequence" | "cwLastUpdated" | "cwDateEntered" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
export type OpportunityInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
_count?: boolean | Prisma.OpportunityCountOutputTypeDefaultArgs<ExtArgs>
}
export type OpportunityIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
@@ -2026,6 +2385,7 @@ export type OpportunityIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.
export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "Opportunity"
objects: {
generatedQuotes: Prisma.$GeneratedQuotesPayload<ExtArgs>[]
company: Prisma.$CompanyPayload<ExtArgs> | null
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
@@ -2060,6 +2420,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
siteName: string | null
customerPO: string | null
totalSalesTax: number
probability: number
locationName: string | null
locationCwId: number | null
departmentName: string | null
@@ -2074,6 +2435,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
companyId: string | null
productSequence: number[]
cwLastUpdated: Date | null
cwDateEntered: Date | null
createdAt: Date
updatedAt: Date
}, ExtArgs["result"]["opportunity"]>
@@ -2470,6 +2832,7 @@ readonly fields: OpportunityFieldRefs;
*/
export interface Prisma__OpportunityClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
generatedQuotes<T extends Prisma.Opportunity$generatedQuotesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Opportunity$generatedQuotesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$GeneratedQuotesPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
company<T extends Prisma.Opportunity$companyArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Opportunity$companyArgs<ExtArgs>>): Prisma.Prisma__CompanyClient<runtime.Types.Result.GetResult<Prisma.$CompanyPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
@@ -2531,6 +2894,7 @@ export interface OpportunityFieldRefs {
readonly siteName: Prisma.FieldRef<"Opportunity", 'String'>
readonly customerPO: Prisma.FieldRef<"Opportunity", 'String'>
readonly totalSalesTax: Prisma.FieldRef<"Opportunity", 'Float'>
readonly probability: Prisma.FieldRef<"Opportunity", 'Float'>
readonly locationName: Prisma.FieldRef<"Opportunity", 'String'>
readonly locationCwId: Prisma.FieldRef<"Opportunity", 'Int'>
readonly departmentName: Prisma.FieldRef<"Opportunity", 'String'>
@@ -2545,6 +2909,7 @@ export interface OpportunityFieldRefs {
readonly companyId: Prisma.FieldRef<"Opportunity", 'String'>
readonly productSequence: Prisma.FieldRef<"Opportunity", 'Int[]'>
readonly cwLastUpdated: Prisma.FieldRef<"Opportunity", 'DateTime'>
readonly cwDateEntered: Prisma.FieldRef<"Opportunity", 'DateTime'>
readonly createdAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
}
@@ -2942,6 +3307,30 @@ export type OpportunityDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.I
limit?: number
}
/**
* Opportunity.generatedQuotes
*/
export type Opportunity$generatedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the GeneratedQuotes
*/
select?: Prisma.GeneratedQuotesSelect<ExtArgs> | null
/**
* Omit specific fields from the GeneratedQuotes
*/
omit?: Prisma.GeneratedQuotesOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.GeneratedQuotesInclude<ExtArgs> | null
where?: Prisma.GeneratedQuotesWhereInput
orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[]
cursor?: Prisma.GeneratedQuotesWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[]
}
/**
* Opportunity.company
*/
+157
View File
@@ -240,6 +240,7 @@ export type UserWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
roles?: Prisma.RoleListRelationFilter
sessions?: Prisma.SessionListRelationFilter
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
}
export type UserOrderByWithRelationInput = {
@@ -257,6 +258,7 @@ export type UserOrderByWithRelationInput = {
updatedAt?: Prisma.SortOrder
roles?: Prisma.RoleOrderByRelationAggregateInput
sessions?: Prisma.SessionOrderByRelationAggregateInput
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
}
export type UserWhereUniqueInput = Prisma.AtLeast<{
@@ -277,6 +279,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
roles?: Prisma.RoleListRelationFilter
sessions?: Prisma.SessionListRelationFilter
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
}, "id" | "login" | "email" | "userId">
export type UserOrderByWithAggregationInput = {
@@ -330,6 +333,7 @@ export type UserCreateInput = {
updatedAt?: Date | string
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
}
export type UserUncheckedCreateInput = {
@@ -347,6 +351,7 @@ export type UserUncheckedCreateInput = {
updatedAt?: Date | string
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
}
export type UserUpdateInput = {
@@ -364,6 +369,7 @@ export type UserUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
}
export type UserUncheckedUpdateInput = {
@@ -381,6 +387,7 @@ export type UserUncheckedUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
}
export type UserCreateManyInput = {
@@ -488,6 +495,11 @@ export type UserOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder
}
export type UserNullableScalarRelationFilter = {
is?: Prisma.UserWhereInput | null
isNot?: Prisma.UserWhereInput | null
}
export type UserCreateNestedOneWithoutSessionsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutSessionsInput, Prisma.UserUncheckedCreateWithoutSessionsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutSessionsInput
@@ -544,6 +556,22 @@ export type UserUncheckedUpdateManyWithoutRolesNestedInput = {
deleteMany?: Prisma.UserScalarWhereInput | Prisma.UserScalarWhereInput[]
}
export type UserCreateNestedOneWithoutGeneratedQuotesInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutGeneratedQuotesInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneWithoutGeneratedQuotesNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutGeneratedQuotesInput
upsert?: Prisma.UserUpsertWithoutGeneratedQuotesInput
disconnect?: Prisma.UserWhereInput | boolean
delete?: Prisma.UserWhereInput | boolean
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutGeneratedQuotesInput, Prisma.UserUpdateWithoutGeneratedQuotesInput>, Prisma.UserUncheckedUpdateWithoutGeneratedQuotesInput>
}
export type UserCreateWithoutSessionsInput = {
id?: string
permissions?: string | null
@@ -558,6 +586,7 @@ export type UserCreateWithoutSessionsInput = {
createdAt?: Date | string
updatedAt?: Date | string
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
}
export type UserUncheckedCreateWithoutSessionsInput = {
@@ -574,6 +603,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
createdAt?: Date | string
updatedAt?: Date | string
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
}
export type UserCreateOrConnectWithoutSessionsInput = {
@@ -606,6 +636,7 @@ export type UserUpdateWithoutSessionsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
}
export type UserUncheckedUpdateWithoutSessionsInput = {
@@ -622,6 +653,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
}
export type UserCreateWithoutRolesInput = {
@@ -638,6 +670,7 @@ export type UserCreateWithoutRolesInput = {
createdAt?: Date | string
updatedAt?: Date | string
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
}
export type UserUncheckedCreateWithoutRolesInput = {
@@ -654,6 +687,7 @@ export type UserUncheckedCreateWithoutRolesInput = {
createdAt?: Date | string
updatedAt?: Date | string
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
}
export type UserCreateOrConnectWithoutRolesInput = {
@@ -695,6 +729,90 @@ export type UserScalarWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
}
export type UserCreateWithoutGeneratedQuotesInput = {
id?: string
permissions?: string | null
login: string
name?: string | null
email: string
emailVerified?: Date | string | null
image?: string | null
cwIdentifier?: string | null
userId: string
token?: string | null
createdAt?: Date | string
updatedAt?: Date | string
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutGeneratedQuotesInput = {
id?: string
permissions?: string | null
login: string
name?: string | null
email: string
emailVerified?: Date | string | null
image?: string | null
cwIdentifier?: string | null
userId: string
token?: string | null
createdAt?: Date | string
updatedAt?: Date | string
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutGeneratedQuotesInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
}
export type UserUpsertWithoutGeneratedQuotesInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutGeneratedQuotesInput, Prisma.UserUncheckedUpdateWithoutGeneratedQuotesInput>
create: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutGeneratedQuotesInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutGeneratedQuotesInput, Prisma.UserUncheckedUpdateWithoutGeneratedQuotesInput>
}
export type UserUpdateWithoutGeneratedQuotesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
login?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
userId?: Prisma.StringFieldUpdateOperationsInput | string
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutGeneratedQuotesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
login?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
userId?: Prisma.StringFieldUpdateOperationsInput | string
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
}
export type UserUpdateWithoutRolesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -709,6 +827,7 @@ export type UserUpdateWithoutRolesInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
}
export type UserUncheckedUpdateWithoutRolesInput = {
@@ -725,6 +844,7 @@ export type UserUncheckedUpdateWithoutRolesInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
}
export type UserUncheckedUpdateManyWithoutRolesInput = {
@@ -750,11 +870,13 @@ export type UserUncheckedUpdateManyWithoutRolesInput = {
export type UserCountOutputType = {
roles: number
sessions: number
generatedQuotes: number
}
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
roles?: boolean | UserCountOutputTypeCountRolesArgs
sessions?: boolean | UserCountOutputTypeCountSessionsArgs
generatedQuotes?: boolean | UserCountOutputTypeCountGeneratedQuotesArgs
}
/**
@@ -781,6 +903,13 @@ export type UserCountOutputTypeCountSessionsArgs<ExtArgs extends runtime.Types.E
where?: Prisma.SessionWhereInput
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountGeneratedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.GeneratedQuotesWhereInput
}
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@@ -797,6 +926,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
updatedAt?: boolean
roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]>
@@ -849,6 +979,7 @@ export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = run
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
@@ -859,6 +990,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
objects: {
roles: Prisma.$RolePayload<ExtArgs>[]
sessions: Prisma.$SessionPayload<ExtArgs>[]
generatedQuotes: Prisma.$GeneratedQuotesPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@@ -1269,6 +1401,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
readonly [Symbol.toStringTag]: "PrismaPromise"
roles<T extends Prisma.User$rolesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$rolesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$RolePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
sessions<T extends Prisma.User$sessionsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$sessionsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SessionPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
generatedQuotes<T extends Prisma.User$generatedQuotesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$generatedQuotesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$GeneratedQuotesPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1745,6 +1878,30 @@ export type User$sessionsArgs<ExtArgs extends runtime.Types.Extensions.InternalA
distinct?: Prisma.SessionScalarFieldEnum | Prisma.SessionScalarFieldEnum[]
}
/**
* User.generatedQuotes
*/
export type User$generatedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the GeneratedQuotes
*/
select?: Prisma.GeneratedQuotesSelect<ExtArgs> | null
/**
* Omit specific fields from the GeneratedQuotes
*/
omit?: Prisma.GeneratedQuotesOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.GeneratedQuotesInclude<ExtArgs> | null
where?: Prisma.GeneratedQuotesWhereInput
orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[]
cursor?: Prisma.GeneratedQuotesWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[]
}
/**
* User without action
*/
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+4
View File
@@ -28,6 +28,8 @@
"utils:gen_private_keys": "bun ./utils/genPrivateKeys",
"utils:create_admin_role": "bun ./utils/createAdminRole",
"utils:assign_user_role": "bun ./utils/assignUserRole",
"utils:test_webserver": "bun ./utils/testWebserver.ts",
"utils:test_adjustments_poll": "bun ./utils/testAdjustmentsPoll.ts",
"utils:analyze_cw": "python3 debug-scripts/analyze-cw-calls.py",
"db:check": "bunx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --shadow-database-url $DATABASE_URL --exit-code"
},
@@ -46,6 +48,8 @@
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
"keypair": "^1.0.4",
"pdf-lib": "^1.17.1",
"pdfmake": "^0.3.5",
"prisma": "^7.3.0",
"socket.io": "^4.8.3",
"zod": "^4.3.6",
@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "GeneratedQuotes" (
"id" TEXT NOT NULL,
"quoteFile" BYTEA NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GeneratedQuotes_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,19 @@
/*
Warnings:
- Added the required column `opportunityId` to the `GeneratedQuotes` table without a default value. This is not possible if the table is not empty.
- Added the required column `quoteFileName` to the `GeneratedQuotes` table without a default value. This is not possible if the table is not empty.
- Added the required column `quoteRegenData` to the `GeneratedQuotes` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "GeneratedQuotes" ADD COLUMN "createdById" TEXT,
ADD COLUMN "opportunityId" TEXT NOT NULL,
ADD COLUMN "quoteFileName" TEXT NOT NULL,
ADD COLUMN "quoteRegenData" JSONB NOT NULL;
-- AddForeignKey
ALTER TABLE "GeneratedQuotes" ADD CONSTRAINT "GeneratedQuotes_opportunityId_fkey" FOREIGN KEY ("opportunityId") REFERENCES "Opportunity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GeneratedQuotes" ADD CONSTRAINT "GeneratedQuotes_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable: Opportunity
ALTER TABLE "Opportunity" ADD COLUMN "probability" DOUBLE PRECISION NOT NULL DEFAULT 0;
@@ -0,0 +1,10 @@
-- AlterTable: GeneratedQuotes — add columns missing from prior db push
ALTER TABLE "GeneratedQuotes" ADD COLUMN "quoteRegenParams" JSONB NOT NULL DEFAULT '{}';
ALTER TABLE "GeneratedQuotes" ADD COLUMN "quoteRegenHash" TEXT NOT NULL DEFAULT '';
ALTER TABLE "GeneratedQuotes" ADD COLUMN "downloads" JSONB NOT NULL DEFAULT '[]';
-- AlterTable: GeneratedQuotes — set default on existing quoteRegenData column
ALTER TABLE "GeneratedQuotes" ALTER COLUMN "quoteRegenData" SET DEFAULT '{}';
-- CreateIndex
CREATE UNIQUE INDEX "GeneratedQuotes_quoteRegenHash_key" ON "GeneratedQuotes"("quoteRegenHash");
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "CwMember" (
"id" TEXT NOT NULL,
"cwMemberId" INTEGER NOT NULL,
"identifier" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"officeEmail" TEXT,
"inactiveFlag" BOOLEAN NOT NULL DEFAULT false,
"apiKey" TEXT,
"cwLastUpdated" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CwMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CwMember_cwMemberId_key" ON "CwMember"("cwMemberId");
-- CreateIndex
CREATE UNIQUE INDEX "CwMember_identifier_key" ON "CwMember"("identifier");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Opportunity" ADD COLUMN "cwDateEntered" TIMESTAMP(3);
+44
View File
@@ -43,6 +43,7 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
generatedQuotes GeneratedQuotes[]
}
model Role {
@@ -130,6 +131,8 @@ model Opportunity {
name String
notes String?
generatedQuotes GeneratedQuotes[]
// Stage / status / priority / type / rating stored as JSON references
// so we don't need separate lookup tables for CW enums
typeName String?
@@ -165,6 +168,7 @@ model Opportunity {
// Financials
totalSalesTax Float @default(0)
probability Float @default(0)
// Location / department
locationName String?
@@ -190,6 +194,7 @@ model Opportunity {
productSequence Int[] @default([])
cwLastUpdated DateTime?
cwDateEntered DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -244,3 +249,42 @@ model Credential {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model GeneratedQuotes {
id String @id @default(uuid())
quoteRegenData Json @default("{}") // Store any additional data needed for quote regeneration, such as product details, pricing, etc.
quoteRegenParams Json @default("{}") // Store parameters used for quote regeneration, such as template ID, formatting options, etc.
quoteRegenHash String @unique @default("")
downloads Json @default("[]") // Array of download records with timestamp and user info
quoteFile Bytes
quoteFileName String
opportunityId String
opportunity Opportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade)
createdById String?
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CwMember {
id String @id @default(cuid())
cwMemberId Int @unique
identifier String @unique
firstName String
lastName String
officeEmail String?
inactiveFlag Boolean @default(false)
apiKey String?
cwLastUpdated DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+25
View File
@@ -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"],
}),
);
+2 -1
View File
@@ -1,7 +1,8 @@
import { default as fetchAll } from "./fetchAll";
import { default as fetch } from "./[id]/fetch";
import { default as configurations } from "./[id]/configurations";
import { default as sites } from "./[id]/sites";
import { default as unifiSites } from "./[id]/unifiSites";
import { default as count } from "./count";
export { configurations, count, fetch, fetchAll, unifiSites };
export { configurations, count, fetch, fetchAll, sites, unifiSites };
+164
View File
@@ -0,0 +1,164 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { z } from "zod";
import GenericError from "../../Errors/GenericError";
type ParsedJson = Record<string, unknown> | unknown[];
const callbackResource = z.enum([
"opportunity",
"ticket",
"company",
"activity",
]);
const safeParseJson = (value: string): ParsedJson | null => {
try {
const parsed = JSON.parse(value);
const isObject = typeof parsed === "object" && parsed !== null;
return isObject ? (parsed as ParsedJson) : null;
} catch {
return null;
}
};
const asObject = (value: ParsedJson | null): Record<string, unknown> | null => {
if (!value) return null;
if (Array.isArray(value)) return null;
return value;
};
const parseJsonStringFields = (
value: Record<string, unknown> | null,
): Record<string, unknown> | null => {
if (!value) return null;
return Object.entries(value).reduce<Record<string, unknown>>(
(acc, [key, current]) => {
if (typeof current !== "string") {
acc[key] = current;
return acc;
}
const looksLikeJson = current.startsWith("{") || current.startsWith("[");
if (!looksLikeJson) {
acc[key] = current;
return acc;
}
const parsed = safeParseJson(current);
acc[key] = parsed ?? current;
return acc;
},
{},
);
};
const parseEntity = (value: unknown): ParsedJson | null => {
if (typeof value === "string") return safeParseJson(value);
if (typeof value !== "object" || value === null) return null;
return value as ParsedJson;
};
const buildSummary = (
resource: z.infer<typeof callbackResource>,
parsedBody: Record<string, unknown> | null,
parsedEntity: Record<string, unknown> | null,
) => {
if (!parsedBody) return null;
return {
resource,
messageId: parsedBody.MessageId ?? null,
action: parsedBody.Action ?? null,
type: parsedBody.Type ?? null,
id: parsedBody.ID ?? null,
memberId: parsedBody.MemberId ?? null,
entityStatus:
parsedEntity?.StatusName ??
parsedEntity?.TicketStatus ??
parsedEntity?.Status ??
null,
entitySummary: parsedEntity?.Summary ?? parsedEntity?.CompanyName ?? null,
entityUpdatedBy: parsedEntity?.UpdatedBy ?? null,
entityLastUpdated:
parsedEntity?.LastUpdatedUTC ?? parsedEntity?.LastUpdated ?? null,
};
};
const parseHeaders = (headers: Headers): Record<string, string> =>
Object.fromEntries(headers.entries());
const callbackHeaderSummary = (headers: Record<string, string>) => ({
contentType: headers["content-type"] ?? null,
userAgent: headers["user-agent"] ?? null,
host: headers.host ?? null,
forwardedFor: headers["x-forwarded-for"] ?? null,
callbackId:
headers["x-cw-request-id"] ??
headers["x-request-id"] ??
headers["x-correlation-id"] ??
null,
});
/* /v1/cw/callback/:resource */
export default createRoute("post", ["/callback/:secret/:resource"], async (c) => {
const suppliedSecret = c.req.param("secret");
const expectedSecret = process.env.CW_CALLBACK_SECRET;
if (expectedSecret && suppliedSecret !== expectedSecret) {
throw new GenericError({
name: "Unauthorized",
message: "Invalid callback secret.",
cause: "Path secret mismatch",
status: 401,
});
}
if (!expectedSecret) {
console.warn(
"[cw-callback] CW_CALLBACK_SECRET is not configured; accepting path secret without verification",
);
}
const resource = callbackResource.parse(c.req.param("resource"));
const headers = parseHeaders(c.req.raw.headers);
const headerSummary = callbackHeaderSummary(headers);
const rawBody = await c.req.text();
const parsedJson = safeParseJson(rawBody);
const parsedBody = asObject(parsedJson);
const parsedBodyExpanded = parseJsonStringFields(parsedBody);
const parsedEntity = asObject(parseEntity(parsedBodyExpanded?.Entity));
const summary = buildSummary(resource, parsedBodyExpanded, parsedEntity);
const line = [
`[cw-callback] resource=${resource}`,
`action=${String(summary?.action ?? "-")}`,
`type=${String(summary?.type ?? "-")}`,
`id=${String(summary?.id ?? "-")}`,
`by=${String(summary?.entityUpdatedBy ?? summary?.memberId ?? "-")}`,
`requestId=${String(headerSummary.callbackId ?? "-")}`,
`status=${String(summary?.entityStatus ?? "-")}`,
`summary=${String(summary?.entitySummary ?? "-")}`,
].join(" ");
console.log(line);
const response = apiResponse.successful("CW callback received.", {
resource,
secretValidated: Boolean(expectedSecret),
summary,
headers,
headerSummary,
bodyParsed: parsedBodyExpanded,
receivedAt: new Date().toISOString(),
});
return c.json(response, response.status as ContentfulStatusCode);
});
+37
View File
@@ -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(),
);
+4
View File
@@ -0,0 +1,4 @@
import { default as callback } from "./callback";
import { default as fetchMembers } from "./fetchMembers";
export { callback, fetchMembers };
+7
View File
@@ -0,0 +1,7 @@
import { Hono } from "hono";
import * as cwRoutes from "../cw";
const cwRouter = new Hono();
Object.values(cwRoutes).map((r) => cwRouter.route("/", r));
export default cwRouter;
+133 -15
View File
@@ -4,11 +4,26 @@ import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
import GenericError from "../../../Errors/GenericError";
import { prisma } from "../../../constants";
import { computeSubResourceCacheTTL } from "../../../modules/algorithms/computeSubResourceCacheTTL";
import { computeProductsCacheTTL } from "../../../modules/algorithms/computeProductsCacheTTL";
import {
getCachedSite,
getCachedNotes,
getCachedContacts,
getCachedProducts,
fetchAndCacheNotes,
fetchAndCacheContacts,
fetchAndCacheProducts,
fetchAndCacheSite,
} from "../../../modules/cache/opportunityCache";
import { generatedQuotes } from "../../../managers/generatedQuotes";
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products */
/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */
export default createRoute(
"get",
["/opportunities/:identifier"],
["/opportunities/opportunity/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const includeParam = c.req.query("include") ?? "";
@@ -19,19 +34,102 @@ export default createRoute(
.filter(Boolean),
);
const item = await opportunities.fetchItem(identifier);
// ── Quick DB lookup (≈3ms) to get cwOpportunityId for pre-warming ──
const isNumeric = /^\d+$/.test(identifier);
const dbRecord = await prisma.opportunity.findFirst({
where: isNumeric
? { cwOpportunityId: Number(identifier) }
: { id: identifier },
select: {
cwOpportunityId: true,
companyCwId: true,
siteCwId: true,
closedFlag: true,
closedDate: true,
expectedCloseDate: true,
cwLastUpdated: true,
statusCwId: true,
},
});
// Eagerly load site data so toJson() includes full site info
await item.fetchSite();
if (!dbRecord) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
const gatedData = await processObjectValuePerms(
item.toJson(),
"obj.opportunity",
c.get("user"),
// Compute TTLs from DB state
const subTtl = computeSubResourceCacheTTL({
closedFlag: dbRecord.closedFlag,
closedDate: dbRecord.closedDate,
expectedCloseDate: dbRecord.expectedCloseDate,
lastUpdated: dbRecord.cwLastUpdated,
});
const prodTtl = computeProductsCacheTTL({
closedFlag: dbRecord.closedFlag,
closedDate: dbRecord.closedDate,
expectedCloseDate: dbRecord.expectedCloseDate,
lastUpdated: dbRecord.cwLastUpdated,
statusCwId: dbRecord.statusCwId,
});
// ── Pre-warm sub-resources only on cache miss ───────────────────────
// Check Redis first — if the background refresh has kept the keys warm,
// skip the CW calls entirely. Only fetch-and-cache on a miss.
const cwOppId = dbRecord.cwOpportunityId;
const _ignoreErrors = (p: Promise<any>) => p.catch(() => {});
const prewarmPromises: Promise<any>[] = [];
if (dbRecord.companyCwId && dbRecord.siteCwId) {
const compId = dbRecord.companyCwId,
siteId = dbRecord.siteCwId;
prewarmPromises.push(
_ignoreErrors(
getCachedSite(compId, siteId).then(
(c) => c ?? fetchAndCacheSite(compId, siteId),
),
),
);
}
if (includes.has("notes") && subTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedNotes(cwOppId).then(
(c) => c ?? fetchAndCacheNotes(cwOppId, subTtl),
),
),
);
if (includes.has("contacts") && subTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedContacts(cwOppId).then(
(c) => c ?? fetchAndCacheContacts(cwOppId, subTtl),
),
),
);
if (includes.has("products") && prodTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedProducts(cwOppId).then(
(c) => c ?? fetchAndCacheProducts(cwOppId, prodTtl),
),
),
);
// Fetch requested sub-resources in parallel
const subResourcePromises: Record<string, Promise<any>> = {};
// fetchItem runs its own CW calls (opp, activities, company) —
// these execute concurrently with the sub-resource pre-warming above.
const [item] = await Promise.all([
opportunities.fetchItem(identifier),
...prewarmPromises,
]);
// Sub-resources now hit warm Redis cache (near-instant)
const subResourcePromises: Record<string, Promise<any>> = {
_site: item.fetchSite(),
};
if (includes.has("notes")) {
subResourcePromises.notes = item.fetchNotes();
}
@@ -43,22 +141,42 @@ export default createRoute(
.fetchProducts()
.then((products) => products.map((p) => p.toJson()));
}
if (includes.has("quotes")) {
subResourcePromises.quotes = generatedQuotes
.fetchByOpportunity(item.id)
.then((quotes) => quotes.map((q) => q.toJson()));
}
const keys = Object.keys(subResourcePromises);
if (keys.length > 0) {
const results = await Promise.all(
keys.map((k) => subResourcePromises[k]),
const results = await Promise.all(keys.map((k) => subResourcePromises[k]));
// Apply toJson after site is hydrated (side-effect from fetchSite)
const gatedData = await processObjectValuePerms(
item.toJson(),
"obj.opportunity",
c.get("user"),
);
const originalOpportunityNoteText = (gatedData as any).notes;
// Attach sub-resources (skip the internal _site key)
keys.forEach((k, i) => {
if (k !== "_site") {
(gatedData as any)[k] = results[i];
}
});
if (includes.has("notes")) {
(gatedData as any).opportunityNoteText =
typeof originalOpportunityNoteText === "string"
? originalOpportunityNoteText
: null;
}
const response = apiResponse.successful(
"Opportunity fetched successfully!",
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
+53 -13
View File
@@ -1,31 +1,71 @@
import { default as fetchAll } from "./fetchAll";
import { default as fetchAll } from "./opportunities/fetchAll";
import { default as metrics } from "./opportunities/metrics";
import { default as createOpportunity } from "./opportunities/create";
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
import { default as count } from "./count";
import { default as fetch } from "./[id]/fetch";
import { default as refresh } from "./[id]/refresh";
import { default as products } from "./[id]/products";
import { default as addProduct } from "./[id]/addProduct";
import { default as resequenceProducts } from "./[id]/resequenceProducts";
import { default as notes } from "./[id]/notes";
import { default as fetchNote } from "./[id]/fetchNote";
import { default as createNote } from "./[id]/createNote";
import { default as updateNote } from "./[id]/updateNote";
import { default as deleteNote } from "./[id]/deleteNote";
import { default as contacts } from "./[id]/contacts";
import { default as count } from "./opportunities/count";
import { default as fetch } from "./opportunities/[id]/fetch";
import { default as refresh } from "./opportunities/[id]/refresh";
import { default as updateOpportunity } from "./opportunities/[id]/update";
import { default as deleteOpportunity } from "./opportunities/[id]/delete";
import { default as products } from "./opportunities/[id]/products/fetchAll";
import { default as addProduct } from "./opportunities/[id]/products/add";
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
import { default as addLabor } from "./opportunities/[id]/products/addLabor";
import { default as laborOptions } from "./opportunities/[id]/products/laborOptions";
import { default as resequenceProducts } from "./opportunities/[id]/products/resequence";
import { default as updateProduct } from "./opportunities/[id]/products/update";
import { default as cancelProduct } from "./opportunities/[id]/products/cancel";
import { default as deleteProduct } from "./opportunities/[id]/products/delete";
import { default as notes } from "./opportunities/[id]/notes/fetchAll";
import { default as fetchNote } from "./opportunities/[id]/notes/fetch";
import { default as createNote } from "./opportunities/[id]/notes/create";
import { default as updateNote } from "./opportunities/[id]/notes/update";
import { default as deleteNote } from "./opportunities/[id]/notes/delete";
import { default as contacts } from "./opportunities/[id]/contacts";
import { default as commitQuote } from "./opportunities/[id]/quotes/commit";
import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll";
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
import { default as fetchByUser } from "./opportunities/fetchByUser";
import { default as fetchByUserId } from "./opportunities/fetchByUserId";
import { default as workflowDispatch } from "./opportunities/[id]/workflow/dispatch";
import { default as workflowStatus } from "./opportunities/[id]/workflow/status";
import { default as workflowHistory } from "./opportunities/[id]/workflow/history";
export {
addProduct,
fetchByUser,
fetchByUserId,
addLabor,
laborOptions,
addSpecialOrderProduct,
count,
createOpportunity,
deleteOpportunity,
metrics,
fetch,
fetchAll,
fetchOpportunityTypes,
products,
resequenceProducts,
updateProduct,
cancelProduct,
deleteProduct,
notes,
fetchNote,
createNote,
updateNote,
deleteNote,
contacts,
commitQuote,
fetchQuotes,
previewQuote,
downloadQuote,
fetchDownloads,
refresh,
updateOpportunity,
workflowDispatch,
workflowStatus,
workflowHistory,
};
@@ -1,13 +1,13 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { authMiddleware } from "../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/contacts */
/* GET /v1/sales/opportunities/opportunity/:identifier/contacts */
export default createRoute(
"get",
["/opportunities/:identifier/contacts"],
["/opportunities/opportunity/:identifier/contacts"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchRecord(identifier);
@@ -0,0 +1,50 @@
import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
import GenericError from "../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/opportunity/:identifier */
export default createRoute(
"delete",
["/opportunities/opportunity/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
try {
await opportunities.deleteItem(identifier);
const response = apiResponse.successful(
"Opportunity deleted successfully!",
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
const isAxios =
err != null && typeof err === "object" && "isAxiosError" in err;
if (isAxios) {
const axiosErr = err as any;
const cwStatus: number = axiosErr.response?.status ?? 502;
const cwMessage: string =
axiosErr.response?.data?.message ??
"Failed to delete the opportunity in ConnectWise";
return c.json(
{
status: cwStatus,
message: cwMessage,
error: "ConnectWiseDeleteError",
successful: false,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
);
}
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.delete"] }),
);
+187
View File
@@ -0,0 +1,187 @@
import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
import { processObjectValuePerms } from "../../../../modules/permission-utils/processObjectPermissions";
import GenericError from "../../../../Errors/GenericError";
import { prisma } from "../../../../constants";
import { computeSubResourceCacheTTL } from "../../../../modules/algorithms/computeSubResourceCacheTTL";
import { computeProductsCacheTTL } from "../../../../modules/algorithms/computeProductsCacheTTL";
import {
getCachedSite,
getCachedNotes,
getCachedContacts,
getCachedProducts,
fetchAndCacheNotes,
fetchAndCacheContacts,
fetchAndCacheProducts,
fetchAndCacheSite,
} from "../../../../modules/cache/opportunityCache";
import { generatedQuotes } from "../../../../managers/generatedQuotes";
/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */
export default createRoute(
"get",
["/opportunities/opportunity/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const includeParam = c.req.query("include") ?? "";
const includes = new Set(
includeParam
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
// ── Quick DB lookup (≈3ms) to get cwOpportunityId for pre-warming ──
const isNumeric = /^\d+$/.test(identifier);
const dbRecord = await prisma.opportunity.findFirst({
where: isNumeric
? { cwOpportunityId: Number(identifier) }
: { id: identifier },
select: {
cwOpportunityId: true,
companyCwId: true,
siteCwId: true,
closedFlag: true,
closedDate: true,
expectedCloseDate: true,
cwLastUpdated: true,
statusCwId: true,
},
});
if (!dbRecord) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
// Compute TTLs from DB state
const subTtl = computeSubResourceCacheTTL({
closedFlag: dbRecord.closedFlag,
closedDate: dbRecord.closedDate,
expectedCloseDate: dbRecord.expectedCloseDate,
lastUpdated: dbRecord.cwLastUpdated,
});
const prodTtl = computeProductsCacheTTL({
closedFlag: dbRecord.closedFlag,
closedDate: dbRecord.closedDate,
expectedCloseDate: dbRecord.expectedCloseDate,
lastUpdated: dbRecord.cwLastUpdated,
statusCwId: dbRecord.statusCwId,
});
// ── Pre-warm sub-resources only on cache miss ───────────────────────
// Check Redis first — if the background refresh has kept the keys warm,
// skip the CW calls entirely. Only fetch-and-cache on a miss.
const cwOppId = dbRecord.cwOpportunityId;
const _ignoreErrors = (p: Promise<any>) => p.catch(() => {});
const prewarmPromises: Promise<any>[] = [];
if (dbRecord.companyCwId && dbRecord.siteCwId) {
const compId = dbRecord.companyCwId,
siteId = dbRecord.siteCwId;
prewarmPromises.push(
_ignoreErrors(
getCachedSite(compId, siteId).then(
(c) => c ?? fetchAndCacheSite(compId, siteId),
),
),
);
}
if (includes.has("notes") && subTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedNotes(cwOppId).then(
(c) => c ?? fetchAndCacheNotes(cwOppId, subTtl),
),
),
);
if (includes.has("contacts") && subTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedContacts(cwOppId).then(
(c) => c ?? fetchAndCacheContacts(cwOppId, subTtl),
),
),
);
if (includes.has("products") && prodTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedProducts(cwOppId).then(
(c) => c ?? fetchAndCacheProducts(cwOppId, prodTtl),
),
),
);
// fetchItem runs its own CW calls (opp, activities, company) —
// these execute concurrently with the sub-resource pre-warming above.
const [item] = await Promise.all([
opportunities.fetchItem(identifier),
...prewarmPromises,
]);
// Sub-resources now hit warm Redis cache (near-instant)
const subResourcePromises: Record<string, Promise<any>> = {
_site: item.fetchSite(),
};
if (includes.has("notes")) {
subResourcePromises.notes = item.fetchNotes();
}
if (includes.has("contacts")) {
subResourcePromises.contacts = item.fetchContacts();
}
if (includes.has("products")) {
subResourcePromises.products = item
.fetchProducts()
.then((products) => products.map((p) => p.toJson()));
}
if (includes.has("quotes")) {
const includeRegenData = c.req.query("includeRegenData") === "true";
const includeRegenParams = c.req.query("includeRegenParams") === "true";
subResourcePromises.quotes = generatedQuotes
.fetchByOpportunity(item.id)
.then((quotes) =>
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams })),
);
}
const keys = Object.keys(subResourcePromises);
const results = await Promise.all(keys.map((k) => subResourcePromises[k]));
// Apply toJson after site is hydrated (side-effect from fetchSite)
const gatedData = await processObjectValuePerms(
item.toJson(),
"obj.opportunity",
c.get("user"),
);
const originalOpportunityNoteText = (gatedData as any).notes;
// Attach sub-resources (skip the internal _site key)
keys.forEach((k, i) => {
if (k !== "_site") {
(gatedData as any)[k] = results[i];
}
});
if (includes.has("notes")) {
(gatedData as any).opportunityNoteText =
typeof originalOpportunityNoteText === "string"
? originalOpportunityNoteText
: null;
}
const response = apiResponse.successful(
"Opportunity fetched successfully!",
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
@@ -1,15 +1,15 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { resolveMember } from "../../../modules/cw-utils/members/memberCache";
import { authMiddleware } from "../../../../middleware/authorization";
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
import { z } from "zod";
/* POST /v1/sales/opportunities/:identifier/notes */
/* POST /v1/sales/opportunities/opportunity/:identifier/notes */
export default createRoute(
"post",
["/opportunities/:identifier/notes"],
["/opportunities/opportunity/:identifier/notes"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -1,14 +1,14 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
/* DELETE /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
export default createRoute(
"delete",
["/opportunities/:identifier/notes/:noteId"],
["/opportunities/opportunity/:identifier/notes/:noteId"],
async (c) => {
const identifier = c.req.param("identifier");
const noteId = Number(c.req.param("noteId"));
@@ -1,14 +1,14 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */
/* GET /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
export default createRoute(
"get",
["/opportunities/:identifier/notes/:noteId"],
["/opportunities/opportunity/:identifier/notes/:noteId"],
async (c) => {
const identifier = c.req.param("identifier");
const noteId = Number(c.req.param("noteId"));
@@ -0,0 +1,25 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/opportunity/:identifier/notes */
export default createRoute(
"get",
["/opportunities/opportunity/:identifier/notes"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchRecord(identifier);
const data = await item.fetchNotes();
const response = apiResponse.successful(
"Opportunity notes fetched successfully!",
data,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
@@ -1,16 +1,16 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { resolveMember } from "../../../modules/cw-utils/members/memberCache";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */
/* PATCH /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
export default createRoute(
"patch",
["/opportunities/:identifier/notes/:noteId"],
["/opportunities/opportunity/:identifier/notes/:noteId"],
async (c) => {
const identifier = c.req.param("identifier");
const noteId = Number(c.req.param("noteId"));
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
import { authMiddleware } from "../../../../middleware/authorization";
import { processObjectValuePerms } from "../../../../../modules/permission-utils/processObjectPermissions";
import { z } from "zod";
const productItemSchema = z
@@ -11,6 +11,7 @@ const productItemSchema = z
catalogItem: z.object({ id: z.number().int().positive() }).optional(),
forecastDescription: z.string().optional(),
productDescription: z.string().optional(),
customerDescription: z.string().nullable().optional(),
quantity: z.number().positive().optional(),
status: z.object({ id: z.number().int().positive() }).optional(),
productClass: z.string().optional(),
@@ -33,10 +34,10 @@ const addProductSchema = z.union([
z.array(productItemSchema).min(1, "At least one product is required"),
]);
/* POST /v1/sales/opportunities/:identifier/products */
/* POST /v1/sales/opportunities/opportunity/:identifier/products */
export default createRoute(
"post",
["/opportunities/:identifier/products"],
["/opportunities/opportunity/:identifier/products"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -54,7 +55,40 @@ export default createRoute(
);
const item = await opportunities.fetchRecord(identifier);
const created = await item.addProducts(gatedItems);
// Strip customerDescription from forecast payloads — CW only accepts
// it on procurement products, not forecast items.
const customerDescriptions = gatedItems.map(
(g: any) => g.customerDescription,
);
const forecastPayloads = gatedItems.map(
({ customerDescription, ...rest }: any) => rest,
);
const created = await item.addProducts(forecastPayloads);
// If any items included customerDescription, patch the linked
// procurement products after creation. This is best-effort since
// newly created forecast items may not have a linked procurement
// product yet.
const procurementUpdates = created
.map((product, idx) => ({
product,
customerDescription: customerDescriptions[idx],
}))
.filter((entry) => entry.customerDescription != null);
if (procurementUpdates.length > 0) {
await Promise.all(
procurementUpdates.map(({ product, customerDescription }) =>
item
.updateProcurementProductByForecastItem(product.cwForecastId, {
customerDescription,
})
.catch(() => null),
),
);
}
const isBatch = Array.isArray(body);
const response = apiResponse.created(
@@ -0,0 +1,147 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { procurement } from "../../../../../managers/procurement";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod";
const LABOR_DEFAULT_RATE = {
corporate: 100,
residential: 85,
} as const;
const roundMoney = (value: number) => Math.round(value * 100) / 100;
const addLaborSchema = z
.object({
laborStyle: z.enum(["field", "tech"]),
customerType: z.enum(["corporate", "residential"]).optional(),
hours: z.number().positive().optional(),
rate: z.number().min(0).optional(),
ppu: z.number().min(0).optional(),
cpu: z.number().min(0).optional(),
taxable: z.boolean().optional(),
taxableFlag: z.boolean().optional(),
description: z.string().min(1).optional(),
customerDescription: z.string().min(1).optional(),
procurementNotes: z.string().optional(),
productNarrative: z.string().optional(),
})
.strict();
/* POST /v1/sales/opportunities/opportunity/:identifier/products/labor */
export default createRoute(
"post",
["/opportunities/opportunity/:identifier/products/labor"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const input = addLaborSchema.parse(body);
const laborCatalog = await procurement.fetchLaborCatalogItems();
const selectedCatalog =
input.laborStyle === "tech" ? laborCatalog.tech : laborCatalog.field;
const customerType = input.customerType ?? "corporate";
const defaultRate = LABOR_DEFAULT_RATE[customerType];
const quantity = input.hours ?? 1;
const ppu = input.ppu ?? input.rate ?? defaultRate;
const cpu = input.cpu ?? roundMoney(ppu * 0.5);
const taxableFlag =
input.taxable ?? input.taxableFlag ?? selectedCatalog.salesTaxable;
const makeCustomField = (
caption: string,
value: string,
fieldId: number,
) => ({
id: fieldId,
caption,
type: "Text",
entryMethod: "EntryField",
value,
});
const payload = {
...(input.procurementNotes || input.productNarrative
? {
customFields: [
...(input.procurementNotes
? [
makeCustomField(
"Procurement Notes",
input.procurementNotes,
29,
),
]
: []),
...(input.productNarrative
? [
makeCustomField(
"Product Narrative",
input.productNarrative,
46,
),
]
: []),
],
}
: {}),
catalogItem: { id: selectedCatalog.cwCatalogId },
description:
input.description ??
selectedCatalog.name ??
selectedCatalog.identifier ??
`${input.laborStyle.toUpperCase()} Labor`,
customerDescription: input.customerDescription,
quantity,
price: ppu,
cost: cpu,
taxableFlag,
dropshipFlag: false,
billableOption: "Billable",
};
const opportunity = await opportunities.fetchRecord(identifier);
const [created] = await opportunity.addProcurementProducts(payload);
const fields = Array.isArray(created?.customFields)
? created.customFields
: [];
const procurementNotes =
fields.find((f: any) => f?.id === 29)?.value ?? null;
const productNarrative =
fields.find((f: any) => f?.id === 46)?.value ?? null;
const response = apiResponse.created(
"Labor added to opportunity successfully!",
{
id: created?.id ?? null,
forecastDetailId: created?.forecastDetailId ?? null,
laborStyle: input.laborStyle,
customerType,
catalogItem: {
id: selectedCatalog.cwCatalogId,
identifier: selectedCatalog.identifier,
name: selectedCatalog.name,
},
description: created?.description ?? payload.description,
customerDescription:
created?.customerDescription ?? input.customerDescription ?? null,
quantity: created?.quantity ?? quantity,
rate: ppu,
ppu,
cpu,
revenue: roundMoney((created?.quantity ?? quantity) * ppu),
cost: roundMoney((created?.quantity ?? quantity) * cpu),
taxableFlag: created?.taxableFlag ?? taxableFlag,
procurementNotes,
productNarrative,
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
);
@@ -0,0 +1,134 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { procurement } from "../../../../../managers/procurement";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod";
const specialOrderItemSchema = z
.object({
desc: z.string().min(1),
customerDesc: z.string().min(1).optional(),
qty: z.number().positive().optional(),
price: z.number(),
cost: z.number().optional(),
taxable: z.boolean().optional(),
taxableFlag: z.boolean().optional(),
procurementNotes: z.string().optional(),
productNarrative: z.string().optional(),
})
.strict();
const addSpecialOrderSchema = z.union([
specialOrderItemSchema,
z
.array(specialOrderItemSchema)
.min(1, "At least one special-order product is required"),
]);
/* POST /v1/sales/opportunities/opportunity/:identifier/products/special-order */
export default createRoute(
"post",
["/opportunities/opportunity/:identifier/products/special-order"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const validated = addSpecialOrderSchema.parse(body);
const inputItems = Array.isArray(validated) ? validated : [validated];
const specialOrderCatalogItem =
await procurement.fetchItem("SPECIAL ORDER");
const makeCustomField = (
caption: string,
value: string,
fieldId: number,
) => ({
id: fieldId,
caption,
type: "Text",
entryMethod: "EntryField",
value,
});
const normalizedItems = inputItems.map((item) => ({
...(item.procurementNotes || item.productNarrative
? {
customFields: [
...(item.procurementNotes
? [
makeCustomField(
"Procurement Notes",
item.procurementNotes,
29,
),
]
: []),
...(item.productNarrative
? [
makeCustomField(
"Product Narrative",
item.productNarrative,
46,
),
]
: []),
],
}
: {}),
catalogItem: { id: specialOrderCatalogItem.cwCatalogId },
description: item.desc,
customerDescription: item.customerDesc,
quantity: item.qty ?? 1,
price: item.price,
cost: item.cost,
taxableFlag:
item.taxable ??
item.taxableFlag ??
specialOrderCatalogItem.salesTaxable,
dropshipFlag: false,
billableOption: "Billable",
}));
const opportunity = await opportunities.fetchRecord(identifier);
const created = await opportunity.addProcurementProducts(normalizedItems);
const serialized = created.map((item: any) => {
const fields = Array.isArray(item?.customFields) ? item.customFields : [];
const procurementNotes =
fields.find((f: any) => f?.id === 29)?.value ?? null;
const productNarrative =
fields.find((f: any) => f?.id === 46)?.value ?? null;
return {
id: item?.id ?? null,
forecastDetailId: item?.forecastDetailId ?? null,
description: item?.description ?? null,
productDescription: item?.description ?? null,
customerDescription: item?.customerDescription ?? null,
quantity: item?.quantity ?? null,
price: item?.price ?? null,
revenue: item?.price ?? null,
cost: item?.cost ?? null,
taxableFlag: item?.taxableFlag ?? null,
specialOrderFlag: item?.specialOrderFlag ?? null,
procurementNotes,
productNarrative,
};
});
const isBatch = Array.isArray(body);
const response = apiResponse.created(
isBatch
? `${created.length} special-order product(s) added successfully!`
: "Special-order product added successfully!",
isBatch ? serialized : serialized[0]!,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({
permissions: ["sales.opportunity.product.add.specialOrder"],
}),
);
@@ -0,0 +1,84 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
import { z } from "zod";
const cancelProductSchema = z
.object({
quantityCancelled: z.number().int().min(0),
cancellationReason: z.string().nullable().optional(),
})
.strict();
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/cancel */
export default createRoute(
"patch",
["/opportunities/opportunity/:identifier/products/:productId/cancel"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
const body = await c.req.json();
if (!Number.isInteger(productId) || productId <= 0) {
throw new GenericError({
status: 400,
name: "InvalidProductId",
message: "productId must be a positive integer",
});
}
const input = cancelProductSchema.parse(body);
const opportunity = await opportunities.fetchRecord(identifier);
const products = await opportunity.fetchProducts();
const product = products.find((item) => item.cwForecastId === productId);
if (!product) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
message: `Forecast item ${productId} not found on opportunity`,
});
}
const quantity = product.quantity ?? 0;
if (input.quantityCancelled > quantity) {
throw new GenericError({
status: 400,
name: "InvalidCancelledQuantity",
message: `quantityCancelled cannot exceed product quantity (${quantity})`,
});
}
await opportunity.setProductCancellation(productId, {
quantityCancelled: input.quantityCancelled,
cancellationReason: input.cancellationReason,
});
const refreshedProducts = await opportunity.fetchProducts({ fresh: true });
const updated = refreshedProducts.find(
(item) => item.cwForecastId === productId,
);
if (!updated) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
message: `Forecast item ${productId} not found on opportunity`,
});
}
const response = apiResponse.successful(
input.quantityCancelled === 0
? "Product uncancelled successfully!"
: "Product cancellation updated successfully!",
updated.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
);
@@ -0,0 +1,72 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/opportunity/:identifier/products/:productId */
export default createRoute(
"delete",
["/opportunities/opportunity/:identifier/products/:productId"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
if (!Number.isInteger(productId) || productId <= 0) {
throw new GenericError({
status: 400,
name: "InvalidProductId",
message: "productId must be a positive integer",
});
}
const opportunity = await opportunities.fetchRecord(identifier);
// Verify the forecast item exists before attempting deletion
const products = await opportunity.fetchProducts();
const product = products.find((item) => item.cwForecastId === productId);
if (!product) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
message: `Forecast item ${productId} not found on opportunity`,
});
}
try {
await opportunity.deleteProduct(productId);
const response = apiResponse.successful(
"Product deleted from opportunity successfully!",
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
const isAxios =
err != null && typeof err === "object" && "isAxiosError" in err;
if (isAxios) {
const axiosErr = err as any;
const cwStatus: number = axiosErr.response?.status ?? 502;
const cwMessage: string =
axiosErr.response?.data?.message ??
"Failed to delete the product in ConnectWise";
return c.json(
{
status: cwStatus,
message: cwMessage,
error: "ConnectWiseDeleteError",
successful: false,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
);
}
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.product.delete"] }),
);
@@ -1,13 +1,13 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/products */
/* GET /v1/sales/opportunities/opportunity/:identifier/products */
export default createRoute(
"get",
["/opportunities/:identifier/products"],
["/opportunities/opportunity/:identifier/products"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchRecord(identifier);
@@ -0,0 +1,51 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { procurement } from "../../../../../managers/procurement";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/opportunity/:identifier/products/labor/options */
export default createRoute(
"get",
["/opportunities/opportunity/:identifier/products/labor/options"],
async (c) => {
const identifier = c.req.param("identifier");
await opportunities.fetchRecord(identifier);
const laborCatalog = await procurement.fetchLaborCatalogItems();
const response = apiResponse.successful(
"Labor product options fetched successfully!",
{
defaults: {
customerType: "corporate",
rates: {
corporate: 100,
residential: 85,
},
cpuMultiplier: 0.5,
quantity: 1,
},
options: {
field: {
cwCatalogId: laborCatalog.field.cwCatalogId,
identifier: laborCatalog.field.identifier,
name: laborCatalog.field.name,
taxableFlag: laborCatalog.field.salesTaxable,
},
tech: {
cwCatalogId: laborCatalog.tech.cwCatalogId,
identifier: laborCatalog.tech.identifier,
name: laborCatalog.tech.name,
taxableFlag: laborCatalog.tech.salesTaxable,
},
},
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
);
@@ -1,14 +1,14 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/sequence */
export default createRoute(
"patch",
["/opportunities/:identifier/products/sequence"],
["/opportunities/opportunity/:identifier/products/sequence"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -0,0 +1,240 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
import { z } from "zod";
const PRODUCT_NARRATIVE_FIELD_ID = 46;
const PROCUREMENT_NOTES_FIELD_ID = 29;
const updateProductSchema = z
.object({
productDescription: z.string().min(1).optional(),
quantity: z.number().positive().optional(),
unitPrice: z.number().min(0).optional(),
unitCost: z.number().min(0).optional(),
customerDescription: z.string().nullable().optional(),
productNarrative: z.string().nullable().optional(),
procurementNotes: z.string().nullable().optional(),
taxableFlag: z.boolean().optional(),
})
.strict()
.refine(
(value) =>
Object.values(value).some((item) => item !== undefined && item !== null),
"At least one editable field is required",
);
const upsertCustomTextField = (
fields: Array<Record<string, unknown>>,
fieldId: number,
caption: string,
value: string,
) => {
const next = [...fields];
const idx = next.findIndex((f) => Number(f.id) === fieldId);
const field = {
id: fieldId,
caption,
type: "Text",
entryMethod: "EntryField",
value,
};
if (idx === -1) {
next.push(field);
return next;
}
next[idx] = {
...next[idx],
...field,
};
return next;
};
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/edit */
export default createRoute(
"patch",
["/opportunities/opportunity/:identifier/products/:productId/edit"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
const body = await c.req.json();
if (!Number.isInteger(productId) || productId <= 0) {
throw new GenericError({
status: 400,
name: "InvalidProductId",
message: "productId must be a positive integer",
});
}
const input = updateProductSchema.parse(body);
const opportunity = await opportunities.fetchRecord(identifier);
const forecastItems = await opportunity.fetchProducts();
const forecastItem = forecastItems.find(
(item) => item.cwForecastId === productId,
);
if (!forecastItem) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
message: `Forecast item ${productId} not found on opportunity`,
});
}
const forecastJson = forecastItem.toJson();
const effectiveQuantity = input.quantity ?? forecastJson.quantity ?? 1;
const forecastPatch: Record<string, unknown> = {};
if (input.productDescription !== undefined) {
forecastPatch.productDescription = input.productDescription;
}
if (input.quantity !== undefined) {
forecastPatch.quantity = input.quantity;
}
if (input.unitPrice !== undefined) {
forecastPatch.revenue = Number(
(input.unitPrice * effectiveQuantity).toFixed(2),
);
}
if (input.unitCost !== undefined) {
forecastPatch.cost = Number(
(input.unitCost * effectiveQuantity).toFixed(2),
);
}
if (input.taxableFlag !== undefined) {
forecastPatch.taxableFlag = input.taxableFlag;
}
const existingProcurement =
await opportunity.fetchProcurementProductByForecastItem(productId);
if (
(input.productNarrative !== undefined ||
input.procurementNotes !== undefined) &&
!existingProcurement
) {
throw new GenericError({
status: 400,
name: "ProcurementLinkRequired",
message:
"Product Narrative and Procurement Notes can only be updated on products linked to a procurement record",
});
}
let updatedProcurement = existingProcurement;
if (existingProcurement) {
const procurementPatch: Record<string, unknown> = {};
if (input.productDescription !== undefined) {
procurementPatch.description = input.productDescription;
}
if (input.quantity !== undefined) {
procurementPatch.quantity = input.quantity;
}
if (input.unitPrice !== undefined) {
procurementPatch.price = input.unitPrice;
}
if (input.unitCost !== undefined) {
procurementPatch.cost = input.unitCost;
}
if (
input.customerDescription !== undefined &&
input.customerDescription !== null
) {
procurementPatch.customerDescription = input.customerDescription;
}
const existingFields = Array.isArray(existingProcurement.customFields)
? existingProcurement.customFields.map((field) => ({ ...field }))
: [];
let updatedFields = existingFields as Array<Record<string, unknown>>;
if (
input.procurementNotes !== undefined &&
input.procurementNotes !== null
) {
updatedFields = upsertCustomTextField(
updatedFields,
PROCUREMENT_NOTES_FIELD_ID,
"Procurement Notes",
input.procurementNotes,
);
}
if (
input.productNarrative !== undefined &&
input.productNarrative !== null
) {
updatedFields = upsertCustomTextField(
updatedFields,
PRODUCT_NARRATIVE_FIELD_ID,
"Product Narrative",
input.productNarrative,
);
}
if (
(input.procurementNotes !== undefined &&
input.procurementNotes !== null) ||
(input.productNarrative !== undefined &&
input.productNarrative !== null)
) {
procurementPatch.customFields = updatedFields;
}
if (Object.keys(procurementPatch).length > 0) {
updatedProcurement =
await opportunity.updateProcurementProductByForecastItem(
productId,
procurementPatch,
);
}
}
let updatedForecast = forecastJson;
if (Object.keys(forecastPatch).length > 0) {
const patched = await opportunity.updateProduct(productId, forecastPatch);
updatedForecast = patched.toJson();
}
const updatedFields = Array.isArray(updatedProcurement?.customFields)
? updatedProcurement.customFields
: [];
const procurementNotes =
updatedFields.find(
(field: any) => field?.id === PROCUREMENT_NOTES_FIELD_ID,
)?.value ?? null;
const productNarrative =
updatedFields.find(
(field: any) => field?.id === PRODUCT_NARRATIVE_FIELD_ID,
)?.value ?? null;
const quantity =
updatedProcurement?.quantity ?? updatedForecast.quantity ?? null;
const unitPrice = updatedProcurement?.price ?? null;
const unitCost = updatedProcurement?.cost ?? null;
const response = apiResponse.successful("Product updated successfully!", {
...updatedForecast,
productDescription:
updatedProcurement?.description ?? updatedForecast.productDescription,
customerDescription:
updatedProcurement?.customerDescription ??
updatedForecast.customerDescription ??
null,
quantity,
unitPrice,
unitCost,
procurementNotes,
productNarrative,
});
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
);
@@ -0,0 +1,72 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod";
import { cwMembers } from "../../../../../managers/cwMembers";
import {
createWorkflowActivity,
OptimaType,
} from "../../../../../workflows/wf.opportunity";
const commitQuoteSchema = z
.object({
lineItemPricing: z.boolean().optional(),
includeQuoteNarrative: z.boolean().optional(),
includeItemNarratives: z.boolean().optional(),
})
.strict()
.optional();
/* POST /v1/sales/opportunities/opportunity/:identifier/quote/commit */
export default createRoute(
"post",
["/opportunities/opportunity/:identifier/quote/commit"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json().catch(() => undefined);
const opts = commitQuoteSchema.parse(body);
const item = await opportunities.fetchRecord(identifier);
const user = c.get("user");
const quote = await item.commitQuote(opts ?? {}, user);
// Create a workflow activity for the generated quote
try {
let cwMemberId: number | null = null;
if (user.cwIdentifier) {
const cwMember = await cwMembers.fetch(user.cwIdentifier);
cwMemberId = cwMember.cwMemberId;
}
if (cwMemberId) {
await createWorkflowActivity({
name: `[Workflow] Quote generated — ${item.name}`,
opportunityCwId: item.cwOpportunityId,
companyCwId: item.companyCwId,
assignToCwMemberId: cwMemberId,
notes: `Quote "${quote.quoteFileName}" generated.`,
optimaType: OptimaType.QuoteGenerated,
quoteId: quote.id,
});
}
} catch (activityErr) {
console.error(
"[Quote Commit] Failed to create workflow activity:",
activityErr,
);
// Don't fail the quote commit if the activity fails
}
const response = apiResponse.created(
"Quote committed successfully!",
quote.toJson({ includeRegenData: true, includeRegenParams: true }),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.commit"] }),
);
@@ -0,0 +1,55 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { injectPdfMetadata } from "../../../../../modules/pdf-utils";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
const VALID_FETCH_ACTIONS = ["download", "print"] as const;
type FetchAction = (typeof VALID_FETCH_ACTIONS)[number];
/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/download?fetchAction=download|print */
export default createRoute(
"get",
["/opportunities/opportunity/:identifier/quote/:quoteId/download"],
async (c) => {
const quoteId = c.req.param("quoteId");
const user = c.get("user");
const fetchAction = c.req.query("fetchAction") as FetchAction | undefined;
if (!fetchAction || !VALID_FETCH_ACTIONS.includes(fetchAction)) {
throw new GenericError({
status: 400,
name: "InvalidFetchAction",
message: `Query parameter 'fetchAction' is required and must be one of: ${VALID_FETCH_ACTIONS.join(", ")}`,
});
}
const downloadedAt = new Date().toISOString();
const quote = await generatedQuotes.recordDownload(quoteId, {
id: user.id,
name: user.name,
email: user.email,
fetchAction,
});
// Inject download-time metadata into the PDF's document properties
const pdfWithMetadata = await injectPdfMetadata(quote.quoteFile, {
downloadedAt,
downloadedById: user.id,
downloadedByName: user.name ?? undefined,
downloadedByEmail: user.email ?? undefined,
});
const response = apiResponse.successful("Quote downloaded successfully!", {
id: quote.id,
quoteFileName: quote.quoteFileName,
mimeType: "application/pdf",
contentBase64: Buffer.from(pdfWithMetadata).toString("base64"),
});
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.download"] }),
);
@@ -0,0 +1,27 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/opportunity/:identifier/quotes */
export default createRoute(
"get",
["/opportunities/opportunity/:identifier/quotes"],
async (c) => {
const identifier = c.req.param("identifier");
const includeRegenData = c.req.query("includeRegenData") === "true";
const includeRegenParams = c.req.query("includeRegenParams") === "true";
const item = await opportunities.fetchRecord(identifier);
const quotes = await generatedQuotes.fetchByOpportunity(item.id);
const response = apiResponse.successful(
"Committed quotes fetched successfully!",
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams })),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.fetch"] }),
);
@@ -0,0 +1,33 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/opportunity/:identifier/quotes/downloads */
export default createRoute(
"get",
["/opportunities/opportunity/:identifier/quotes/downloads"],
async (c) => {
const identifier = c.req.param("identifier");
const opportunity = await opportunities.fetchRecord(identifier);
const quotes = await generatedQuotes.fetchByOpportunity(opportunity.id);
const data = quotes.map((quote) => ({
quoteId: quote.id,
quoteFileName: quote.quoteFileName,
createdById: quote.createdById,
createdAt: quote.createdAt,
downloads: quote.downloads,
}));
const response = apiResponse.successful(
"Quote download history fetched successfully!",
data,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.fetch_downloads"] }),
);
@@ -0,0 +1,61 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/preview */
export default createRoute(
"get",
["/opportunities/opportunity/:identifier/quote/:quoteId/preview"],
async (c) => {
const identifier = c.req.param("identifier");
const quoteId = c.req.param("quoteId");
const item = await opportunities.fetchRecord(identifier);
const quote = await generatedQuotes.fetch(quoteId);
const regenData =
quote.quoteRegenData && typeof quote.quoteRegenData === "object"
? (quote.quoteRegenData as Record<string, unknown>)
: {};
const options =
regenData.options && typeof regenData.options === "object"
? (regenData.options as Record<string, unknown>)
: {};
const creator = await quote.fetchCreatedBy();
const previewBuffer = await item.generateQuote({
lineItemPricing: options.lineItemPricing as boolean | undefined,
includeQuoteNarrative: options.includeQuoteNarrative as
| boolean
| undefined,
includeItemNarratives: options.includeItemNarratives as
| boolean
| undefined,
showPreview: true,
metadata: {
quoteId: quote.id,
createdById: quote.createdById ?? undefined,
createdByName: creator?.name ?? undefined,
createdByEmail: creator?.email ?? undefined,
createdAt: quote.createdAt?.toISOString(),
},
});
const previewBase64 = Buffer.from(previewBuffer).toString("base64");
const response = apiResponse.successful(
"Quote preview generated successfully!",
{
mimeType: "application/pdf",
contentBase64: previewBase64,
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.preview"] }),
);
@@ -1,13 +1,13 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { authMiddleware } from "../../../middleware/authorization";
/* POST /v1/sales/opportunities/:identifier/refresh */
/* POST /v1/sales/opportunities/opportunity/:identifier/refresh */
export default createRoute(
"post",
["/opportunities/:identifier/refresh"],
["/opportunities/opportunity/:identifier/refresh"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchItem(identifier);
@@ -0,0 +1,93 @@
import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
import GenericError from "../../../../Errors/GenericError";
import { z } from "zod";
const updateSchema = z
.object({
name: z.string().min(1).optional(),
notes: z.string().optional(),
rating: z.object({ id: z.number() }).optional(),
type: z.object({ id: z.number() }).optional(),
stage: z.object({ id: z.number() }).optional(),
status: z.object({ id: z.number() }).optional(),
priority: z.object({ id: z.number() }).optional(),
campaign: z.object({ id: z.number() }).optional(),
primarySalesRep: z.object({ id: z.number() }).optional(),
secondarySalesRep: z.object({ id: z.number() }).nullable().optional(),
company: z.object({ id: z.number() }).optional(),
contact: z.object({ id: z.number() }).nullable().optional(),
site: z.object({ id: z.number() }).nullable().optional(),
expectedCloseDate: z
.string()
.optional()
.transform((v) => (v ? new Date(v).toISOString() : v)),
customerPO: z.string().nullable().optional(),
source: z.string().nullable().optional(),
locationId: z.number().optional(),
businessUnitId: z.number().optional(),
})
.refine((d) => Object.values(d).some((v) => v !== undefined), {
message: "At least one field must be provided",
});
/* PATCH /v1/sales/opportunities/opportunity/:identifier */
export default createRoute(
"patch",
["/opportunities/opportunity/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const data = updateSchema.parse(body);
const item = await opportunities.fetchRecord(identifier);
try {
const updated = await item.updateOpportunity(data);
const response = apiResponse.successful(
"Opportunity updated successfully!",
updated.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
const isAxios =
err != null && typeof err === "object" && "isAxiosError" in err;
if (isAxios) {
const axiosErr = err as any;
const cwStatus: number = axiosErr.response?.status ?? 502;
const cwData = axiosErr.response?.data;
const cwMessage: string =
cwData?.message ?? "Failed to update the opportunity in ConnectWise";
const cwErrors: unknown[] | undefined = Array.isArray(cwData?.errors)
? cwData.errors
: undefined;
return c.json(
{
status: cwStatus,
message: cwMessage,
error: "ConnectWiseUpdateError",
successful: false,
errors: cwErrors,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
);
}
throw new GenericError({
status: 500,
name: "OpportunitySaveError",
message: "Failed to save opportunity data",
cause: err instanceof Error ? err.message : String(err),
});
}
},
authMiddleware({ permissions: ["sales.opportunity.update"] }),
);
@@ -0,0 +1,196 @@
import { z } from "zod";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { opportunities } from "../../../../../managers/opportunities";
import { cwMembers } from "../../../../../managers/cwMembers";
import GenericError from "../../../../../Errors/GenericError";
import {
processOpportunityAction,
type WorkflowAction,
type WorkflowUser,
} from "../../../../../workflows/wf.opportunity";
// ── Zod schemas ───────────────────────────────────────────────────────────
const basePayload = z.object({
note: z.string().optional(),
timeStarted: z.string().datetime().optional(),
timeEnded: z.string().datetime().optional(),
});
const noteRequiredPayload = z.object({
note: z.string().min(1, "A non-empty note is required."),
timeStarted: z.string().datetime().optional(),
timeEnded: z.string().datetime().optional(),
});
const dispatchSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("acceptNew"),
payload: basePayload,
}),
z.object({
action: z.literal("requestReview"),
payload: noteRequiredPayload,
}),
z.object({
action: z.literal("reviewDecision"),
payload: noteRequiredPayload.extend({
decision: z.enum(["approve", "reject", "send", "cancel"]),
}),
}),
z.object({
action: z.literal("sendQuote"),
payload: basePayload.extend({
quoteConfirmed: z.boolean().optional(),
won: z.boolean().optional(),
lost: z.boolean().optional(),
finalize: z.boolean().optional(),
needsRevision: z.boolean().optional(),
}),
}),
z.object({
action: z.literal("markReadyToSend"),
payload: basePayload,
}),
z.object({
action: z.literal("confirmQuote"),
payload: basePayload,
}),
z.object({
action: z.literal("finalize"),
payload: noteRequiredPayload.extend({
outcome: z.enum(["won", "lost"]),
}),
}),
z.object({
action: z.literal("resurrect"),
payload: noteRequiredPayload,
}),
z.object({
action: z.literal("beginRevision"),
payload: basePayload,
}),
z.object({
action: z.literal("resendQuote"),
payload: basePayload.extend({
quoteConfirmed: z.boolean().optional(),
won: z.boolean().optional(),
lost: z.boolean().optional(),
finalize: z.boolean().optional(),
needsRevision: z.boolean().optional(),
}),
}),
z.object({
action: z.literal("cancel"),
payload: noteRequiredPayload,
}),
z.object({
action: z.literal("reopen"),
payload: noteRequiredPayload,
}),
]);
// ── Route ─────────────────────────────────────────────────────────────────
/* POST /v1/sales/opportunities/opportunity/:identifier/workflow */
export default createRoute(
"post",
["/opportunities/opportunity/:identifier/workflow"],
async (c) => {
try {
const identifier = c.req.param("identifier");
const body = await c.req.json();
console.log(
"[Workflow Dispatch] Raw request body:",
JSON.stringify(body, null, 2),
);
const parsed = dispatchSchema.parse(body);
console.log(
"[Workflow Dispatch] Parsed payload:",
JSON.stringify(parsed.payload, null, 2),
);
const user = c.get("user");
// ── Resolve opportunity ────────────────────────────────────────────
const opportunity = await opportunities.fetchItem(identifier);
// ── Build WorkflowUser ─────────────────────────────────────────────
if (!user.cwIdentifier) {
throw new GenericError({
status: 400,
name: "MissingCwIdentifier",
message:
"Your account is not linked to a ConnectWise member. A CW member association is required to execute workflow actions.",
});
}
const cwMember = await cwMembers.fetch(user.cwIdentifier);
const permissions = await user.readAllPermissions();
const workflowUser: WorkflowUser = {
id: user.id,
cwMemberId: cwMember.cwMemberId,
permissions,
};
// ── Dispatch ───────────────────────────────────────────────────────
const result = await processOpportunityAction(
opportunity,
parsed as WorkflowAction,
workflowUser,
);
if (!result.success) {
console.error(
`[Workflow Dispatch] Transition failed for opportunity "${identifier}":`,
result.error,
);
const response = apiResponse.error(
new GenericError({
status: 422,
name: "WorkflowTransitionFailed",
message: result.error ?? "Workflow action failed.",
}),
);
return c.json(
{
...response,
data: {
previousStatusId: result.previousStatusId,
previousStatus: result.previousStatus,
newStatusId: result.newStatusId,
newStatus: result.newStatus,
},
},
response.status as ContentfulStatusCode,
);
}
const response = apiResponse.successful(
"Workflow action completed successfully.",
{
previousStatusId: result.previousStatusId,
previousStatus: result.previousStatus,
newStatusId: result.newStatusId,
newStatus: result.newStatus,
activitiesCreated: result.activitiesCreated.map((a) => a.toJson()),
coldCheck: result.coldCheck,
},
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err: any) {
console.error("[Workflow Dispatch] Unhandled error:", err);
if (err?.response?.data) {
console.error(
"[Workflow Dispatch] CW response body:",
JSON.stringify(err.response.data, null, 2),
);
}
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.workflow"] }),
);
@@ -0,0 +1,150 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { opportunities } from "../../../../../managers/opportunities";
import { activityCw } from "../../../../../modules/cw-utils/activities/activities";
import { ActivityController } from "../../../../../controllers/ActivityController";
import { OptimaType } from "../../../../../workflows/wf.opportunity";
// ═══════════════════════════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════════════════════════
const OPTIMA_TYPE_VALUES = new Set<string>([
OptimaType.OpportunityCreated,
OptimaType.OpportunitySetup,
OptimaType.OpportunityReview,
OptimaType.QuoteSent,
OptimaType.QuoteConfirmed,
OptimaType.QuoteSentConfirmed,
OptimaType.QuoteGenerated,
OptimaType.Revision,
OptimaType.Finalized,
OptimaType.Converted,
]);
/** QuoteID custom field ID (matches wf.opportunity.ts QUOTE_ID_FIELD_ID). */
const QUOTE_ID_FIELD_ID = 48;
/** Close Date custom field ID (matches wf.opportunity.ts CLOSE_DATE_FIELD_ID). */
const CLOSE_DATE_FIELD_ID = 49;
/**
* Extract the Optima_Type value from a CW activity's custom fields.
* Returns the string value if present, or null.
*/
function extractOptimaType(
customFields: { id: number; value: unknown }[] | undefined,
): string | null {
if (!customFields) return null;
const field = customFields.find((f) => f.id === OptimaType.FIELD_ID);
if (!field?.value || typeof field.value !== "string") return null;
return OPTIMA_TYPE_VALUES.has(field.value) ? field.value : null;
}
/**
* Extract the QuoteID custom field value from a CW activity.
* Returns the string value or null.
*/
function extractQuoteId(
customFields: { id: number; value: unknown }[] | undefined,
): string | null {
if (!customFields) return null;
const field = customFields.find((f) => f.id === QUOTE_ID_FIELD_ID);
if (!field?.value || typeof field.value !== "string") return null;
return field.value;
}
/**
* Extract the Close Date custom field value from a CW activity.
* Returns the ISO-8601 string or null.
*/
function extractCloseDate(
customFields: { id: number; value: unknown }[] | undefined,
): string | null {
if (!customFields) return null;
const field = customFields.find((f) => f.id === CLOSE_DATE_FIELD_ID);
if (!field?.value || typeof field.value !== "string") return null;
return field.value;
}
// ═══════════════════════════════════════════════════════════════════════════
// ROUTE
// ═══════════════════════════════════════════════════════════════════════════
/* GET /v1/sales/opportunities/opportunity/:identifier/workflow/history */
export default createRoute(
"get",
["/opportunities/opportunity/:identifier/workflow/history"],
async (c) => {
try {
const identifier = c.req.param("identifier");
const filterType = c.req.query("type") ?? null; // optional filter by Optima_Type value
// Resolve the opportunity to get the CW opportunity ID
const opportunity = await opportunities.fetchItem(identifier);
// Fetch all activities for this opportunity from CW
const activitiesCollection = await activityCw.fetchByOpportunity(
opportunity.cwOpportunityId,
);
// Filter to workflow activities (those with a valid Optima_Type)
const workflowActivities: {
activity: ReturnType<ActivityController["toJson"]>;
optimaType: string;
quoteId: string | null;
closed: boolean;
closedAt: string | null;
}[] = [];
for (const [, raw] of activitiesCollection) {
const controller = new ActivityController(raw);
const json = controller.toJson();
const optimaType = extractOptimaType(raw.customFields);
if (!optimaType) continue;
if (filterType && optimaType !== filterType) continue;
const quoteId = extractQuoteId(raw.customFields);
const closed = raw.status?.id === 2;
const closedAt = extractCloseDate(raw.customFields);
workflowActivities.push({
activity: json,
optimaType,
quoteId,
closed,
closedAt,
});
}
// Sort newest first
workflowActivities.sort((a, b) => {
const dateA = new Date(
a.activity.dateEnd ?? a.activity.dateStart ?? 0,
).getTime();
const dateB = new Date(
b.activity.dateEnd ?? b.activity.dateStart ?? 0,
).getTime();
return dateB - dateA;
});
const response = apiResponse.successful(
"Workflow history fetched successfully.",
{
opportunityId: opportunity.id,
cwOpportunityId: opportunity.cwOpportunityId,
totalActivities: workflowActivities.length,
activities: workflowActivities,
},
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
console.error("[Workflow History] Unhandled error:", err);
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
@@ -0,0 +1,420 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { opportunities } from "../../../../../managers/opportunities";
import {
OpportunityStatus,
StatusIdToKey,
WorkflowPermissions,
type OpportunityStatusKey,
} from "../../../../../workflows/wf.opportunity";
import {
checkColdStatus,
type ColdCheckResult,
} from "../../../../../modules/algorithms/algo.coldThreshold";
// ═══════════════════════════════════════════════════════════════════════════
// ACTION AVAILABILITY MAP
// ═══════════════════════════════════════════════════════════════════════════
/**
* Per-status list of actions the user can invoke.
*
* Each entry describes the action key, a human-readable label, the
* expected target status(es), whether a note is required, and any
* permission gate beyond the base workflow permission.
*/
interface AvailableAction {
action: string;
label: string;
targetStatuses: { key: OpportunityStatusKey; id: number }[];
requiresNote: boolean;
requiresPermission: string | null;
/** Extra payload fields that can/must be provided. */
payloadHints?: Record<string, string>;
}
const ACTION_MAP: Record<number, AvailableAction[]> = {
[OpportunityStatus.PendingNew]: [
{
action: "acceptNew",
label: "Accept / Set Up Opportunity",
targetStatuses: [{ key: "New", id: OpportunityStatus.New }],
requiresNote: false,
requiresPermission: null,
},
],
[OpportunityStatus.New]: [
{
action: "markReadyToSend",
label: "Mark Ready to Send",
targetStatuses: [
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
],
requiresNote: false,
requiresPermission: null,
},
{
action: "sendQuote",
label: "Send Quote (skip review)",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: false,
requiresPermission: null,
payloadHints: {
quoteConfirmed: "boolean — mark confirmed simultaneously",
won: "boolean — immediate win",
lost: "boolean — immediate rejection",
needsRevision: "boolean — needs revision",
},
},
{
action: "requestReview",
label: "Send to Internal Review",
targetStatuses: [
{ key: "InternalReview", id: OpportunityStatus.InternalReview },
],
requiresNote: true,
requiresPermission: null,
},
{
action: "cancel",
label: "Cancel Opportunity",
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
requiresNote: true,
requiresPermission: WorkflowPermissions.CANCEL,
},
],
[OpportunityStatus.InternalReview]: [
{
action: "reviewDecision",
label: "Approve (move to Ready to Send)",
targetStatuses: [
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { decision: '"approve"' },
},
{
action: "reviewDecision",
label: "Reject (move to Pending Revision)",
targetStatuses: [
{ key: "PendingRevision", id: OpportunityStatus.PendingRevision },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { decision: '"reject"' },
},
{
action: "reviewDecision",
label: "Send Quote (reviewer sends directly)",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: true,
requiresPermission: null,
payloadHints: { decision: '"send"' },
},
{
action: "reviewDecision",
label: "Cancel from Review",
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
requiresNote: true,
requiresPermission: WorkflowPermissions.CANCEL,
payloadHints: { decision: '"cancel"' },
},
],
[OpportunityStatus.ReadyToSend]: [
{
action: "sendQuote",
label: "Send Quote to Customer",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: false,
requiresPermission: null,
payloadHints: {
quoteConfirmed: "boolean — mark confirmed simultaneously",
won: "boolean — immediate win",
lost: "boolean — immediate rejection",
needsRevision: "boolean — needs revision",
},
},
],
[OpportunityStatus.PendingSent]: [
{
action: "markReadyToSend",
label: "Mark Ready to Send",
targetStatuses: [
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
],
requiresNote: false,
requiresPermission: null,
},
{
action: "sendQuote",
label: "Send Quote to Customer",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: false,
requiresPermission: null,
payloadHints: {
quoteConfirmed: "boolean — mark confirmed simultaneously",
won: "boolean — immediate win",
lost: "boolean — immediate rejection",
needsRevision: "boolean — needs revision",
},
},
],
[OpportunityStatus.PendingRevision]: [
{
action: "beginRevision",
label: "Begin Revision",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: false,
requiresPermission: null,
},
],
[OpportunityStatus.QuoteSent]: [
{
action: "confirmQuote",
label: "Confirm Quote Receipt",
targetStatuses: [
{ key: "ConfirmedQuote", id: OpportunityStatus.ConfirmedQuote },
],
requiresNote: false,
requiresPermission: null,
},
{
action: "finalize",
label: "Mark as Won",
targetStatuses: [
{ key: "Won", id: OpportunityStatus.Won },
{ key: "PendingWon", id: OpportunityStatus.PendingWon },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { outcome: '"won"' },
},
{
action: "finalize",
label: "Mark as Lost",
targetStatuses: [
{ key: "PendingLost", id: OpportunityStatus.PendingLost },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { outcome: '"lost"' },
},
{
action: "resendQuote",
label: "Revise & Re-send (back to Active)",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: false,
requiresPermission: null,
payloadHints: { needsRevision: "true" },
},
],
[OpportunityStatus.ConfirmedQuote]: [
{
action: "finalize",
label: "Mark as Won",
targetStatuses: [
{ key: "Won", id: OpportunityStatus.Won },
{ key: "PendingWon", id: OpportunityStatus.PendingWon },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { outcome: '"won"' },
},
{
action: "finalize",
label: "Mark as Lost",
targetStatuses: [
{ key: "PendingLost", id: OpportunityStatus.PendingLost },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { outcome: '"lost"' },
},
{
action: "resendQuote",
label: "Revise & Re-send (back to Active)",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: false,
requiresPermission: null,
payloadHints: { needsRevision: "true" },
},
],
[OpportunityStatus.Active]: [
{
action: "markReadyToSend",
label: "Mark Ready to Send",
targetStatuses: [
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
],
requiresNote: false,
requiresPermission: null,
},
{
action: "resendQuote",
label: "Send Revised Quote",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: false,
requiresPermission: null,
payloadHints: {
quoteConfirmed: "boolean — mark confirmed simultaneously",
won: "boolean — immediate win",
lost: "boolean — immediate rejection",
},
},
{
action: "requestReview",
label: "Send to Internal Review",
targetStatuses: [
{ key: "InternalReview", id: OpportunityStatus.InternalReview },
],
requiresNote: true,
requiresPermission: null,
},
{
action: "cancel",
label: "Cancel Opportunity",
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
requiresNote: true,
requiresPermission: WorkflowPermissions.CANCEL,
},
],
[OpportunityStatus.PendingWon]: [
{
action: "finalize",
label: "Approve Won",
targetStatuses: [{ key: "Won", id: OpportunityStatus.Won }],
requiresNote: true,
requiresPermission: WorkflowPermissions.FINALIZE,
payloadHints: { outcome: '"won"' },
},
],
[OpportunityStatus.PendingLost]: [
{
action: "finalize",
label: "Approve Lost",
targetStatuses: [{ key: "Lost", id: OpportunityStatus.Lost }],
requiresNote: true,
requiresPermission: WorkflowPermissions.FINALIZE,
payloadHints: { outcome: '"lost"' },
},
{
action: "resurrect",
label: "Resurrect (back to Active)",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: true,
requiresPermission: null,
},
],
[OpportunityStatus.Won]: [],
[OpportunityStatus.Lost]: [],
[OpportunityStatus.Canceled]: [
{
action: "reopen",
label: "Re-open Opportunity",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: true,
requiresPermission: null,
},
],
};
// ═══════════════════════════════════════════════════════════════════════════
// ROUTE
// ═══════════════════════════════════════════════════════════════════════════
/* GET /v1/sales/opportunities/opportunity/:identifier/workflow */
export default createRoute(
"get",
["/opportunities/opportunity/:identifier/workflow"],
async (c) => {
try {
const identifier = c.req.param("identifier");
const user = c.get("user");
const opportunity = await opportunities.fetchItem(identifier);
const statusCwId = opportunity.statusCwId;
const statusKey: OpportunityStatusKey | null =
statusCwId != null ? (StatusIdToKey[statusCwId] ?? null) : null;
const isOptimaStage = opportunity.stageName === "Optima";
const isTerminal =
statusCwId === OpportunityStatus.Won ||
statusCwId === OpportunityStatus.Lost;
// ── Resolve available actions (permission-aware) ──────────────────
const rawActions =
statusCwId != null ? (ACTION_MAP[statusCwId] ?? []) : [];
const resolvedActions = await Promise.all(
rawActions.map(async (a) => {
const hasGate =
!a.requiresPermission ||
(await user.hasPermission(a.requiresPermission));
return { ...a, permitted: hasGate };
}),
);
// ── Cold check (only for QuoteSent / ConfirmedQuote) ──────────────
let coldCheck: ColdCheckResult | null = null;
if (
statusCwId === OpportunityStatus.QuoteSent ||
statusCwId === OpportunityStatus.ConfirmedQuote
) {
// Fetch activities to determine latest activity date
const activities = await opportunity.fetchActivities();
const latestDate =
activities.length > 0
? new Date(
Math.max(
...activities.map((a) => {
const json = a.toJson();
return new Date(
json.dateEnd ?? json.dateStart ?? 0,
).getTime();
}),
),
)
: null;
coldCheck = checkColdStatus({
statusCwId,
lastActivityDate: latestDate,
});
}
const response = apiResponse.successful(
"Workflow status fetched successfully.",
{
currentStatusId: statusCwId,
currentStatus: statusKey,
stageName: opportunity.stageName ?? null,
isOptimaStage,
isTerminal,
availableActions: isOptimaStage ? resolvedActions : [],
coldCheck,
},
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
console.error("[Workflow Status] Unhandled error:", err);
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
@@ -1,8 +1,8 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { opportunities } from "../../managers/opportunities";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { authMiddleware } from "../../middleware/authorization";
/* GET /v1/sales/opportunities/count */
export default createRoute(
+119
View File
@@ -0,0 +1,119 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { z } from "zod";
import { cwMembers } from "../../../managers/cwMembers";
import {
createWorkflowActivity,
OptimaType,
} from "../../../workflows/wf.opportunity";
const createSchema = z.object({
name: z.string().min(1),
expectedCloseDate: z
.string()
.min(1)
.transform((v) => new Date(v).toISOString().replace(/\.\d{3}Z$/, "Z")),
notes: z.string().optional(),
rating: z.object({ id: z.number() }).optional(),
type: z.object({ id: z.number() }).optional(),
stage: z.object({ id: z.number() }).optional(),
status: z.object({ id: z.number() }).optional(),
priority: z.object({ id: z.number() }).optional(),
campaign: z.object({ id: z.number() }).optional(),
primarySalesRep: z.object({ id: z.number() }),
secondarySalesRep: z.object({ id: z.number() }).nullable().optional(),
company: z.object({ id: z.number() }),
contact: z.object({ id: z.number() }),
site: z.object({ id: z.number() }).nullable().optional(),
source: z.string().nullable().optional(),
customerPO: z.string().nullable().optional(),
locationId: z.number().optional(),
businessUnitId: z.number().optional(),
});
/* POST /v1/sales/opportunities */
export default createRoute(
"post",
["/opportunities"],
async (c) => {
const body = await c.req.json();
const data = createSchema.parse(body);
try {
const item = await opportunities.createItem(data);
// Create a workflow activity for the new opportunity
try {
const user = c.get("user");
let cwMemberId: number | null = null;
if (user.cwIdentifier) {
const cwMember = await cwMembers.fetch(user.cwIdentifier);
cwMemberId = cwMember.cwMemberId;
}
if (cwMemberId) {
await createWorkflowActivity({
name: `[Workflow] Opportunity created — ${item.name}`,
opportunityCwId: item.cwOpportunityId,
companyCwId: item.companyCwId,
assignToCwMemberId: cwMemberId,
notes: "Opportunity created.",
optimaType: OptimaType.OpportunityCreated,
});
}
} catch (activityErr) {
console.error(
"[Opportunity Create] Failed to create workflow activity:",
activityErr,
);
// Don't fail the opportunity creation if the activity fails
}
const response = apiResponse.created(
"Opportunity created successfully!",
item.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
const isAxios =
err != null && typeof err === "object" && "isAxiosError" in err;
if (isAxios) {
const axiosErr = err as any;
const cwStatus: number = axiosErr.response?.status ?? 502;
const cwData = axiosErr.response?.data;
const cwMessage: string =
cwData?.message ?? "Failed to create the opportunity in ConnectWise";
const cwErrors: unknown[] | undefined = Array.isArray(cwData?.errors)
? cwData.errors
: undefined;
return c.json(
{
status: cwStatus,
message: cwMessage,
error: "ConnectWiseCreateError",
successful: false,
errors: cwErrors,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
);
}
throw new GenericError({
status: 500,
name: "OpportunityCreateError",
message: "Failed to create opportunity",
cause: err instanceof Error ? err.message : String(err),
});
}
},
authMiddleware({ permissions: ["sales.opportunity.create"] }),
);
@@ -1,9 +1,9 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { opportunities } from "../../managers/opportunities";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* GET /v1/sales/opportunities */
export default createRoute(
@@ -4,22 +4,22 @@ import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/notes */
/* GET /v1/sales/opportunities/@me */
export default createRoute(
"get",
["/opportunities/:identifier/notes"],
["/opportunities/@me"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchRecord(identifier);
const user = c.get("user");
const includeClosed = c.req.query("includeClosed") === "true";
const data = await item.fetchNotes();
const data = await opportunities.fetchByUser(user.id, { includeClosed });
const response = apiResponse.successful(
"Opportunity notes fetched successfully!",
data,
"Opportunities fetched successfully!",
data.map((item) => item.toJson()),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
);
@@ -0,0 +1,25 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
/* GET /v1/sales/opportunities/user/:id */
export default createRoute(
"get",
["/opportunities/user/:id"],
async (c) => {
const userId = c.req.param("id");
const includeClosed = c.req.query("includeClosed") === "true";
const data = await opportunities.fetchByUser(userId, { includeClosed });
const response = apiResponse.successful(
"Opportunities fetched successfully!",
data.map((item) => item.toJson()),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
);
+95
View File
@@ -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"] }),
);
+1
View File
@@ -57,6 +57,7 @@ v1.route("/permissions", require("./routers/permissionRouter").default);
v1.route("/unifi", require("./routers/unifiRouter").default);
v1.route("/procurement", require("./routers/procurementRouter").default);
v1.route("/sales", require("./routers/salesRouter").default);
v1.route("/cw", require("./routers/cwRouter").default);
app.route("/v1", v1);
export default app;
+102
View File
@@ -0,0 +1,102 @@
import { Socket } from "socket.io";
import { attachSocketEventPermissions } from "../middleware/authorization";
import { opportunities } from "../../../managers/opportunities";
const LIVE_QUOTE_PREVIEW_PERMISSION = "sales.opportunity.fetch";
export const registerLiveQuotePreviewHandlers = (socket: Socket) => {
attachSocketEventPermissions(socket, {
"opp:live_quote_preview": [LIVE_QUOTE_PREVIEW_PERMISSION],
});
const registeredLivePreviewEvents = new Set<string>();
socket.on(
"opp:live_quote_preview",
async (
payload: { id?: string | number },
ack?: (response: { ok: boolean; event?: string; error?: string }) => void,
) => {
const oppId = payload?.id;
const normalizedId =
typeof oppId === "string" || typeof oppId === "number"
? `${oppId}`
: "";
if (!normalizedId) {
if (ack) return ack({ ok: false, error: "Missing opportunity id" });
socket.emit("opp:live_quote_preview:error", {
message: "Missing opportunity id",
});
return;
}
const dataEvent = `opp:live_quote_preview:${normalizedId}:data`;
const previewEvent = `opp:live_quote_preview:${normalizedId}:preview`;
const roomName = `opp:live_quote_preview:${normalizedId}`;
if (!registeredLivePreviewEvents.has(dataEvent)) {
registeredLivePreviewEvents.add(dataEvent);
socket.join(roomName);
socket.on(dataEvent, async (data: any) => {
socket.to(roomName).emit(dataEvent, data);
try {
const opportunity = await opportunities.fetchRecord(normalizedId);
const opts =
data?.options && typeof data.options === "object"
? data.options
: data;
const previewBuffer = await opportunity.generateQuote({
lineItemPricing: opts?.lineItemPricing,
includeQuoteNarrative: opts?.includeQuoteNarrative,
includeItemNarratives: opts?.includeItemNarratives,
logoPath: opts?.logoPath,
showPreview: true,
});
const previewBase64 = Buffer.from(previewBuffer).toString("base64");
socket.to(roomName).emit(previewEvent, {
id: normalizedId,
mimeType: "application/pdf",
contentBase64: previewBase64,
});
socket.to(roomName).emit(dataEvent, {
id: normalizedId,
mimeType: "application/pdf",
contentBase64: previewBase64,
});
socket.emit(previewEvent, {
id: normalizedId,
mimeType: "application/pdf",
contentBase64: previewBase64,
});
socket.emit(dataEvent, {
id: normalizedId,
mimeType: "application/pdf",
contentBase64: previewBase64,
});
} catch (err: any) {
socket.emit("opp:live_quote_preview:error", {
message: err?.message ?? "Failed to generate live quote preview",
id: normalizedId,
});
}
});
}
if (ack) return ack({ ok: true, event: dataEvent });
socket.emit("opp:live_quote_preview:ready", {
id: normalizedId,
event: dataEvent,
});
},
);
};
+5
View File
@@ -0,0 +1,5 @@
import { setupSecureNamespace } from "./secure";
export const setupSockets = () => {
setupSecureNamespace();
};
@@ -0,0 +1,71 @@
import { Socket } from "socket.io";
import UserController from "../../../controllers/UserController";
type SecureSocket = Socket & {
data: {
user?: UserController;
[key: string]: unknown;
};
};
export const attachSocketEventPermissions = (
socket: Socket,
eventPermissions: Record<string, string[]>,
): boolean => {
const user = (socket.data?.user as UserController | undefined) ?? undefined;
if (!user) return false;
socket.use(async (packet, packetNext) => {
const eventName = packet[0];
if (typeof eventName !== "string") return packetNext();
const eventRequiredPermissions = eventPermissions[eventName] ?? [];
if (eventRequiredPermissions.length === 0) return packetNext();
const eventChecks = await Promise.all(
eventRequiredPermissions.map((permission) =>
user.hasPermission(permission),
),
);
if (eventChecks.includes(false)) {
return packetNext(new Error("Forbidden: insufficient permissions"));
}
return packetNext();
});
return true;
};
export const socketAuthMiddleware = (permParams?: {
permissions?: string[];
eventPermissions?: Record<string, string[]>;
}) => {
return async (socket: SecureSocket, next: (err?: Error) => void) => {
const user = socket.data.user;
if (!user) return next(new Error("Unauthorized"));
const requiredPermissions = permParams?.permissions ?? [];
if (requiredPermissions.length > 0) {
const permissionChecks = await Promise.all(
requiredPermissions.map((permission) => user.hasPermission(permission)),
);
if (permissionChecks.includes(false)) {
return next(new Error("Forbidden: insufficient permissions"));
}
}
const eventPermissions = permParams?.eventPermissions;
if (eventPermissions) {
const attached = attachSocketEventPermissions(socket, eventPermissions);
if (!attached) return next(new Error("Unauthorized"));
}
return next();
};
};
+128
View File
@@ -0,0 +1,128 @@
import { Namespace } from "socket.io";
import { io, prisma } from "../../constants";
import { sessions } from "../../managers/sessions";
import { socketAuthMiddleware } from "./middleware/authorization";
import { registerLiveQuotePreviewHandlers } from "./events/liveQuotePreview";
const SESSION_ENFORCEMENT_INTERVAL_MS = 60 * 1000;
const MAX_TIMEOUT_MS = 2_147_483_647;
const AUTH_HEADER_REGEX =
/^(Bearer|Key)\s([a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+)$/;
const resolveAuthorization = (handshake: {
auth?: Record<string, unknown>;
headers?: Record<string, unknown>;
}): string | null => {
const headerAuth = handshake.headers?.authorization;
if (typeof headerAuth === "string" && headerAuth.length > 0)
return headerAuth;
const authAuthorization = handshake.auth?.authorization;
if (typeof authAuthorization === "string" && authAuthorization.length > 0)
return authAuthorization;
const authToken = handshake.auth?.token;
if (typeof authToken === "string" && authToken.length > 0)
return `Bearer ${authToken}`;
return null;
};
export const setupSecureNamespace = (): Namespace => {
const secureNamespace = io.of("/secure");
secureNamespace.use(async (socket, next) => {
try {
const authorization = resolveAuthorization(socket.handshake as any);
if (!authorization)
return next(new Error("Unauthorized: missing authorization"));
const components = authorization.match(AUTH_HEADER_REGEX);
if (!components)
return next(new Error("Unauthorized: invalid authorization format"));
const authValue = components[2] ?? "";
const session = await sessions.fetch({ accessToken: authValue });
const user = await session.fetchUser();
socket.data.user = user;
socket.data.session = session;
return next();
} catch {
return next(new Error("Unauthorized"));
}
});
secureNamespace.use(socketAuthMiddleware());
secureNamespace.on("connection", (socket) => {
const sessionId = socket.data.session?.id as string | undefined;
const sessionExpiresAt = socket.data.session?.expires
? new Date(socket.data.session.expires).getTime()
: null;
const disconnectForSession = () => {
if (socket.disconnected) return;
socket.emit("secure:session:expired");
socket.disconnect(true);
};
let expiryTimeout: ReturnType<typeof setTimeout> | null = null;
const scheduleExpiryDisconnect = () => {
if (sessionExpiresAt === null) return;
const remainingMs = sessionExpiresAt - Date.now();
if (remainingMs <= 0) {
disconnectForSession();
return;
}
const delayMs = Math.min(remainingMs, MAX_TIMEOUT_MS);
expiryTimeout = setTimeout(scheduleExpiryDisconnect, delayMs);
};
scheduleExpiryDisconnect();
const interval = setInterval(async () => {
if (!sessionId) {
disconnectForSession();
return;
}
const session = await prisma.session.findFirst({
where: { id: sessionId },
select: { id: true, expires: true, invalidatedAt: true },
});
if (!session) {
disconnectForSession();
return;
}
if (session.invalidatedAt) {
disconnectForSession();
return;
}
if (session.expires.getTime() <= Date.now()) {
disconnectForSession();
}
}, SESSION_ENFORCEMENT_INTERVAL_MS);
socket.on("disconnect", () => {
clearInterval(interval);
if (expiryTimeout) clearTimeout(expiryTimeout);
});
registerLiveQuotePreviewHandlers(socket);
socket.emit("secure:connected", {
userId: socket.data.user?.id ?? null,
});
});
return secureNamespace;
};
+9 -6
View File
@@ -50,18 +50,21 @@ export class CompanyController {
const cwCompany = await fetchCwCompanyById(this.cw_CompanyId);
if (!cwCompany) return this;
const contactHref = cwCompany.defaultContact?._info?.contact_href;
const defaultContactData = contactHref
? await connectWiseApi.get(contactHref)
: undefined;
const allContactsData = await connectWiseApi.get(
`${cwCompany._info.contacts_href}&pageSize=1000`,
);
// Derive default contact from allContacts instead of a separate CW call
const defaultContactId = cwCompany.defaultContact?.id;
const defaultContactData = defaultContactId
? ((allContactsData.data as any[]).find(
(c: any) => c.id === defaultContactId,
) ?? null)
: null;
this.cw_Data = {
company: cwCompany,
defaultContact: defaultContactData?.data ?? null,
defaultContact: defaultContactData,
allContacts: allContactsData.data,
};
+86
View File
@@ -0,0 +1,86 @@
import type { CwMember } from "../../generated/prisma/client";
import type { CWMember } from "../modules/cw-utils/members/fetchAllMembers";
/**
* CW Member Controller
*
* Domain model class that encapsulates a ConnectWise Member entity,
* providing access to member data and serialization for the API.
*/
export class CwMemberController {
public readonly id: string;
public readonly cwMemberId: number;
public readonly identifier: string;
public firstName: string;
public lastName: string;
public officeEmail: string | null;
public inactiveFlag: boolean;
public apiKey: string | null;
public cwLastUpdated: Date | null;
public readonly createdAt: Date;
public readonly updatedAt: Date;
constructor(data: CwMember) {
this.id = data.id;
this.cwMemberId = data.cwMemberId;
this.identifier = data.identifier;
this.firstName = data.firstName;
this.lastName = data.lastName;
this.officeEmail = data.officeEmail;
this.inactiveFlag = data.inactiveFlag;
this.apiKey = data.apiKey;
this.cwLastUpdated = data.cwLastUpdated;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
/**
* Full Name
*
* Returns the member's full name, falling back to the identifier.
*/
public get fullName(): string {
const name = `${this.firstName} ${this.lastName}`.trim();
return name || this.identifier;
}
/**
* Map CW Member Prisma create/update payload
*
* Static helper used by both the controller and the refresh sync.
*/
public static mapCwToDb(item: CWMember) {
return {
identifier: item.identifier,
firstName: item.firstName ?? "",
lastName: item.lastName ?? "",
officeEmail: item.officeEmail ?? null,
inactiveFlag: item.inactiveFlag ?? false,
cwLastUpdated: item._info?.lastUpdated
? new Date(item._info.lastUpdated)
: new Date(),
};
}
/**
* To JSON
*
* Serializes the member into a safe, API-friendly object.
*/
public toJson(): Record<string, any> {
return {
id: this.id,
cwMemberId: this.cwMemberId,
identifier: this.identifier,
firstName: this.firstName,
lastName: this.lastName,
fullName: this.fullName,
officeEmail: this.officeEmail,
inactiveFlag: this.inactiveFlag,
apiKey: this.apiKey,
cwLastUpdated: this.cwLastUpdated,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}
@@ -23,6 +23,8 @@ export class ForecastProductController {
public catalogItemIdentifier: string | null;
public productDescription: string;
public customerDescription: string | null;
public productNarrative: string | null;
public productClass: string;
public forecastType: string;
@@ -74,6 +76,9 @@ export class ForecastProductController {
this.catalogItemIdentifier = data.catalogItem?.identifier ?? null;
this.productDescription = data.productDescription;
this.customerDescription = data.customerDescription ?? null;
this.productNarrative =
data.customFields?.find((f) => f.id === 46)?.value?.toString() ?? null;
this.productClass = data.productClass;
this.forecastType = data.forecastType;
@@ -118,6 +123,24 @@ export class ForecastProductController {
* Enriches this forecast product with cancellation data from the
* procurement products endpoint.
*/
/**
* Apply Procurement Custom Fields
*
* Enriches this forecast product with custom field data from the
* procurement products endpoint (the forecast endpoint does not
* return customFields).
*/
public applyProcurementCustomFields(data: {
customFields?: Array<{ id: number; value?: unknown }>;
}): void {
const narrative = data.customFields
?.find((f) => f.id === 46)
?.value?.toString();
if (narrative) {
this.productNarrative = narrative;
}
}
public applyCancellationData(data: {
cancelledFlag?: boolean;
quantityCancelled?: number;
@@ -154,6 +177,38 @@ export class ForecastProductController {
return this.revenue - this.cost;
}
/**
* Effective Quantity
*
* Returns the quantity adjusted for cancellations (minimum 0).
*/
public get effectiveQuantity(): number {
if (this.cancellationType === "full") return 0;
return Math.max(0, this.quantity - this.quantityCancelled);
}
/**
* Effective Revenue
*
* Returns the revenue adjusted proportionally for cancelled units.
*/
public get effectiveRevenue(): number {
if (this.cancellationType === "full" || this.quantity <= 0) return 0;
const unitPrice = this.revenue / this.quantity;
return unitPrice * this.effectiveQuantity;
}
/**
* Effective Cost
*
* Returns the cost adjusted proportionally for cancelled units.
*/
public get effectiveCost(): number {
if (this.cancellationType === "full" || this.quantity <= 0) return 0;
const unitCost = this.cost / this.quantity;
return unitCost * this.effectiveQuantity;
}
/**
* Cancelled
*
@@ -201,12 +256,17 @@ export class ForecastProductController {
? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier }
: null,
productDescription: this.productDescription,
customerDescription: this.customerDescription,
productNarrative: this.productNarrative,
productClass: this.productClass,
forecastType: this.forecastType,
revenue: this.revenue,
cost: this.cost,
margin: this.margin,
profit: this.profit,
effectiveQuantity: this.effectiveQuantity,
effectiveRevenue: this.effectiveRevenue,
effectiveCost: this.effectiveCost,
percentage: this.percentage,
includeFlag: this.includeFlag,
linkFlag: this.linkFlag,
+131
View File
@@ -0,0 +1,131 @@
import {
GeneratedQuotes,
Opportunity,
Role,
User,
} from "../../generated/prisma/client";
import { prisma } from "../constants";
import { OpportunityController } from "./OpportunityController";
import UserController from "./UserController";
export class GeneratedQuoteController {
public readonly id: string;
public quoteRegenData: unknown;
public quoteRegenParams: unknown;
public quoteRegenHash: string;
public downloads: unknown[];
public quoteFile: Uint8Array;
public quoteFileName: string;
public opportunityId: string;
public createdById: string | null;
public createdAt: Date;
public updatedAt: Date;
private _opportunity: OpportunityController | null;
private _createdBy: UserController | null;
constructor(
data: GeneratedQuotes & {
opportunity?: Opportunity | null;
createdBy?: (User & { roles: Role[] }) | null;
},
) {
this.id = data.id;
this.quoteRegenData = data.quoteRegenData;
this.quoteRegenParams = data.quoteRegenParams;
this.quoteRegenHash = data.quoteRegenHash;
this.downloads = Array.isArray(data.downloads)
? (data.downloads as unknown[])
: [];
this.quoteFile = data.quoteFile;
this.quoteFileName = data.quoteFileName;
this.opportunityId = data.opportunityId;
this.createdById = data.createdById;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
this._opportunity = data.opportunity
? new OpportunityController(data.opportunity)
: null;
this._createdBy = data.createdBy
? new UserController(data.createdBy)
: null;
}
public async fetchOpportunity(): Promise<OpportunityController | null> {
if (this._opportunity) return this._opportunity;
const opportunity = await prisma.opportunity.findFirst({
where: { id: this.opportunityId },
});
if (!opportunity) return null;
this._opportunity = new OpportunityController(opportunity);
return this._opportunity;
}
public async fetchCreatedBy(): Promise<UserController | null> {
if (this._createdBy) return this._createdBy;
if (!this.createdById) return null;
const user = await prisma.user.findFirst({
where: { id: this.createdById },
include: { roles: true },
});
if (!user) return null;
this._createdBy = new UserController(user);
return this._createdBy;
}
public toJson(opts?: {
includeFile?: boolean;
encodeFileAsBase64?: boolean;
includeRegenData?: boolean;
includeRegenParams?: boolean;
includeDownloads?: boolean;
includeOpportunity?: boolean;
includeCreatedBy?: boolean;
}): Record<string, any> {
return {
id: this.id,
quoteFileName: this.quoteFileName,
quoteRegenHash: this.quoteRegenHash,
opportunityId: this.opportunityId,
createdById: this.createdById,
downloads: opts?.includeDownloads ? this.downloads : undefined,
quoteRegenData: opts?.includeRegenData ? this.quoteRegenData : undefined,
quoteRegenParams: opts?.includeRegenParams
? this.quoteRegenParams
: undefined,
quoteFile: !opts?.includeFile
? undefined
: opts?.encodeFileAsBase64
? Buffer.from(this.quoteFile).toString("base64")
: this.quoteFile,
opportunity:
opts?.includeOpportunity && this._opportunity
? this._opportunity.toJson()
: undefined,
createdBy:
opts?.includeCreatedBy && this._createdBy
? this._createdBy.toJson()
: undefined,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}
+721 -13
View File
@@ -14,12 +14,20 @@ import {
CWForecastItemCreate,
CWOpportunity,
CWOpportunityNote,
CWOpportunityUpdate,
CWProcurementProduct,
CWProcurementProductCreate,
} from "../modules/cw-utils/opportunities/opportunity.types";
import { resolveMember } from "../modules/cw-utils/members/memberCache";
import {
resolveMember,
resolveMembers,
getMemberCache,
} from "../modules/cw-utils/members/memberCache";
import { ForecastProductController } from "./ForecastProductController";
import GenericError from "../Errors/GenericError";
import { computeSubResourceCacheTTL } from "../modules/algorithms/computeSubResourceCacheTTL";
import { computeProductsCacheTTL } from "../modules/algorithms/computeProductsCacheTTL";
import UserController from "./UserController";
import {
getCachedNotes,
getCachedContacts,
@@ -32,6 +40,13 @@ import {
invalidateNotesCache,
invalidateProductsCache,
} from "../modules/cache/opportunityCache";
import {
generateQuote as generateQuotePdf,
type QuoteMetadata,
} from "../modules/pdf-utils";
import { generatedQuotes } from "../managers/generatedQuotes";
import { getExpectedSalesTaxRate } from "../modules/sales-utils/expectedSalesTax";
import { normalizeProbabilityPercent } from "../modules/sales-utils/normalizeProbability";
/**
* Opportunity Controller
@@ -76,6 +91,7 @@ export class OpportunityController {
public customerPO: string | null;
public totalSalesTax: number;
public probability: number;
public locationName: string | null;
public locationCwId: number | null;
@@ -92,6 +108,7 @@ export class OpportunityController {
public companyId: string | null;
public cwLastUpdated: Date | null;
public cwDateEntered: Date | null;
// Local product display order — array of CW forecast item IDs.
// When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
@@ -126,6 +143,29 @@ export class OpportunityController {
});
}
/**
* Resolve primary sales rep info for quote generation.
*
* Looks up the primary sales rep in the CW member cache and returns
* their name and email. Returns undefined if no rep is assigned.
*/
private async _resolveSalesRep(): Promise<
{ name: string; email?: string } | undefined
> {
if (!this.primarySalesRepIdentifier) return undefined;
const cache = await getMemberCache();
const member = cache.get(this.primarySalesRepIdentifier);
const name = member
? `${member.firstName} ${member.lastName}`.trim() ||
this.primarySalesRepName ||
this.primarySalesRepIdentifier
: (this.primarySalesRepName ?? this.primarySalesRepIdentifier);
return {
name,
email: member?.officeEmail ?? undefined,
};
}
constructor(
data: Opportunity & { company?: Company | null },
opts?: {
@@ -169,6 +209,7 @@ export class OpportunityController {
this.customerPO = data.customerPO;
this.totalSalesTax = data.totalSalesTax;
this.probability = data.probability;
this.locationName = data.locationName;
this.locationCwId = data.locationCwId;
@@ -185,6 +226,7 @@ export class OpportunityController {
this.companyId = data.companyId;
this.cwLastUpdated = data.cwLastUpdated;
this.cwDateEntered = data.cwDateEntered ?? null;
this.productSequence = data.productSequence;
this.createdAt = data.createdAt;
@@ -198,6 +240,18 @@ export class OpportunityController {
this._activities = opts?.activities ?? null;
}
/**
* Hydrate Custom Fields
*
* Lazily fetches the opportunity's custom fields from ConnectWise
* if they haven't been loaded yet.
*/
private async _hydrateCustomFields(): Promise<void> {
if (this._customFields !== null) return;
const cwData = await fetchOpportunity(this.cwOpportunityId);
this._customFields = cwData.customFields ?? [];
}
/**
* Fetch Company
*
@@ -243,6 +297,30 @@ export class OpportunityController {
return new OpportunityController(updated);
}
/**
* Update Opportunity
*
* Patches the opportunity in ConnectWise with the provided fields,
* then syncs the updated data back to the local database.
*
* @param data Partial fields to update on the CW opportunity
* @returns A fresh OpportunityController with the updated data
*/
public async updateOpportunity(
data: CWOpportunityUpdate,
): Promise<OpportunityController> {
const cwData = await opportunityCw.update(this.cwOpportunityId, data);
const mapped = OpportunityController.mapCwToDb(cwData);
const updated = await prisma.opportunity.update({
where: { id: this.id },
data: mapped,
include: { company: true },
});
return new OpportunityController(updated);
}
/**
* Fetch raw CW data
*
@@ -292,6 +370,7 @@ export class OpportunityController {
customerPO: item.customerPO ?? null,
totalSalesTax: item.totalSalesTax ?? 0,
probability: normalizeProbabilityPercent(item.probability?.name),
locationName: item.location?.name ?? null,
locationCwId: item.location?.id ?? null,
@@ -315,6 +394,9 @@ export class OpportunityController {
cwLastUpdated: item._info?.lastUpdated
? new Date(item._info.lastUpdated)
: new Date(),
cwDateEntered: item._info?.dateEntered
? new Date(item._info.dateEntered)
: null,
};
}
@@ -429,18 +511,25 @@ export class OpportunityController {
/** Serialize raw CW note data into the API response shape. */
private async _serializeNotes(notes: any[]) {
return Promise.all(
notes.map(async (n: any) => ({
// Batch-resolve all member identifiers in a single DB query
const identifiers = notes
.map((n: any) => n.enteredBy as string)
.filter(Boolean);
const memberMap = await resolveMembers(identifiers);
return notes.map((n: any) => ({
id: n.id,
text: n.text,
type: n.type ? { id: n.type.id, name: n.type.name } : null,
flagged: n.flagged,
dateEntered: n._info?.lastUpdated
? new Date(n._info.lastUpdated)
: null,
enteredBy: await resolveMember(n.enteredBy),
})),
);
dateEntered: n._info?.lastUpdated ? new Date(n._info.lastUpdated) : null,
enteredBy: memberMap.get(n.enteredBy) ?? {
id: null,
identifier: n.enteredBy,
name: n.enteredBy,
cwMemberId: null,
},
}));
}
/**
@@ -524,6 +613,397 @@ export class OpportunityController {
return this._buildProductControllers(forecast, procProducts);
}
/**
* Generate Quote PDF
*
* Builds a customer-facing quote PDF using the opportunity, company, site,
* and product data available to this controller.
*/
public async generateQuote(opts?: {
lineItemPricing?: boolean;
includeQuoteNarrative?: boolean;
includeItemNarratives?: boolean;
showPreview?: boolean; // INTERNAL ONLY
logoPath?: string;
metadata?: QuoteMetadata;
}): Promise<Buffer> {
const options = {
lineItemPricing: opts?.lineItemPricing ?? true,
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
includeItemNarratives: opts?.includeItemNarratives ?? true,
showPreview: opts?.showPreview ?? false,
logoPath: opts?.logoPath,
};
const products = await this.fetchProducts();
const activeProducts = products.filter(
(item) => item.includeFlag && item.cancellationType !== "full",
);
if (activeProducts.length === 0) {
throw new GenericError({
status: 400,
name: "QuoteGenerationError",
message: "Cannot generate a quote with no included line items",
});
}
const company = await this.fetchCompany();
const companyJson = company?.toJson({
includeAddress: true,
includePrimaryContact: true,
includeAllContacts: false,
});
const site = await this.fetchSite();
const siteAddress = [
site?.address?.line1,
site?.address?.line2,
[site?.address?.city, site?.address?.state, site?.address?.zip]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const companyAddress = [
companyJson?.cw_Data?.address?.line1,
companyJson?.cw_Data?.address?.line2,
[
companyJson?.cw_Data?.address?.city,
companyJson?.cw_Data?.address?.state,
companyJson?.cw_Data?.address?.zip,
]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const addressLines = siteAddress.length > 0 ? siteAddress : companyAddress;
const lineItems = activeProducts.map((item) => {
const isLabor = item.productClass === "Service";
const quantity = item.effectiveQuantity > 0 ? item.effectiveQuantity : 1;
const lineTotal = Number.isFinite(item.revenue) ? item.revenue : 0;
const unitPrice = isLabor ? lineTotal : lineTotal / quantity;
const itemNarrative = item.productNarrative || null;
const shouldIncludeNarrative =
options.includeItemNarratives && !!itemNarrative;
return {
qty: isLabor ? 1 : quantity,
description: item.productDescription || "Line Item",
unitPrice,
narrative: shouldIncludeNarrative ? itemNarrative : undefined,
};
});
const taxableSubTotal = activeProducts.reduce((sum, item) => {
if (!item.taxableFlag) return sum;
const isLabor = item.productClass === "Service";
const quantity = item.effectiveQuantity > 0 ? item.effectiveQuantity : 1;
const lineTotal = Number.isFinite(item.revenue) ? item.revenue : 0;
const unitPrice = isLabor ? lineTotal : lineTotal / quantity;
return sum + (isLabor ? 1 : quantity) * unitPrice;
}, 0);
const quoteDescription = this.name;
const primaryContactFullName = [
companyJson?.cw_Data?.primaryContact?.firstName,
companyJson?.cw_Data?.primaryContact?.lastName,
]
.filter(Boolean)
.join(" ")
.trim();
const customerName =
this.contactName ||
primaryContactFullName ||
this.companyName ||
"Customer";
const normalizedTaxRate = getExpectedSalesTaxRate(
site?.address ?? companyJson?.cw_Data?.address,
);
const taxLabel =
normalizedTaxRate > 0
? `Sales Tax (${(normalizedTaxRate * 100).toFixed(2)}%)`
: "Sales Tax";
await this._hydrateCustomFields();
const quoteNarrativeField = options.includeQuoteNarrative
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
undefined
: undefined;
// Fall back to the customerDescription of a QUO-Narrative product
const quoNarrativeProduct = !quoteNarrativeField
? activeProducts.find((p) => p.catalogItemIdentifier === "QUO-Narrative")
: undefined;
const quoteNarrative =
quoteNarrativeField ??
quoNarrativeProduct?.customerDescription ??
undefined;
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
// Only show attention if it differs from the customer name
const attention =
this.contactName && this.contactName !== customerName
? this.contactName
: undefined;
// Only show company if it's meaningfully different from the customer name
// (catches "Patterson, Diane" vs "Diane Patterson" style duplicates)
const normalise = (s: string) =>
s
.toLowerCase()
.replace(/[,.\s]+/g, " ")
.trim()
.split(" ")
.sort()
.join(" ");
const showCompany = normalise(companyLine) !== normalise(customerName);
return generateQuotePdf(
{
customer: {
name: customerName,
company: showCompany ? companyLine : undefined,
attention,
address:
addressLines.length > 0 ? addressLines : ["Address unavailable"],
},
contact: {
email: companyJson?.cw_Data?.primaryContact?.email ?? undefined,
phone: companyJson?.cw_Data?.primaryContact?.phone ?? undefined,
},
salesRep: await this._resolveSalesRep(),
quote: {
quoteNumber: this.cwOpportunityId.toString(),
date: new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}),
description: quoteDescription,
},
lineItems,
taxableSubtotal: taxableSubTotal,
quoteNarrative,
tax: {
rate: normalizedTaxRate,
label: taxLabel,
},
isPreview: options.showPreview,
showLineItemPricing: options.lineItemPricing,
metadata: opts?.metadata,
},
{},
options.logoPath,
);
}
/**
* Commit Quote
*
* Generates a non-preview quote PDF and stores it in the GeneratedQuotes
* table with a full data snapshot for exact reproduction, regeneration
* metadata, and creator attribution.
*/
public async commitQuote(
opts: {
lineItemPricing?: boolean;
includeQuoteNarrative?: boolean;
includeItemNarratives?: boolean;
logoPath?: string;
} = {},
user: UserController,
) {
const quoteOptions = {
lineItemPricing: opts?.lineItemPricing ?? true,
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
includeItemNarratives: opts?.includeItemNarratives ?? true,
logoPath: opts?.logoPath,
};
// ── Fetch all data sources BEFORE generating ──────────────────────
const products = await this.fetchProducts();
const company = await this.fetchCompany();
const companyJson = company?.toJson({
includeAddress: true,
includePrimaryContact: true,
includeAllContacts: false,
});
const site = await this.fetchSite();
const salesRep = await this._resolveSalesRep();
await this._hydrateCustomFields();
const quoteNarrativeField = quoteOptions.includeQuoteNarrative
? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ??
null)
: null;
// Fall back to the customerDescription of a QUO-Narrative product
const quoNarrativeProduct = !quoteNarrativeField
? products.find((p) => p.catalogItemIdentifier === "QUO-Narrative")
: undefined;
const quoteNarrative =
quoteNarrativeField ?? quoNarrativeProduct?.customerDescription ?? null;
// ── Pre-generate IDs & timestamps for metadata ───────────────────
const quoteId = crypto.randomUUID();
const createdAt = new Date().toISOString();
// ── Generate the PDF ──────────────────────────────────────────────
const quoteBuffer = await this.generateQuote({
...quoteOptions,
showPreview: false,
metadata: {
quoteId,
createdById: user.id,
createdByName: user.name ?? undefined,
createdByEmail: user.email ?? undefined,
createdAt,
},
});
const fileTimestamp = createdAt.replace(/[:.]/g, "-");
const quoteFileName = `OPP-${this.cwOpportunityId}-${fileTimestamp}.pdf`;
// ── Build the full data snapshot ──────────────────────────────────
const siteAddress = [
site?.address?.line1,
site?.address?.line2,
[site?.address?.city, site?.address?.state, site?.address?.zip]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const companyAddress = [
companyJson?.cw_Data?.address?.line1,
companyJson?.cw_Data?.address?.line2,
[
companyJson?.cw_Data?.address?.city,
companyJson?.cw_Data?.address?.state,
companyJson?.cw_Data?.address?.zip,
]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const primaryContactFullName = [
companyJson?.cw_Data?.primaryContact?.firstName,
companyJson?.cw_Data?.primaryContact?.lastName,
]
.filter(Boolean)
.join(" ")
.trim();
const regenData = {
// Generation options
options: {
lineItemPricing: quoteOptions.lineItemPricing,
includeQuoteNarrative: quoteOptions.includeQuoteNarrative,
includeItemNarratives: quoteOptions.includeItemNarratives,
},
// Opportunity metadata
opportunity: {
id: this.id,
cwOpportunityId: this.cwOpportunityId,
name: this.name,
totalSalesTax: this.totalSalesTax,
contactName: this.contactName,
companyName: this.companyName,
},
// Customer / company / site snapshot
customer: {
preparedFor:
this.contactName ||
primaryContactFullName ||
this.companyName ||
"Customer",
companyName: this.companyName ?? company?.name ?? null,
primaryContact: companyJson?.cw_Data?.primaryContact
? {
firstName: companyJson.cw_Data.primaryContact.firstName ?? null,
lastName: companyJson.cw_Data.primaryContact.lastName ?? null,
email: companyJson.cw_Data.primaryContact.email ?? null,
phone: companyJson.cw_Data.primaryContact.phone ?? null,
}
: null,
siteAddress: siteAddress.length > 0 ? siteAddress : null,
companyAddress: companyAddress.length > 0 ? companyAddress : null,
},
// Sales rep snapshot
salesRep: salesRep ?? null,
// Quote narrative
quoteNarrative: quoteNarrative ?? null,
// Full product snapshot
products: products.map((p) => ({
cwForecastId: p.cwForecastId,
forecastDescription: p.forecastDescription,
productDescription: p.productDescription,
customerDescription: p.customerDescription,
productNarrative: p.productNarrative,
productClass: p.productClass,
forecastType: p.forecastType,
catalogItem: p.catalogItemCwId
? { id: p.catalogItemCwId, identifier: p.catalogItemIdentifier }
: null,
quantity: p.quantity,
effectiveQuantity: p.effectiveQuantity,
revenue: p.revenue,
cost: p.cost,
margin: p.margin,
percentage: p.percentage,
includeFlag: p.includeFlag,
taxableFlag: p.taxableFlag,
recurringFlag: p.recurringFlag,
recurringRevenue: p.recurringRevenue,
recurringCost: p.recurringCost,
sequenceNumber: p.sequenceNumber,
cancelledFlag: p.cancelledFlag,
cancellationType: p.cancellationType,
quantityCancelled: p.quantityCancelled,
cancelledReason: p.cancelledReason,
cancelledDate: p.cancelledDate,
})),
// Timestamp of when this snapshot was taken
snapshotTimestamp: new Date().toISOString(),
};
const regenParams = {
opportunityId: this.id,
cwOpportunityId: this.cwOpportunityId,
};
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(JSON.stringify({ regenData, regenParams }));
const quoteRegenHash = hasher.digest("hex");
return generatedQuotes.create({
id: quoteId,
quoteRegenData: regenData,
quoteRegenParams: regenParams,
quoteRegenHash,
quoteFile: quoteBuffer,
quoteFileName,
opportunityId: this.id,
createdById: user.id,
});
}
/**
* Build ForecastProductController[] from raw CW data.
*
@@ -537,15 +1017,25 @@ export class OpportunityController {
// Build a map of forecastDetailId → procurement product cancellation data
const cancellationMap = new Map<number, Record<string, unknown>>();
for (const pp of procProducts) {
const forecastDetailId = pp.forecastDetailId as number | undefined;
if (forecastDetailId) {
const rawForecastDetailId = (pp as any)?.forecastDetailId;
const forecastDetailId =
typeof rawForecastDetailId === "number"
? rawForecastDetailId
: Number(rawForecastDetailId);
if (Number.isFinite(forecastDetailId) && forecastDetailId > 0) {
cancellationMap.set(forecastDetailId, pp);
}
}
// Procurement-only view: only include forecast items that have a
// matching procurement record (via forecastDetailId).
const forecastItems = (forecast.forecastItems ?? []).filter((fi: any) =>
cancellationMap.has(fi.id),
);
// Apply local ordering if productSequence is set, otherwise fall back
// to CW sequenceNumber.
const forecastItems = forecast.forecastItems ?? [];
let ordered: typeof forecastItems;
if (this.productSequence.length > 0) {
@@ -571,6 +1061,7 @@ export class OpportunityController {
const procData = cancellationMap.get(item.id);
if (procData) {
ctrl.applyCancellationData(procData as any);
ctrl.applyProcurementCustomFields(procData as any);
}
return ctrl;
},
@@ -760,6 +1251,41 @@ export class OpportunityController {
return this.fetchProducts();
}
/**
* Append Product Sequence IDs
*
* Adds newly created forecast item IDs to the end of the local
* productSequence array, preserving existing order and avoiding duplicates.
*/
private async appendProductSequenceIds(ids: number[]): Promise<void> {
const normalizedIds = ids.filter(
(id): id is number => Number.isInteger(id) && id > 0,
);
if (normalizedIds.length === 0) return;
const current = await prisma.opportunity.findUnique({
where: { id: this.id },
select: { productSequence: true },
});
const existing = current?.productSequence ?? [];
const existingSet = new Set(existing);
const idsToAppend = normalizedIds.filter((id) => !existingSet.has(id));
if (idsToAppend.length === 0) {
this.productSequence = existing;
return;
}
const updatedSequence = [...existing, ...idsToAppend];
await prisma.opportunity.update({
where: { id: this.id },
data: { productSequence: updatedSequence },
});
this.productSequence = updatedSequence;
}
/**
* Add Products
*
@@ -778,6 +1304,7 @@ export class OpportunityController {
this.cwOpportunityId,
data,
);
await this.appendProductSequenceIds(created.map((item) => item.id));
await invalidateProductsCache(this.cwOpportunityId);
return created.map((item) => new ForecastProductController(item));
} catch (err: any) {
@@ -806,6 +1333,170 @@ export class OpportunityController {
}
}
/**
* Add Procurement Products
*
* Creates one or more procurement products linked to this opportunity.
* Use this when payloads include procurement-only fields such as customFields.
*/
public async addProcurementProducts(
data: CWProcurementProductCreate | CWProcurementProductCreate[],
): Promise<CWProcurementProduct[]> {
try {
const items = Array.isArray(data) ? data : [data];
const normalized = items.map((item) => ({
...item,
opportunity: { id: this.cwOpportunityId },
}));
const created = await opportunityCw.createProcurementProducts(normalized);
await this.appendProductSequenceIds(
created
.map((item) => item.forecastDetailId)
.filter((id): id is number => typeof id === "number"),
);
await invalidateProductsCache(this.cwOpportunityId);
return created;
} catch (err: any) {
console.error(
`[addProcurementProducts] Failed to create procurement product(s) on opportunity ${this.cwOpportunityId}`,
JSON.stringify(
{
data,
status: err?.response?.status,
statusText: err?.response?.statusText,
responseData: err?.response?.data,
message: err?.message,
},
null,
2,
),
);
throw new GenericError({
status: err?.response?.status ?? 500,
name: "AddProcurementProductFailed",
message:
err?.response?.data?.message ??
"Failed to add procurement product(s) to opportunity",
cause: err?.message,
});
}
}
/**
* Delete Product
*
* Removes a forecast item from this opportunity in ConnectWise,
* removes the item ID from the local productSequence, and
* invalidates the products cache.
*
* @param forecastItemId - The CW forecast item ID to delete
*/
public async deleteProduct(forecastItemId: number): Promise<void> {
await opportunityCw.deleteProduct(this.cwOpportunityId, forecastItemId);
// Remove the deleted item from the local product sequence
if (this.productSequence.includes(forecastItemId)) {
const updatedSequence = this.productSequence.filter(
(id) => id !== forecastItemId,
);
await prisma.opportunity.update({
where: { id: this.id },
data: { productSequence: updatedSequence },
});
this.productSequence = updatedSequence;
}
await invalidateProductsCache(this.cwOpportunityId);
}
/**
* Fetch Procurement Product By Forecast Item
*
* Returns the linked procurement product for a forecast item ID,
* or null when no procurement record exists.
*/
public async fetchProcurementProductByForecastItem(
forecastItemId: number,
): Promise<CWProcurementProduct | null> {
return opportunityCw.fetchProcurementProductByForecastDetail(
this.cwOpportunityId,
forecastItemId,
);
}
/**
* Update Procurement Product By Forecast Item
*
* Finds the linked procurement product for a forecast item and updates it.
* Returns null when no linked procurement product exists.
*/
public async updateProcurementProductByForecastItem(
forecastItemId: number,
data: Record<string, unknown>,
): Promise<CWProcurementProduct | null> {
const linked =
await this.fetchProcurementProductByForecastItem(forecastItemId);
if (!linked?.id) return null;
const updated = await opportunityCw.updateProcurementProduct(
linked.id,
data,
);
await invalidateProductsCache(this.cwOpportunityId);
return updated;
}
/**
* Set Product Cancellation
*
* Updates cancellation fields on the procurement product linked to a
* forecast item. A quantity of 0 is treated as uncancelled.
*/
public async setProductCancellation(
forecastItemId: number,
opts: { quantityCancelled: number; cancellationReason?: string | null },
): Promise<CWProcurementProduct> {
const linked =
await this.fetchProcurementProductByForecastItem(forecastItemId);
if (!linked?.id) {
throw new GenericError({
status: 404,
name: "ProcurementProductNotFound",
message:
"No linked procurement product found for the specified forecast item",
});
}
const quantityCancelled = Math.max(0, Math.trunc(opts.quantityCancelled));
const cancelledFlag = quantityCancelled > 0;
const updated = await this.updateProcurementProductByForecastItem(
forecastItemId,
{
quantityCancelled,
cancelledFlag,
cancelledReason: cancelledFlag
? (opts.cancellationReason ?? null)
: null,
},
);
if (!updated) {
throw new GenericError({
status: 404,
name: "ProcurementProductNotFound",
message:
"No linked procurement product found for the specified forecast item",
});
}
return updated;
}
/**
* Add Note
*
@@ -867,11 +1558,26 @@ export class OpportunityController {
* Serializes the opportunity into a safe, API-friendly object.
*/
public toJson(): Record<string, any> {
const siteAddress = this._siteData?.address;
const companyAddress = this._company?.cw_Data?.company
? {
line1: this._company.cw_Data.company.addressLine1,
line2: this._company.cw_Data.company.addressLine2,
city: this._company.cw_Data.company.city,
state: this._company.cw_Data.company.state,
zip: this._company.cw_Data.company.zip,
country: this._company.cw_Data.company.country?.name ?? null,
}
: null;
const expectedSalesTax = getExpectedSalesTaxRate(
siteAddress ?? companyAddress,
);
return {
id: this.id,
cwOpportunityId: this.cwOpportunityId,
name: this.name,
notes: this.notes,
description: this.notes,
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
stage: this.stageCwId
? { id: this.stageCwId, name: this.stageName }
@@ -922,6 +1628,8 @@ export class OpportunityController {
: null,
customerPO: this.customerPO,
totalSalesTax: this.totalSalesTax,
expectedSalesTax,
probability: this.probability,
location: this.locationCwId
? { id: this.locationCwId, name: this.locationName }
: null,
+58 -9
View File
@@ -1,5 +1,6 @@
import { refresh } from "./api/auth";
import app from "./api/server";
import { setupSockets } from "./api/sockets";
import {
engine,
PORT,
@@ -12,9 +13,12 @@ import { unifiSites } from "./managers/unifiSites";
import { refreshCompanies } from "./modules/cw-utils/refreshCompanies";
import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog";
import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory";
import { listenInventoryAdjustments } from "./modules/cw-utils/procurement/listenInventoryAdjustments";
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
import { refreshSalesOpportunityMetricsCache } from "./modules/cache/salesOpportunityMetricsCache";
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
import { refreshCwMembers } from "./modules/cw-utils/members/refreshCwMembers";
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
import { events, setupEventDebugger } from "./modules/globalEvents";
import { signPermissions } from "./modules/permission-utils/signPermissions";
@@ -67,6 +71,9 @@ Bun.serve({
console.log(`[startup] Server listening on port ${PORT}`);
setupSockets();
console.log("[startup] Socket namespaces initialized");
// ---------------------------------------------------------------------------
// Background initialisation — none of this blocks the server.
// ---------------------------------------------------------------------------
@@ -105,25 +112,37 @@ setInterval(() => {
);
}, 60 * 1000);
// Refresh the internal catalog every minute
// Refresh the internal catalog every 30 minutes
await safeStartup("refreshCatalog", refreshCatalog);
setInterval(() => {
setInterval(
() => {
return refreshCatalog().catch((err) =>
console.error(`[interval] refreshCatalog failed: ${briefErr(err)}`),
);
}, 60 * 1000);
},
30 * 60 * 1000,
);
// Refresh inventory on hand every 2 minutes
await safeStartup("refreshInventory", refreshInventory);
// Fallback full inventory sweep every 6 hours (listener handles real-time deltas)
setInterval(
() => {
return refreshInventory().catch((err) =>
console.error(`[interval] refreshInventory failed: ${briefErr(err)}`),
);
},
2 * 60 * 1000,
6 * 60 * 60 * 1000,
);
// Listen for procurement adjustment changes and sync changed products to DB + cache
await safeStartup("listenInventoryAdjustments", listenInventoryAdjustments);
setInterval(() => {
return listenInventoryAdjustments().catch((err) =>
console.error(
`[interval] listenInventoryAdjustments failed: ${briefErr(err)}`,
),
);
}, 60 * 1000);
// Refresh opportunities every minute
await safeStartup("refreshOpportunities", refreshOpportunities);
setInterval(() => {
@@ -132,17 +151,20 @@ setInterval(() => {
);
}, 60 * 1000);
// Refresh opportunity CW cache every 30 seconds (activities + company hydration)
// Refresh opportunity CW cache every 20 minutes (activities + company hydration)
// NOTE: Do NOT await — register the interval immediately so the cache refresh
// is never blocked by a slow/stuck startup task above.
safeStartup("refreshOpportunityCache", refreshOpportunityCache);
setInterval(() => {
setInterval(
() => {
return refreshOpportunityCache().catch((err) => {
console.error(
`[interval] refreshOpportunityCache failed: ${briefErr(err)}`,
);
});
}, 30 * 1000);
},
20 * 60 * 1000,
);
// Refresh User Defined Fields every 5 minutes
await safeStartup("refreshUDFs", () => userDefinedFieldsCw.refresh());
@@ -157,6 +179,22 @@ setInterval(
5 * 60 * 1000,
);
// Refresh sales opportunity metrics cache for active CW members every 5 minutes
await safeStartup(
"refreshSalesOpportunityMetricsCache",
() => refreshSalesOpportunityMetricsCache({ forceColdLoad: true }),
);
setInterval(
() => {
return refreshSalesOpportunityMetricsCache().catch((err) =>
console.error(
`[interval] refreshSalesOpportunityMetricsCache failed: ${briefErr(err)}`,
),
);
},
5 * 60 * 1000,
);
// Refresh CW identifiers for all users every 30 minutes
await safeStartup("refreshCwIdentifiers", refreshCwIdentifiers);
setInterval(
@@ -168,6 +206,17 @@ setInterval(
30 * 60 * 1000,
);
// Refresh CW members DB table every hour
await safeStartup("refreshCwMembers", refreshCwMembers);
setInterval(
() => {
return refreshCwMembers().catch((err) =>
console.error(`[interval] refreshCwMembers failed: ${briefErr(err)}`),
);
},
60 * 60 * 1000,
);
await safeStartup("syncSites", () => unifiSites.syncSites());
setInterval(() => {
return unifiSites
+77
View File
@@ -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);
},
};
+188
View File
@@ -0,0 +1,188 @@
import { prisma } from "../constants";
import GenericError from "../Errors/GenericError";
import { GeneratedQuoteController } from "../controllers/GeneratedQuoteController";
const generatedQuoteInclude = {
opportunity: true,
createdBy: {
include: {
roles: true,
},
},
} as const;
export const generatedQuotes = {
async fetch(id: string): Promise<GeneratedQuoteController> {
const quote = await prisma.generatedQuotes.findFirst({
where: { id },
include: generatedQuoteInclude,
});
if (!quote) {
throw new GenericError({
message: "Generated quote not found",
name: "GeneratedQuoteNotFound",
cause: `No generated quote exists with ID '${id}'`,
status: 404,
});
}
return new GeneratedQuoteController(quote);
},
async fetchByOpportunity(
opportunityId: string,
): Promise<GeneratedQuoteController[]> {
const rows = await prisma.generatedQuotes.findMany({
where: { opportunityId },
include: generatedQuoteInclude,
orderBy: { createdAt: "desc" },
});
return rows.map((row) => new GeneratedQuoteController(row));
},
async fetchByCreator(
createdById: string,
): Promise<GeneratedQuoteController[]> {
const rows = await prisma.generatedQuotes.findMany({
where: { createdById },
include: generatedQuoteInclude,
orderBy: { createdAt: "desc" },
});
return rows.map((row) => new GeneratedQuoteController(row));
},
async fetchByHash(
quoteRegenHash: string,
): Promise<GeneratedQuoteController | null> {
const quote = await prisma.generatedQuotes.findUnique({
where: { quoteRegenHash },
include: generatedQuoteInclude,
});
return quote ? new GeneratedQuoteController(quote) : null;
},
async create(data: {
id?: string;
quoteRegenData: unknown;
quoteRegenParams: unknown;
quoteRegenHash: string;
quoteFile: Buffer | Uint8Array;
quoteFileName: string;
opportunityId: string;
createdById: string;
}): Promise<GeneratedQuoteController> {
const opportunity = await prisma.opportunity.findFirst({
where: { id: data.opportunityId },
select: { id: true },
});
if (!opportunity) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with ID '${data.opportunityId}'`,
status: 404,
});
}
const createdBy = await prisma.user.findFirst({
where: { id: data.createdById },
select: { id: true },
});
if (!createdBy) {
throw new GenericError({
message: "User not found",
name: "UserNotFound",
cause: `No user exists with ID '${data.createdById}'`,
status: 404,
});
}
const quote = await prisma.generatedQuotes.create({
data: {
...(data.id ? { id: data.id } : {}),
quoteRegenData: data.quoteRegenData as any,
quoteRegenParams: data.quoteRegenParams as any,
quoteRegenHash: data.quoteRegenHash,
quoteFile: Buffer.from(data.quoteFile),
quoteFileName: data.quoteFileName,
opportunityId: data.opportunityId,
createdById: data.createdById,
},
include: generatedQuoteInclude,
});
return new GeneratedQuoteController(quote);
},
async delete(id: string): Promise<void> {
const existing = await prisma.generatedQuotes.findFirst({
where: { id },
select: { id: true },
});
if (!existing) {
throw new GenericError({
message: "Generated quote not found",
name: "GeneratedQuoteNotFound",
cause: `No generated quote exists with ID '${id}'`,
status: 404,
});
}
await prisma.generatedQuotes.delete({
where: { id },
});
},
async recordDownload(
id: string,
user: {
id: string;
name?: string | null;
email: string;
fetchAction: string;
},
): Promise<GeneratedQuoteController> {
const existing = await prisma.generatedQuotes.findFirst({
where: { id },
select: { id: true, downloads: true },
});
if (!existing) {
throw new GenericError({
message: "Generated quote not found",
name: "GeneratedQuoteNotFound",
cause: `No generated quote exists with ID '${id}'`,
status: 404,
});
}
const currentDownloads = Array.isArray(existing.downloads)
? (existing.downloads as unknown[])
: [];
const downloadRecord = {
downloadedAt: new Date().toISOString(),
fetchAction: user.fetchAction,
userId: user.id,
userName: user.name ?? null,
userEmail: user.email,
};
const updated = await prisma.generatedQuotes.update({
where: { id },
data: {
downloads: [...currentDownloads, downloadRecord] as any,
},
include: generatedQuoteInclude,
});
return new GeneratedQuoteController(updated);
},
};
+188 -39
View File
@@ -6,6 +6,7 @@ import { OpportunityController } from "../controllers/OpportunityController";
import GenericError from "../Errors/GenericError";
import { activityCw } from "../modules/cw-utils/activities/activities";
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
import { CWOpportunityCreate } from "../modules/cw-utils/opportunities/opportunity.types";
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
import {
getCachedActivities,
@@ -14,6 +15,7 @@ import {
fetchAndCacheActivities,
fetchAndCacheCompanyCwData,
fetchAndCacheOppCwData,
invalidateAllOpportunityCaches,
} from "../modules/cache/opportunityCache";
// ---------------------------------------------------------------------------
@@ -45,13 +47,18 @@ async function buildCompanyController(
const strategy = opts?.strategy ?? "cache-then-cw";
const ctrl = new CompanyController(company);
// ── cw-first: always fetch from CW ──────────────────────────────────
// ── cw-first: always fetch from CW (and cache the result) ──────────
if (strategy === "cw-first") {
const blob = opts?.ttlMs
? await fetchAndCacheCompanyCwData(
company.cw_CompanyId,
opts.ttlMs,
).catch(() => null)
: null;
if (blob) {
ctrl.cw_Data = blob;
} else {
await ctrl.hydrateCwData();
if (ctrl.cw_Data && opts?.ttlMs) {
await fetchAndCacheCompanyCwData(company.cw_CompanyId, opts.ttlMs).catch(
() => {},
);
}
return ctrl;
}
@@ -66,14 +73,16 @@ async function buildCompanyController(
// cache-only stops here — return the bare DB-backed controller
if (strategy === "cache-only") return ctrl;
// cache-then-cw: cache miss — fall through to CW
// cache-then-cw: cache miss — fetch from CW once and cache in one pass
if (opts?.ttlMs) {
const blob = await fetchAndCacheCompanyCwData(
company.cw_CompanyId,
opts.ttlMs,
).catch(() => null);
if (blob) ctrl.cw_Data = blob;
} else {
await ctrl.hydrateCwData();
if (ctrl.cw_Data && opts?.ttlMs) {
await fetchAndCacheCompanyCwData(company.cw_CompanyId, opts.ttlMs).catch(
() => {},
);
}
return ctrl;
}
@@ -95,15 +104,11 @@ async function buildActivities(
): Promise<ActivityController[]> {
const strategy = opts?.strategy ?? "cache-then-cw";
// ── cw-first: always fetch from CW ──────────────────────────────────
// ── cw-first: always fetch from CW (and cache the result) ──────────
if (strategy === "cw-first") {
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
const arr = collection.map((item) => item);
if (opts?.ttlMs) {
await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs).catch(
() => {},
);
}
const arr = opts?.ttlMs
? await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs)
: await activityCw.fetchByOpportunityDirect(cwOpportunityId);
return arr.map((item) => new ActivityController(item));
}
@@ -116,16 +121,53 @@ async function buildActivities(
// cache-only stops here — return empty (background job will fill it)
if (strategy === "cache-only") return [];
// cache-then-cw: cache miss — fall through to CW
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
const arr = collection.map((item) => item);
if (opts?.ttlMs) {
await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs).catch(() => {});
}
// cache-then-cw: cache miss — fetch once and cache in one pass
const arr = opts?.ttlMs
? await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs)
: await activityCw.fetchByOpportunityDirect(cwOpportunityId);
return arr.map((item) => new ActivityController(item));
}
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)
*
@@ -233,8 +275,14 @@ export const opportunities = {
cwData = await getCachedOppCwData(existing.cwOpportunityId);
}
if (!cwData) {
// Cache miss or forced fresh — fetch from CW and cache
// ── Parallel block: CW opp fetch + activities + company ────────────
// Activities and company hydration only need existing.cwOpportunityId
// and existing.company — both available from the initial DB lookup —
// so they can run concurrently with the CW opp fetch + DB update.
const cwOppPromise = (async () => {
if (cwData) return; // cache hit — nothing to do
cwData = ttlMs
? await fetchAndCacheOppCwData(existing.cwOpportunityId, ttlMs)
: await opportunityCw.fetch(existing.cwOpportunityId);
@@ -264,13 +312,14 @@ export const opportunities = {
data: { ...mapped, companyId },
include: { company: true },
});
}
})();
// Hydrate activities and company in parallel
const [activities, company] = await Promise.all([
buildActivities(record.cwOpportunityId, { strategy, ttlMs }),
record.company
? buildCompanyController(record.company, { strategy, ttlMs })
// Hydrate activities and company in parallel with CW opp fetch
const [, activities, company] = await Promise.all([
cwOppPromise,
buildActivities(existing.cwOpportunityId, { strategy, ttlMs }),
existing.company
? buildCompanyController(existing.company, { strategy, ttlMs })
: Promise.resolve(undefined),
]);
@@ -302,7 +351,7 @@ export const opportunities = {
const skip = (Math.max(page, 1) - 1) * rpp;
const items = await prisma.opportunity.findMany({
where: opts?.includeClosed ? undefined : { closedFlag: false },
where: opts?.includeClosed ? undefined : { closedDate: null },
include: { company: true },
skip,
take: rpp,
@@ -352,7 +401,7 @@ export const opportunities = {
const items = await prisma.opportunity.findMany({
where: {
...(opts?.includeClosed ? {} : { closedFlag: false }),
...(opts?.includeClosed ? {} : { closedDate: null }),
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ companyName: { contains: query, mode: "insensitive" } },
@@ -369,7 +418,7 @@ export const opportunities = {
include: { company: true },
skip,
take: rpp,
orderBy: { expectedCloseDate: "asc" },
orderBy: { createdAt: "desc" },
});
return Promise.all(
@@ -396,7 +445,7 @@ export const opportunities = {
*/
async count(opts?: { openOnly?: boolean }): Promise<number> {
return prisma.opportunity.count({
where: opts?.openOnly ? { closedFlag: false } : undefined,
where: opts?.openOnly ? { closedDate: null } : undefined,
});
},
@@ -420,7 +469,7 @@ export const opportunities = {
return prisma.opportunity.count({
where: {
...(opts?.includeClosed ? {} : { closedFlag: false }),
...(opts?.includeClosed ? {} : { closedDate: null }),
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ companyName: { contains: query, mode: "insensitive" } },
@@ -455,7 +504,7 @@ export const opportunities = {
const items = await prisma.opportunity.findMany({
where: {
companyId,
...(opts?.includeClosed ? {} : { closedFlag: false }),
...(opts?.includeClosed ? {} : { closedDate: null }),
},
include: { company: true },
orderBy: { expectedCloseDate: "asc" },
@@ -476,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);
},
};
+148 -1
View File
@@ -2,9 +2,11 @@ import { prisma } from "../constants";
import { CatalogItemController } from "../controllers/CatalogItemController";
import GenericError from "../Errors/GenericError";
import {
CATEGORY_TREE,
getSubcategoriesForCategory,
getSubcategoriesForGroup,
ECOSYSTEM_TREE,
isCategoryGroup,
} from "../modules/catalog-categories/catalogCategories";
/**
@@ -15,6 +17,61 @@ const catalogItemInclude = {
linkedItems: true,
} as const;
const LABOR_STYLE_CANDIDATES = {
field: ["LABOR & INSTALLATION - FIELD", "LABOR - FIELD", "LABOR FIELD"],
tech: ["LABOR & INSTALLATION - TECH", "LABOR - TECH", "LABOR TECH"],
} as const;
async function findCatalogByExactCandidates(
candidates: readonly string[],
): Promise<CatalogItemController | null> {
for (const candidate of candidates) {
const item = await prisma.catalogItem.findFirst({
where: {
inactive: false,
OR: [
{ identifier: { equals: candidate, mode: "insensitive" } },
{ name: { equals: candidate, mode: "insensitive" } },
],
},
include: catalogItemInclude,
});
if (item) return new CatalogItemController(item);
}
return null;
}
async function findCatalogByLaborStyle(
style: "field" | "tech",
): Promise<CatalogItemController | null> {
const fallback = await prisma.catalogItem.findFirst({
where: {
inactive: false,
AND: [
{
OR: [
{ identifier: { contains: "labor", mode: "insensitive" } },
{ name: { contains: "labor", mode: "insensitive" } },
],
},
{
OR: [
{ identifier: { contains: style, mode: "insensitive" } },
{ name: { contains: style, mode: "insensitive" } },
],
},
],
},
include: catalogItemInclude,
orderBy: { name: "asc" },
});
if (!fallback) return null;
return new CatalogItemController(fallback);
}
/**
* Filter options for catalog item queries.
*/
@@ -36,23 +93,83 @@ export interface CatalogFilterOpts {
function buildFilterWhere(opts: CatalogFilterOpts = {}) {
const conditions: Record<string, unknown>[] = [];
const parseNumericId = (value?: string): number | null => {
if (!value) return null;
if (!/^\d+$/.test(value)) return null;
return Number(value);
};
const resolveCategoryNameById = (cwId: number): string | null => {
return CATEGORY_TREE.find((c) => c.cwId === cwId)?.name ?? null;
};
const resolveSubcategoryNameById = (cwId: number): string | null => {
for (const category of CATEGORY_TREE) {
for (const entry of category.entries) {
if (isCategoryGroup(entry)) {
const child = entry.children.find((c) => c.cwId === cwId);
if (child) return child.name;
continue;
}
if (entry.cwId === cwId) return entry.name;
}
}
return null;
};
const categoryId = parseNumericId(opts.category);
const subcategoryId = parseNumericId(opts.subcategory);
const resolvedCategoryName = categoryId
? resolveCategoryNameById(categoryId)
: opts.category;
if (!opts.includeInactive) {
conditions.push({ inactive: false });
}
if (opts.category) {
if (categoryId) {
const categoryOr: Record<string, unknown>[] = [
{ categoryCwId: categoryId },
];
if (resolvedCategoryName) {
categoryOr.push({ category: resolvedCategoryName });
}
conditions.push({ OR: categoryOr });
} else {
conditions.push({ category: opts.category });
}
}
if (opts.subcategory) {
if (subcategoryId) {
const resolvedSubcategoryName = resolveSubcategoryNameById(subcategoryId);
const subcategoryOr: Record<string, unknown>[] = [
{ subcategoryCwId: subcategoryId },
];
if (resolvedSubcategoryName) {
subcategoryOr.push({ subcategory: resolvedSubcategoryName });
}
conditions.push({ OR: subcategoryOr });
} else {
conditions.push({ subcategory: opts.subcategory });
}
}
if (opts.group && opts.category) {
const subcats = getSubcategoriesForGroup(opts.category, opts.group);
if (!resolvedCategoryName) {
conditions.push({ category: "__unknown_category__" });
}
if (resolvedCategoryName) {
const subcats = getSubcategoriesForGroup(
resolvedCategoryName,
opts.group,
);
if (subcats.length > 0) {
conditions.push({ subcategory: { in: subcats } });
}
}
} else if (opts.group && !opts.category) {
// Try to find the group in any category
const {
@@ -142,6 +259,36 @@ export const procurement = {
return new CatalogItemController(item);
},
/**
* Fetch Labor Catalog Items
*
* Resolves canonical Field and Tech labor products from the local catalog.
* Prefers exact identifier/name matches, then falls back to keyword matching.
*/
async fetchLaborCatalogItems(): Promise<{
field: CatalogItemController;
tech: CatalogItemController;
}> {
const fieldItem =
(await findCatalogByExactCandidates(LABOR_STYLE_CANDIDATES.field)) ??
(await findCatalogByLaborStyle("field"));
const techItem =
(await findCatalogByExactCandidates(LABOR_STYLE_CANDIDATES.tech)) ??
(await findCatalogByLaborStyle("tech"));
if (!fieldItem || !techItem) {
throw new GenericError({
message: "Labor catalog products are not configured",
name: "LaborCatalogProductsNotFound",
cause:
"Expected active FIELD and TECH labor catalog items in the local catalog",
status: 500,
});
}
return { field: fieldItem, tech: techItem };
},
/**
* Fetch All Catalog Items (Paginated)
*
+1 -1
View File
@@ -99,7 +99,7 @@ export const users = {
const newUser = await prisma.user.create({
data: {
userId: msData.id,
email: msData.mail,
email: msData.mail ?? msData.userPrincipalName,
name: `${msData.givenName} ${msData.surname}`,
login: msData.userPrincipalName,
cwIdentifier,
@@ -0,0 +1,102 @@
/**
* @module algo.coldThreshold
*
* Cold-Detection Algorithm
* ========================
*
* Determines whether an opportunity has stalled in a status long enough
* to be considered "cold". When an opportunity goes cold it is
* automatically moved to InternalReview, a system-generated activity is
* logged, and it is flagged for the internal review report.
*
* ## Thresholds (defaults)
*
* | Status | Stall Threshold |
* |-----------------|-----------------|
* | QuoteSent | 14 days |
* | ConfirmedQuote | 30 days |
*
* Only these two statuses are eligible for cold detection. All other
* statuses return `cold: false`.
*
* ## How "last activity date" is determined
*
* The algorithm uses `lastActivityDate` the most recent of:
* - the latest activity's `dateStart`
* - the opportunity's `cwLastUpdated`
*
* The caller is responsible for resolving this value before calling
* `checkColdStatus`.
*/
import type { OpportunityController } from "../../controllers/OpportunityController";
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
/** Stall thresholds in milliseconds, keyed by CW status ID. */
export const COLD_THRESHOLDS: Record<number, { days: number; ms: number }> = {
/** QuoteSent — CW status ID 43, "03. Quote Sent" */
43: { days: 14, ms: 14 * 24 * 60 * 60 * 1000 },
/** ConfirmedQuote — CW status ID 57, "04. Confirmed Quote" */
57: { days: 30, ms: 30 * 24 * 60 * 60 * 1000 },
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ColdCheckInput {
/** Current CW status ID of the opportunity. */
statusCwId: number | null;
/**
* The most recent meaningful date to measure staleness from.
* Typically the latest of the last activity dateStart or cwLastUpdated.
*/
lastActivityDate: Date | null;
/** Override for "now" — useful for testing. Defaults to `new Date()`. */
now?: Date;
}
export interface ColdCheckResult {
/** Whether the opportunity is considered cold. */
cold: boolean;
/**
* Which threshold triggered the cold flag.
* `null` when `cold` is `false`.
*/
triggeredBy: {
statusCwId: number;
statusName: string;
thresholdDays: number;
staleDays: number;
} | null;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const STATUS_NAMES: Record<number, string> = {
43: "QuoteSent",
57: "ConfirmedQuote",
};
// ---------------------------------------------------------------------------
// Core
// ---------------------------------------------------------------------------
/**
* Evaluate whether an opportunity has exceeded its cold-stall threshold.
*
* @returns A `ColdCheckResult` indicating cold status and trigger metadata.
*/
export function checkColdStatus(_input: ColdCheckInput): ColdCheckResult {
// Bypassed — always returns not-cold until cold-stall feature is ready
return { cold: false, triggeredBy: null };
}
@@ -0,0 +1,97 @@
/**
* @module algo.followUpScheduler
*
* Follow-Up Scheduling Algorithm
* ===============================
*
* Determines the due date for follow-up activities created by the
* opportunity workflow. The follow-up is always assigned to the user
* who triggered its creation.
*
* ## TODO Calendar-aware scheduling
*
* This module currently uses a **dummy algorithm** that schedules the
* follow-up for the next business day at 10:00 AM local time.
*
* It needs to be replaced with an availability-aware algorithm that:
* 1. Reads the assigned user's calendar (Microsoft Graph / CW schedule).
* 2. Finds the earliest open slot of sufficient duration.
* 3. Respects company-wide blackout dates (holidays, company events).
* 4. Accounts for the user's working-hours preferences.
*
* Until that integration is complete, the simple "next business day"
* heuristic is used as a placeholder.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface FollowUpScheduleInput {
/** The user who triggered the activity (follow-up is assigned to them). */
triggeredByUserId: string;
/** Optional override for "now" — useful for testing. */
now?: Date;
}
export interface FollowUpScheduleResult {
/** Suggested due date for the follow-up activity. */
dueDate: Date;
/** ISO string version for CW API payloads. */
dueDateIso: string;
}
// ---------------------------------------------------------------------------
// Core
// ---------------------------------------------------------------------------
/**
* Schedule a follow-up activity.
*
* Returns a suggested `dueDate` for the follow-up activity.
* Currently uses dummy logic: next business day at 10:00 AM.
*
* @param input - Scheduling parameters
* @returns The scheduled follow-up date
*/
export function scheduleFollowUp(
input: FollowUpScheduleInput,
): FollowUpScheduleResult {
const now = input.now ?? new Date();
const dueDate = getNextBusinessDay(now);
// Set to 10:00 AM
dueDate.setHours(10, 0, 0, 0);
return {
dueDate,
dueDateIso: dueDate.toISOString(),
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Returns the next business day (MonFri) 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;
}
+8 -6
View File
@@ -16,8 +16,8 @@
* | # | Condition | TTL (ms) | TTL (human) | Rationale |
* |---|------------------------------------------------------------------|----------|-------------|--------------------------------------------------------------------|
* | 1 | `closedFlag` is `true` | `null` | Do not cache| Closed records are rarely accessed; caching wastes memory. |
* | 2 | `expectedCloseDate` OR `lastUpdated` is within the last **5 days**| 30 000 | 30 seconds | High-activity window data changes frequently and must stay fresh.|
* | 3 | `expectedCloseDate` OR `lastUpdated` is within the last **14 days**| 60 000 | 60 seconds | Moderate activity still relevant, but changes less often. |
* | 2 | `expectedCloseDate` OR `lastUpdated` is within the last **5 days**| 60 000 | 60 seconds | High-activity window data changes frequently and must stay fresh.|
* | 3 | `expectedCloseDate` OR `lastUpdated` is within the last **14 days**| 90 000 | 90 seconds | Moderate activity still relevant, but changes less often. |
* | 4 | Everything else (older than 14 days) | 900 000 | 15 minutes | Low activity safe to serve from cache for longer. |
*
* ## Evaluation order
@@ -62,11 +62,13 @@
// Constants
// ---------------------------------------------------------------------------
/** 30 seconds TTL for high-activity records (within 5 days). */
export const TTL_HIGH_ACTIVITY = 30_000;
/** 60 seconds TTL for high-activity records (within 5 days).
* Must exceed the 30-second background refresh interval so the cache
* stays warm between cycles. */
export const TTL_HIGH_ACTIVITY = 60_000;
/** 60 seconds TTL for moderate-activity records (within 14 days). */
export const TTL_MODERATE_ACTIVITY = 60_000;
/** 90 seconds TTL for moderate-activity records (within 14 days). */
export const TTL_MODERATE_ACTIVITY = 90_000;
/** 15 minutes TTL for low-activity / stale records. */
export const TTL_LOW_ACTIVITY = 900_000;
@@ -18,7 +18,7 @@
* | 1 | Status is **Won**, **Lost**, **Pending Won**, or **Pending Lost** | `null` | No cache | Products on terminal / near-terminal opps are static; no need to keep them warm. |
* | 2 | Opportunity is **not cacheable** (main cache TTL is `null`) | `null` | No cache | If the opp itself is evicted, sub-resources follow suit. |
* | 3 | `lastUpdated` is within the last **3 days** | 15 000 | 15 seconds | Actively-worked deals products are being edited and need near-real-time freshness. |
* | 4 | Everything else | 1 800 000 | 30 minutes | Lazy on-demand cache: fetched when requested, expires after 30 min without refresh. |
* | 4 | Everything else | 1 200 000 | 20 minutes | Lazy on-demand cache: fetched when requested, expires after 20 min without refresh. |
*
* ## Evaluation order
*
@@ -44,11 +44,13 @@ import { QUOTE_STATUSES } from "../../types/QuoteStatuses";
// Constants
// ---------------------------------------------------------------------------
/** 15 seconds — TTL for hot products (opportunity updated within 3 days). */
export const PRODUCTS_TTL_HOT = 15_000;
/** 45 seconds TTL for hot products (opportunity updated within 3 days).
* Must exceed the 30-second background refresh interval so the cache
* stays warm between cycles. */
export const PRODUCTS_TTL_HOT = 45_000;
/** 30 minutes — TTL for on-demand product cache (lazy fallback). */
export const PRODUCTS_TTL_LAZY = 1_800_000;
/** 20 minutes — TTL for on-demand product cache (lazy fallback). */
export const PRODUCTS_TTL_LAZY = 1_200_000;
/** 3 days in milliseconds. */
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
+43 -16
View File
@@ -310,25 +310,34 @@ export async function fetchAndCacheCompanyCwData(
ttlMs: number,
): Promise<{ company: any; defaultContact: any; allContacts: any[] } | null> {
try {
const cwCompany = await fetchCwCompanyById(cwCompanyId);
// Fetch company and all-contacts in parallel — the allContacts URL
// can be constructed directly without the company response.
const [cwCompany, allContactsData] = await Promise.all([
fetchCwCompanyById(cwCompanyId),
withCwRetry(
() =>
connectWiseApi.get(
`/company/companies/${cwCompanyId}/contacts?pageSize=1000`,
),
{ label: `company#${cwCompanyId}/allContacts` },
),
]);
if (!cwCompany) return null;
const contactHref = cwCompany.defaultContact?._info?.contact_href;
const defaultContactData = contactHref
? await withCwRetry(() => connectWiseApi.get(contactHref), {
label: `company#${cwCompanyId}/defaultContact`,
})
: undefined;
const allContactsData = await withCwRetry(
() =>
connectWiseApi.get(`${cwCompany._info.contacts_href}&pageSize=1000`),
{ label: `company#${cwCompanyId}/allContacts` },
);
// Default contact: derive from allContacts instead of making an
// extra serial CW call. The company object carries the default
// contact's ID, so we can pull it from the list we already fetched.
const defaultContactId = cwCompany.defaultContact?.id;
const defaultContactData = defaultContactId
? ((allContactsData.data as any[]).find(
(c: any) => c.id === defaultContactId,
) ?? null)
: null;
const blob = {
company: cwCompany,
defaultContact: defaultContactData?.data ?? null,
defaultContact: defaultContactData,
allContacts: allContactsData.data,
};
@@ -491,11 +500,29 @@ export async function invalidateProductsCache(
}
/**
* Site TTL 30 minutes. Site/address data rarely changes so we cache
* Invalidate all cached data for an opportunity.
*
* Removes activities, notes, contacts, products, and CW data cache keys.
* Call this when an opportunity is deleted.
*/
export async function invalidateAllOpportunityCaches(
cwOpportunityId: number,
): Promise<void> {
await redis.del(
activityCacheKey(cwOpportunityId),
notesCacheKey(cwOpportunityId),
contactsCacheKey(cwOpportunityId),
productsCacheKey(cwOpportunityId),
oppCwDataCacheKey(cwOpportunityId),
);
}
/**
* Site TTL 20 minutes. Site/address data rarely changes so we cache
* aggressively. The background refresh does NOT proactively warm site keys;
* they are populated lazily on the first detail-view request.
*/
const SITE_TTL_MS = 1_800_000;
const SITE_TTL_MS = 1_200_000;
/**
* Fetch a CW company site from ConnectWise and cache the result.
+900
View File
@@ -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 0100 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;
}
@@ -0,0 +1,79 @@
/**
* CW API Concurrency Limiter
*
* Limits the number of simultaneous in-flight requests to the ConnectWise
* API. CW responds significantly slower under high concurrency (observed
* ~3× slower at 9 concurrent vs 56 concurrent), so bounding the
* parallelism actually reduces total wall-clock time.
*
* Implemented as an Axios request interceptor that gates on a simple
* counting semaphore. When the limit is reached, new requests queue and
* resolve in FIFO order as earlier requests complete.
*/
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
// ---------------------------------------------------------------------------
// Semaphore
// ---------------------------------------------------------------------------
class Semaphore {
private _current = 0;
private _queue: (() => void)[] = [];
constructor(private _max: number) {}
/** Acquire a slot — resolves immediately if under the limit, else waits. */
acquire(): Promise<void> {
if (this._current < this._max) {
this._current++;
return Promise.resolve();
}
return new Promise<void>((resolve) => {
this._queue.push(resolve);
});
}
/** Release a slot — wakes the next queued caller, if any. */
release(): void {
const next = this._queue.shift();
if (next) {
// Hand the slot directly to the next waiter (don't decrement)
next();
} else {
this._current--;
}
}
}
// ---------------------------------------------------------------------------
// Interceptor attachment
// ---------------------------------------------------------------------------
/**
* Attach a concurrency-limiting interceptor to an Axios instance.
*
* @param api - The Axios instance to limit.
* @param max - Maximum concurrent in-flight requests (default: 6).
*/
export function attachCwConcurrencyLimiter(api: AxiosInstance, max = 6): void {
const sem = new Semaphore(max);
// Request interceptor: wait for a slot before the request fires
api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
await sem.acquire();
return config;
});
// Response interceptor: release the slot on success or failure
api.interceptors.response.use(
(response) => {
sem.release();
return response;
},
(error) => {
sem.release();
return Promise.reject(error);
},
);
}
@@ -17,20 +17,26 @@ export interface CWMember {
* Fetches every member from ConnectWise using pagination and returns them
* in a Collection keyed by their identifier (e.g. "jroberts").
*
* @param opts.conditions - Optional CW conditions string to filter members
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
*/
export const fetchAllCwMembers = async (): Promise<
Collection<string, CWMember>
> => {
export const fetchAllCwMembers = async (opts?: {
conditions?: string;
}): Promise<Collection<string, CWMember>> => {
const members = new Collection<string, CWMember>();
const pageSize = 1000;
const conditionsParam = opts?.conditions
? `&conditions=${encodeURIComponent(opts.conditions)}`
: "";
const { data: countData } = await connectWiseApi.get("/system/members/count");
const { data: countData } = await connectWiseApi.get(
`/system/members/count${conditionsParam ? `?${conditionsParam.slice(1)}` : ""}`,
);
const totalPages = Math.ceil(countData.count / pageSize);
for (let page = 0; page < totalPages; page++) {
const { data } = await connectWiseApi.get<CWMember[]>(
`/system/members?page=${page + 1}&pageSize=${pageSize}`,
`/system/members?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
);
for (const member of data) {
@@ -102,3 +102,40 @@ export const resolveMember = async (
cwMemberId: cwMember?.id ?? null,
};
};
/**
* Resolve Multiple CW Identifiers in a Single Batch
*
* Same as `resolveMember` but batches the DB query so that N identifiers
* require only **one** `findMany` instead of N `findFirst` calls.
*
* @param identifiers - Array of CW member identifiers
* @returns Map of identifier ResolvedMember
*/
export const resolveMembers = async (
identifiers: string[],
): Promise<Map<string, ResolvedMember>> => {
const unique = [...new Set(identifiers)];
// Single batched DB query for all identifiers
const localUsers = await prisma.user.findMany({
where: { cwIdentifier: { in: unique } },
select: { id: true, cwIdentifier: true },
});
const userMap = new Map(localUsers.map((u) => [u.cwIdentifier, u.id]));
const result = new Map<string, ResolvedMember>();
for (const identifier of unique) {
const cwMember = memberCache.get(identifier);
const name = cwMember
? `${cwMember.firstName} ${cwMember.lastName}`.trim() || identifier
: identifier;
result.set(identifier, {
id: userMap.get(identifier) ?? null,
identifier,
name,
cwMemberId: cwMember?.id ?? null,
});
}
return result;
};
@@ -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,14 +2,18 @@ import { Collection } from "@discordjs/collection";
import { connectWiseApi } from "../../../constants";
import {
CWOpportunity,
CWOpportunityCreate,
CWOpportunitySummary,
CWForecast,
CWForecastItem,
CWForecastItemCreate,
CWProcurementProduct,
CWProcurementProductCreate,
CWOpportunityNote,
CWOpportunityNoteCreate,
CWOpportunityNoteUpdate,
CWOpportunityContact,
CWOpportunityUpdate,
} from "./opportunity.types";
export const opportunityCw = {
@@ -98,6 +102,45 @@ export const opportunityCw = {
return response.data;
},
/**
* Create Opportunity
*
* Creates a new opportunity in ConnectWise via POST.
* Strips null/undefined values from the payload CW rejects
* null reference objects on create; omitting them lets CW apply
* its own defaults.
*/
create: async (data: CWOpportunityCreate): Promise<CWOpportunity> => {
const cleaned = Object.fromEntries(
Object.entries(data).filter(([, v]) => v != null),
);
const response = await connectWiseApi.post("/sales/opportunities", cleaned);
return response.data;
},
/**
* Update Opportunity
*
* Applies a JSON Patch update to an opportunity record in ConnectWise.
* Each key in `data` produces a replace operation.
*/
update: async (
opportunityId: number,
data: CWOpportunityUpdate,
): Promise<CWOpportunity> => {
const operations = Object.entries(data).map(([key, value]) => ({
op: "replace" as const,
path: key,
value,
}));
const response = await connectWiseApi.patch(
`/sales/opportunities/${opportunityId}`,
operations,
);
return response.data;
},
/**
* Fetch Opportunities by Company
*
@@ -252,6 +295,31 @@ export const opportunityCw = {
return touchedIndices.map((i) => (updated.forecastItems ?? [])[i]!);
},
/**
* Delete Forecast Item
*
* Removes a forecast item from an opportunity by PUTting the forecast
* without the target item. CW's forecast endpoint replaces the entire
* forecast items list on PUT.
*/
deleteProduct: async (
opportunityId: number,
forecastItemId: number,
): Promise<void> => {
const forecast = await opportunityCw.fetchProducts(opportunityId);
const items = forecast.forecastItems ?? [];
const filtered = items.filter((fi) => fi.id !== forecastItemId);
if (filtered.length === items.length) {
throw new Error(
`Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`,
);
}
const url = `/sales/opportunities/${opportunityId}/forecast`;
await connectWiseApi.put(url, { ...forecast, forecastItems: filtered });
},
/**
* Fetch Opportunity Notes
*
@@ -352,8 +420,81 @@ export const opportunityCw = {
opportunityId: number,
): Promise<Record<string, unknown>[]> => {
const response = await connectWiseApi.get(
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate`,
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate,customFields`,
);
return response.data;
},
/**
* Create Procurement Products
*
* Creates one or more procurement products linked to an opportunity.
* This endpoint supports procurement customFields (unlike forecast items).
*/
createProcurementProducts: async (
data: CWProcurementProductCreate | CWProcurementProductCreate[],
): Promise<CWProcurementProduct[]> => {
const productsToCreate = Array.isArray(data) ? data : [data];
const created: CWProcurementProduct[] = [];
for (const product of productsToCreate) {
const response = await connectWiseApi.post(
`/procurement/products`,
product,
);
created.push(response.data as CWProcurementProduct);
}
return created;
},
/**
* Fetch Procurement Product by Forecast Detail
*
* Finds the procurement product linked to a given forecast item ID
* on an opportunity.
*/
fetchProcurementProductByForecastDetail: async (
opportunityId: number,
forecastDetailId: number,
): Promise<CWProcurementProduct | null> => {
const conditions = `opportunity/id=${opportunityId} and forecastDetailId=${forecastDetailId}`;
const response = await connectWiseApi.get(
`/procurement/products?conditions=${encodeURIComponent(conditions)}&fields=id,forecastDetailId,description,customerDescription,quantity,price,cost,taxableFlag,specialOrderFlag,customFields`,
);
const items = (response.data ?? []) as CWProcurementProduct[];
return items[0] ?? null;
},
/**
* Update Procurement Product
*
* Applies a JSON Patch update to a procurement product record.
*/
updateProcurementProduct: async (
procurementProductId: number,
data: Record<string, unknown>,
): Promise<CWProcurementProduct> => {
const operations = Object.entries(data).map(([key, value]) => ({
op: "replace" as const,
path: key,
value,
}));
const response = await connectWiseApi.patch(
`/procurement/products/${procurementProductId}`,
operations,
);
return response.data as CWProcurementProduct;
},
/**
* Delete Opportunity
*
* Deletes an opportunity from ConnectWise by its CW opportunity ID.
*/
delete: async (opportunityId: number): Promise<void> => {
await connectWiseApi.delete(`/sales/opportunities/${opportunityId}`);
},
};
@@ -68,6 +68,7 @@ export interface CWOpportunity {
closedDate: string;
closedBy: CWMemberReference;
totalSalesTax: number;
probability: CWReference;
shipToCompany: CWCompanyReference;
shipToContact: CWContactReference;
shipToSite: CWSiteReference;
@@ -113,6 +114,7 @@ export interface CWForecastItem {
_info?: Record<string, string>;
};
productDescription: string;
customerDescription?: string;
productClass: string;
revenue: number;
cost: number;
@@ -129,6 +131,7 @@ export interface CWForecastItem {
sequenceNumber: number;
subNumber: number;
taxableFlag: boolean;
customFields?: CWCustomField[];
_info?: Record<string, string>;
}
@@ -224,6 +227,81 @@ export interface CWForecastItemCreate {
recurringCost?: number;
cycles?: number;
sequenceNumber?: number;
customFields?: Array<
Partial<Omit<CWCustomField, "connectWiseId" | "rowNum" | "podId">>
>;
}
export interface CWProcurementProductCreate {
opportunity?: { id: number };
catalogItem: { id: number };
description: string;
customerDescription?: string;
quantity?: number;
price?: number;
cost?: number;
taxableFlag?: boolean;
dropshipFlag?: boolean;
billableOption?: string;
customFields?: Array<
Partial<Omit<CWCustomField, "connectWiseId" | "rowNum" | "podId">>
>;
}
export interface CWProcurementProduct {
id: number;
forecastDetailId?: number;
description?: string;
customerDescription?: string;
quantity?: number;
price?: number;
cost?: number;
taxableFlag?: boolean;
specialOrderFlag?: boolean;
customFields?: CWCustomField[];
_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 {
@@ -1,4 +1,5 @@
import { CWOpportunity } from "./opportunity.types";
import { normalizeProbabilityPercent } from "../../sales-utils/normalizeProbability";
export type ProcessedOpportunity = ReturnType<
typeof processOpportunityResponse
@@ -14,6 +15,7 @@ export const processOpportunityResponse = (opportunity: CWOpportunity) => ({
expectedCloseDate: opportunity.expectedCloseDate,
closedDate: opportunity.closedDate,
closedFlag: opportunity.closedFlag,
probability: normalizeProbabilityPercent(opportunity.probability?.name),
type: opportunity.type
? { id: opportunity.type.id, name: opportunity.type.name }
: null,
@@ -2,6 +2,7 @@ import { prisma } from "../../../constants";
import { events } from "../../globalEvents";
import { opportunityCw } from "./opportunities";
import { OpportunityController } from "../../../controllers/OpportunityController";
import { invalidateAllOpportunityCaches } from "../../cache/opportunityCache";
/**
* Refresh Opportunities
@@ -21,11 +22,14 @@ export const refreshOpportunities = async () => {
// 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated
const dbItems = await prisma.opportunity.findMany({
select: { cwOpportunityId: true, cwLastUpdated: true },
select: {
id: true,
cwOpportunityId: true,
cwLastUpdated: true,
cwDateEntered: true,
},
});
const dbMap = new Map(
dbItems.map((item) => [item.cwOpportunityId, item.cwLastUpdated]),
);
const dbMap = new Map(dbItems.map((item) => [item.cwOpportunityId, item]));
// 3. Determine stale / new IDs
const staleIds: number[] = [];
@@ -34,18 +38,48 @@ export const refreshOpportunities = async () => {
const cwLastUpdated = summary._info?.lastUpdated
? new Date(summary._info.lastUpdated)
: null;
const dbLastUpdated = dbMap.get(cwId) ?? null;
const dbItem = dbMap.get(cwId) ?? null;
const dbLastUpdated = dbItem?.cwLastUpdated ?? null;
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
// Treat as stale if never synced, CW has newer data, or cwDateEntered is missing (backfill)
if (
!dbLastUpdated ||
(cwLastUpdated && cwLastUpdated > dbLastUpdated) ||
!dbItem?.cwDateEntered
) {
staleIds.push(cwId);
}
}
// 3b. Reconcile — find local records that no longer exist in CW
const orphanedItems = dbItems.filter(
(item) => !cwSummaries.has(item.cwOpportunityId),
);
if (orphanedItems.length > 0) {
console.log(
`[refreshOpportunities] Reconciling ${orphanedItems.length} orphaned local record(s) not found in CW`,
);
await Promise.all(
orphanedItems.map(async (item) => {
await prisma.opportunity.delete({ where: { id: item.id } });
await invalidateAllOpportunityCaches(item.cwOpportunityId);
}),
);
events.emit("cw:opportunities:refresh:reconciled", {
orphanedCount: orphanedItems.length,
removedCwIds: orphanedItems.map((i) => i.cwOpportunityId),
});
}
if (staleIds.length === 0) {
events.emit("cw:opportunities:refresh:skipped", {
totalCw: cwSummaries.size,
totalDb: dbItems.length,
staleCount: 0,
orphanedCount: orphanedItems.length,
});
return;
}
@@ -106,5 +140,6 @@ export const refreshOpportunities = async () => {
totalDb: dbItems.length,
staleCount: staleIds.length,
itemsUpdated: updatedCount,
orphanedCount: orphanedItems.length,
});
};

Some files were not shown because too many files have changed in this diff Show More