Compare commits

...

7 Commits

106 changed files with 15231 additions and 415 deletions
+1082 -6
View File
File diff suppressed because it is too large Load Diff
+127 -76
View File
@@ -11,7 +11,7 @@ The API caches expensive ConnectWise (CW) API responses in **Redis** to reduce l
### Key design principles ### Key design principles
- **Adaptive TTLs** — cache durations are computed dynamically based on how "hot" an opportunity is (recently updated = shorter TTL = fresher data). - **Adaptive TTLs** — cache durations are computed dynamically based on how "hot" an opportunity is (recently updated = shorter TTL = fresher data).
- **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. - **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. - **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. - **Priority ordering** — most recently updated opportunities are refreshed first so active deals get fresh data before stale ones.
@@ -22,15 +22,21 @@ 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: Each non-closed opportunity can have up to 7 cached payloads in Redis:
| Cache Key Pattern | Data | Source | | Cache Key Pattern | Data | Source |
|---|---|---| | ----------------------------------- | ------------------------------------ | --------------------------------------------------------------------- |
| `opp:cw-data:{cwOpportunityId}` | Raw CW opportunity response | `GET /sales/opportunities/:id` | | `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:activities:{cwOpportunityId}` | CW activities array | `GET /sales/activities?conditions=opportunity/id=:id` |
| `opp:notes:{cwOpportunityId}` | CW notes array | `GET /sales/opportunities/:id/notes` | | `opp:notes:{cwOpportunityId}` | CW notes array | `GET /sales/opportunities/:id/notes` |
| `opp:contacts:{cwOpportunityId}` | CW contacts array | `GET /sales/opportunities/:id/contacts` | | `opp:contacts:{cwOpportunityId}` | CW contacts array | `GET /sales/opportunities/:id/contacts` |
| `opp:products:{cwOpportunityId}` | Forecast + procurement products blob | `GET /sales/opportunities/:id/forecast` + `GET /procurement/products` | | `opp:products:{cwOpportunityId}` | Forecast + procurement products blob | `GET /sales/opportunities/:id/forecast` + `GET /procurement/products` |
| `opp:company-cw:{cw_CompanyId}` | Hydrated company + contacts blob | `GET /company/companies/:id` + contacts endpoints | | `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` | | `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 |
--- ---
@@ -49,13 +55,13 @@ Three algorithms compute cache TTLs. All share the same input signals:
Used for: opportunity CW data, activities, company CW data. Used for: opportunity CW data, activities, company CW data.
| # | Condition | TTL | Human | | # | Condition | TTL | Human |
|---|---|---|---| | --- | ------------------------------------------------------- | ---------- | ------------ |
| 1a | Closed > 30 days ago | `null` | Do not cache | | 1a | Closed > 30 days ago | `null` | Do not cache |
| 1b | Closed within 30 days | 900,000 ms | 15 minutes | | 1b | Closed within 30 days | 900,000 ms | 15 minutes |
| 2 | `expectedCloseDate` or `lastUpdated` within **5 days** | 30,000 ms | 30 seconds | | 2 | `expectedCloseDate` or `lastUpdated` within **5 days** | 30,000 ms | 30 seconds |
| 3 | `expectedCloseDate` or `lastUpdated` within **14 days** | 60,000 ms | 60 seconds | | 3 | `expectedCloseDate` or `lastUpdated` within **14 days** | 60,000 ms | 60 seconds |
| 4 | Everything else | 900,000 ms | 15 minutes | | 4 | Everything else | 900,000 ms | 15 minutes |
Rules are evaluated top-to-bottom; first match wins. Rules are evaluated top-to-bottom; first match wins.
@@ -65,13 +71,13 @@ Rules are evaluated top-to-bottom; first match wins.
Used for: notes, contacts. Used for: notes, contacts.
| # | Condition | TTL | Human | | # | Condition | TTL | Human |
|---|---|---|---| | --- | --------------------- | ---------- | ------------ |
| 1a | Closed > 30 days ago | `null` | Do not cache | | 1a | Closed > 30 days ago | `null` | Do not cache |
| 1b | Closed within 30 days | 300,000 ms | 5 minutes | | 1b | Closed within 30 days | 300,000 ms | 5 minutes |
| 2 | Within **5 days** | 60,000 ms | 60 seconds | | 2 | Within **5 days** | 60,000 ms | 60 seconds |
| 3 | Within **14 days** | 120,000 ms | 2 minutes | | 3 | Within **14 days** | 120,000 ms | 2 minutes |
| 4 | Everything else | 300,000 ms | 5 minutes | | 4 | Everything else | 300,000 ms | 5 minutes |
### Products TTL (`computeProductsCacheTTL`) ### Products TTL (`computeProductsCacheTTL`)
@@ -79,18 +85,18 @@ Used for: notes, contacts.
Used for: forecast + procurement products. Used for: forecast + procurement products.
| # | Condition | TTL | Human | | # | Condition | TTL | Human |
|---|---|---|---| | --- | ------------------------------------------- | ------------ | ---------- |
| 1 | Status is Won/Lost/Pending Won/Pending Lost | `null` | No cache | | 1 | Status is Won/Lost/Pending Won/Pending Lost | `null` | No cache |
| 2 | Main cache TTL is `null` | `null` | No cache | | 2 | Main cache TTL is `null` | `null` | No cache |
| 3 | `lastUpdated` within **3 days** | 15,000 ms | 15 seconds | | 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 ### 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 +104,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` **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 ### Refresh cycle
@@ -108,17 +114,61 @@ 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. 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. 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 ### 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())`. 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())`.
### Current tuning ### Current tuning
| Parameter | Value | Effect | | Parameter | Value | Effect |
|---|---|---| | ---------------- | ---------- | ------------------------------------------ |
| `CONCURRENCY` | 6 | Max simultaneous CW API requests per batch | | `CONCURRENCY` | 6 | Max simultaneous CW API requests per batch |
| `BATCH_DELAY_MS` | 250 | Milliseconds between batches | | `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. At these settings, a full sweep of ~500 expired keys completes in ~1-2 minutes with zero CW errors and ~230ms median latency.
@@ -142,11 +192,11 @@ Wraps CW API calls with exponential backoff retry on transient errors.
### Default configuration ### Default configuration
| Parameter | Default | Description | | Parameter | Default | Description |
|---|---|---| | ------------- | ------- | ----------------------------------------------------------- |
| `maxAttempts` | 3 | Total attempts including the first | | `maxAttempts` | 3 | Total attempts including the first |
| `baseDelayMs` | 1,000 | Delay before first retry (doubles each retry: 1s → 2s → 4s) | | `baseDelayMs` | 1,000 | Delay before first retry (doubles each retry: 1s → 2s → 4s) |
| `label` | — | Optional tag for log messages | | `label` | — | Optional tag for log messages |
### Usage ### Usage
@@ -181,16 +231,16 @@ LOG_CW_API=1 bun run dev
### Log entry fields ### Log entry fields
| Field | Type | Description | | Field | Type | Description |
|---|---|---| | ------------ | ----------------- | ----------------------------------- |
| `timestamp` | string (ISO-8601) | When the request completed | | `timestamp` | string (ISO-8601) | When the request completed |
| `method` | string | HTTP method | | `method` | string | HTTP method |
| `url` | string | Request URL (relative or absolute) | | `url` | string | Request URL (relative or absolute) |
| `baseURL` | string | Axios baseURL | | `baseURL` | string | Axios baseURL |
| `status` | number \| null | HTTP status (null on network error) | | `status` | number \| null | HTTP status (null on network error) |
| `durationMs` | number | Wall-clock time in milliseconds | | `durationMs` | number | Wall-clock time in milliseconds |
| `error` | string \| null | Error code + message, if any | | `error` | string \| null | Error code + message, if any |
| `timeout` | number | Configured timeout in ms | | `timeout` | number | Configured timeout in ms |
### Analysis ### Analysis
@@ -229,12 +279,12 @@ rm -rf cw-api-logs/
Mutation endpoints invalidate the relevant cache keys so the next read fetches fresh data from CW: Mutation endpoints invalidate the relevant cache keys so the next read fetches fresh data from CW:
| Mutation | Cache invalidated | | Mutation | Cache invalidated |
|---|---| | ------------------------------ | ---------------------------------------------------------------- |
| Create/update/delete note | `opp:notes:{cwOpportunityId}` via `invalidateNotesCache()` | | Create/update/delete note | `opp:notes:{cwOpportunityId}` via `invalidateNotesCache()` |
| Create/update/delete contact | `opp:contacts:{cwOpportunityId}` via `invalidateContactsCache()` | | Create/update/delete contact | `opp:contacts:{cwOpportunityId}` via `invalidateContactsCache()` |
| Add/update/resequence products | `opp:products:{cwOpportunityId}` via `invalidateProductsCache()` | | Add/update/resequence products | `opp:products:{cwOpportunityId}` via `invalidateProductsCache()` |
| Refresh opportunity | All keys for that opportunity (via re-fetch) | | Refresh opportunity | All keys for that opportunity (via re-fetch) |
--- ---
@@ -242,11 +292,11 @@ Mutation endpoints invalidate the relevant cache keys so the next read fetches f
The shared Axios instance (`connectWiseApi`) is configured in `src/constants.ts`: The shared Axios instance (`connectWiseApi`) is configured in `src/constants.ts`:
| Setting | Value | Purpose | | Setting | Value | Purpose |
|---|---|---| | --------- | ---------------------------------------------------- | ------------------------------ |
| `baseURL` | `https://ttscw.totaltech.net/v4_6_release/apis/3.0/` | CW API base | | `baseURL` | `https://ttscw.totaltech.net/v4_6_release/apis/3.0/` | CW API base |
| `timeout` | 30,000 ms (30s) | Per-request timeout | | `timeout` | 30,000 ms (30s) | Per-request timeout |
| Logger | `attachCwApiLogger()` | Writes to `cw-api-calls.jsonl` | | Logger | `attachCwApiLogger()` | Writes to `cw-api-calls.jsonl` |
--- ---
@@ -255,7 +305,7 @@ The shared Axios instance (`connectWiseApi`) is configured in `src/constants.ts`
``` ```
src/index.ts src/index.ts
├─ setInterval(refreshOpportunityCache, 30s) ├─ setInterval(refreshOpportunityCache, 20m)
└─► src/modules/cache/opportunityCache.ts └─► src/modules/cache/opportunityCache.ts
@@ -283,15 +333,16 @@ src/index.ts
## File reference ## File reference
| File | Purpose | | File | Purpose |
|---|---| | ---------------------------------------------------------------- | ------------------------------------------------------------- |
| `src/modules/cache/opportunityCache.ts` | Cache read/write helpers, background refresh logic | | `src/modules/cache/opportunityCache.ts` | Cache read/write helpers, background refresh logic |
| `src/modules/algorithms/computeCacheTTL.ts` | Primary adaptive TTL algorithm | | `src/modules/algorithms/computeCacheTTL.ts` | Primary adaptive TTL algorithm |
| `src/modules/algorithms/computeSubResourceCacheTTL.ts` | Sub-resource (notes, contacts) TTL algorithm | | `src/modules/algorithms/computeSubResourceCacheTTL.ts` | Sub-resource (notes, contacts) TTL algorithm |
| `src/modules/algorithms/computeProductsCacheTTL.ts` | Products TTL algorithm | | `src/modules/algorithms/computeProductsCacheTTL.ts` | Products TTL algorithm |
| `src/modules/cw-utils/withCwRetry.ts` | Retry wrapper with exponential backoff | | `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/cwApiLogger.ts` | Axios interceptor for JSONL call logging |
| `src/modules/cw-utils/fetchCompany.ts` | Company fetch with retry | | `src/modules/cw-utils/fetchCompany.ts` | Company fetch with retry |
| `src/constants.ts` | CW Axios instance config (timeout, logger) | | `src/modules/cw-utils/procurement/listenInventoryAdjustments.ts` | Adjustment listener for targeted catalog-item cache + DB sync |
| `src/index.ts` | Refresh interval registration | | `src/constants.ts` | CW Axios instance config (timeout, logger) |
| `debug-scripts/analyze-cw-calls.py` | CW API call analysis script | | `src/index.ts` | Refresh interval registration |
| `debug-scripts/analyze-cw-calls.py` | CW API call analysis script |
+35 -10
View File
@@ -124,20 +124,44 @@ Admin-specific UI permissions that control visibility and data loading for admin
| `procurement.catalog.inventory.refresh` | Refresh on-hand inventory for a catalog item from ConnectWise | [src/api/procurement/[id]/refreshInventory.ts](src/api/procurement/[id]/refreshInventory.ts) | `procurement.catalog.fetch` | | `procurement.catalog.inventory.refresh` | Refresh on-hand inventory for a catalog item from ConnectWise | [src/api/procurement/[id]/refreshInventory.ts](src/api/procurement/[id]/refreshInventory.ts) | `procurement.catalog.fetch` |
| `procurement.catalog.link` | Link or unlink catalog items to each other | [src/api/procurement/[id]/link.ts](src/api/procurement/[id]/link.ts), [src/api/procurement/[id]/unlink.ts](src/api/procurement/[id]/unlink.ts) | `procurement.catalog.fetch` | | `procurement.catalog.link` | Link or unlink catalog items to each other | [src/api/procurement/[id]/link.ts](src/api/procurement/[id]/link.ts), [src/api/procurement/[id]/unlink.ts](src/api/procurement/[id]/unlink.ts) | `procurement.catalog.fetch` |
### ConnectWise 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 ### 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. Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (products, notes, contacts) are fetched live from CW.
| Permission Node | Description | Used In | Dependencies | **WebSocket note:** The `/secure` socket event chain `opp:live_quote_preview` and `opp:live_quote_preview:<id>:data` is gated by `sales.opportunity.fetch`.
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
| `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) | | | Permission Node | Description | Used In | Dependencies |
| `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.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.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.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.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.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/opportunities/[id]/refresh.ts](src/api/sales/opportunities/[id]/refresh.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.ts) | `sales.opportunity.fetch` | | `sales.opportunity.update` | Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise | [src/api/sales/opportunities/[id]/update.ts](src/api/sales/opportunities/[id]/update.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.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.create` | Create a new opportunity in ConnectWise | [src/api/sales/opportunities/create.ts](src/api/sales/opportunities/create.ts) | |
| `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.note.create` | Create a new note on an opportunity | [src/api/sales/opportunities/[id]/notes/create.ts](src/api/sales/opportunities/[id]/notes/create.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/opportunities/[id]/notes/update.ts](src/api/sales/opportunities/[id]/notes/update.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/opportunities/[id]/notes/delete.ts](src/api/sales/opportunities/[id]/notes/delete.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/opportunities/[id]/products/resequence.ts](src/api/sales/opportunities/[id]/products/resequence.ts), [src/api/sales/opportunities/[id]/products/update.ts](src/api/sales/opportunities/[id]/products/update.ts), [src/api/sales/opportunities/[id]/products/cancel.ts](src/api/sales/opportunities/[id]/products/cancel.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<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` |
<details> <details>
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary> <summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
@@ -339,6 +363,7 @@ All fetch and fetchAll routes gate response object keys using `processObjectValu
| `obj.opportunity.site` | View site | | `obj.opportunity.site` | View site |
| `obj.opportunity.customerPO` | View customer PO | | `obj.opportunity.customerPO` | View customer PO |
| `obj.opportunity.totalSalesTax` | View total sales tax | | `obj.opportunity.totalSalesTax` | View total sales tax |
| `obj.opportunity.probability` | View probability percentage |
| `obj.opportunity.location` | View location | | `obj.opportunity.location` | View location |
| `obj.opportunity.department` | View department | | `obj.opportunity.department` | View department |
| `obj.opportunity.expectedCloseDate` | View expected close date | | `obj.opportunity.expectedCloseDate` | View expected close date |
+58
View File
@@ -19,6 +19,8 @@
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"keypair": "^1.0.4", "keypair": "^1.0.4",
"pdf-lib": "^1.17.1",
"pdfmake": "^0.3.5",
"prisma": "^7.3.0", "prisma": "^7.3.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"zod": "^4.3.6", "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=="], "@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/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=="], "@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=="], "@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/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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"cuid": ["cuid@3.0.0", "", {}, "sha512-WZYYkHdIDnaxdeP8Misq3Lah5vFjJwGuItJuV+tvMafosMzw0nF297T7mrm8IOWiPJkV6gc7sa8pzx27+w25Zg=="], "cuid": ["cuid@3.0.0", "", {}, "sha512-WZYYkHdIDnaxdeP8Misq3Lah5vFjJwGuItJuV+tvMafosMzw0nF297T7mrm8IOWiPJkV6gc7sa8pzx27+w25Zg=="],
@@ -164,6 +180,8 @@
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], "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=="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], "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=="], "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=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "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=="], "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=="], "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=="], "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": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
"postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
@@ -350,12 +386,16 @@
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="], "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=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "@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=="], "nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="],
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "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=="], "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 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 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> _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> = { export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
@@ -521,4 +538,21 @@ export type NestedJsonFilterBase<$PrismaModel = never> = {
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter 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
+203 -2
View File
@@ -393,7 +393,9 @@ export const ModelName = {
Opportunity: 'Opportunity', Opportunity: 'Opportunity',
CredentialType: 'CredentialType', CredentialType: 'CredentialType',
SecureValue: 'SecureValue', SecureValue: 'SecureValue',
Credential: 'Credential' Credential: 'Credential',
GeneratedQuotes: 'GeneratedQuotes',
CwMember: 'CwMember'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -409,7 +411,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { meta: {
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential" modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential" | "generatedQuotes" | "cwMember"
txIsolationLevel: TransactionIsolationLevel txIsolationLevel: TransactionIsolationLevel
} }
model: { 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: { other: {
@@ -1322,6 +1472,7 @@ export const OpportunityScalarFieldEnum = {
siteName: 'siteName', siteName: 'siteName',
customerPO: 'customerPO', customerPO: 'customerPO',
totalSalesTax: 'totalSalesTax', totalSalesTax: 'totalSalesTax',
probability: 'probability',
locationName: 'locationName', locationName: 'locationName',
locationCwId: 'locationCwId', locationCwId: 'locationCwId',
departmentName: 'departmentName', departmentName: 'departmentName',
@@ -1384,6 +1535,40 @@ export const CredentialScalarFieldEnum = {
export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof 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 = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -1506,6 +1691,20 @@ export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'J
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'> 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 * Batch Payload for updateMany & deleteMany & createMany
*/ */
@@ -1611,6 +1810,8 @@ export type GlobalOmitConfig = {
credentialType?: Prisma.CredentialTypeOmit credentialType?: Prisma.CredentialTypeOmit
secureValue?: Prisma.SecureValueOmit secureValue?: Prisma.SecureValueOmit
credential?: Prisma.CredentialOmit credential?: Prisma.CredentialOmit
generatedQuotes?: Prisma.GeneratedQuotesOmit
cwMember?: Prisma.CwMemberOmit
} }
/* Types for Logging */ /* Types for Logging */
@@ -60,7 +60,9 @@ export const ModelName = {
Opportunity: 'Opportunity', Opportunity: 'Opportunity',
CredentialType: 'CredentialType', CredentialType: 'CredentialType',
SecureValue: 'SecureValue', SecureValue: 'SecureValue',
Credential: 'Credential' Credential: 'Credential',
GeneratedQuotes: 'GeneratedQuotes',
CwMember: 'CwMember'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -209,6 +211,7 @@ export const OpportunityScalarFieldEnum = {
siteName: 'siteName', siteName: 'siteName',
customerPO: 'customerPO', customerPO: 'customerPO',
totalSalesTax: 'totalSalesTax', totalSalesTax: 'totalSalesTax',
probability: 'probability',
locationName: 'locationName', locationName: 'locationName',
locationCwId: 'locationCwId', locationCwId: 'locationCwId',
departmentName: 'departmentName', departmentName: 'departmentName',
@@ -271,6 +274,40 @@ export const CredentialScalarFieldEnum = {
export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof 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 = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' 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/CredentialType.ts'
export type * from './models/SecureValue.ts' export type * from './models/SecureValue.ts'
export type * from './models/Credential.ts' export type * from './models/Credential.ts'
export type * from './models/GeneratedQuotes.ts'
export type * from './models/CwMember.ts'
export type * from './commonInputTypes.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
+351 -1
View File
@@ -40,6 +40,7 @@ export type OpportunityAvgAggregateOutputType = {
contactCwId: number | null contactCwId: number | null
siteCwId: number | null siteCwId: number | null
totalSalesTax: number | null totalSalesTax: number | null
probability: number | null
locationCwId: number | null locationCwId: number | null
departmentCwId: number | null departmentCwId: number | null
closedByCwId: number | null closedByCwId: number | null
@@ -60,6 +61,7 @@ export type OpportunitySumAggregateOutputType = {
contactCwId: number | null contactCwId: number | null
siteCwId: number | null siteCwId: number | null
totalSalesTax: number | null totalSalesTax: number | null
probability: number | null
locationCwId: number | null locationCwId: number | null
departmentCwId: number | null departmentCwId: number | null
closedByCwId: number | null closedByCwId: number | null
@@ -98,6 +100,7 @@ export type OpportunityMinAggregateOutputType = {
siteName: string | null siteName: string | null
customerPO: string | null customerPO: string | null
totalSalesTax: number | null totalSalesTax: number | null
probability: number | null
locationName: string | null locationName: string | null
locationCwId: number | null locationCwId: number | null
departmentName: string | null departmentName: string | null
@@ -147,6 +150,7 @@ export type OpportunityMaxAggregateOutputType = {
siteName: string | null siteName: string | null
customerPO: string | null customerPO: string | null
totalSalesTax: number | null totalSalesTax: number | null
probability: number | null
locationName: string | null locationName: string | null
locationCwId: number | null locationCwId: number | null
departmentName: string | null departmentName: string | null
@@ -196,6 +200,7 @@ export type OpportunityCountAggregateOutputType = {
siteName: number siteName: number
customerPO: number customerPO: number
totalSalesTax: number totalSalesTax: number
probability: number
locationName: number locationName: number
locationCwId: number locationCwId: number
departmentName: number departmentName: number
@@ -230,6 +235,7 @@ export type OpportunityAvgAggregateInputType = {
contactCwId?: true contactCwId?: true
siteCwId?: true siteCwId?: true
totalSalesTax?: true totalSalesTax?: true
probability?: true
locationCwId?: true locationCwId?: true
departmentCwId?: true departmentCwId?: true
closedByCwId?: true closedByCwId?: true
@@ -250,6 +256,7 @@ export type OpportunitySumAggregateInputType = {
contactCwId?: true contactCwId?: true
siteCwId?: true siteCwId?: true
totalSalesTax?: true totalSalesTax?: true
probability?: true
locationCwId?: true locationCwId?: true
departmentCwId?: true departmentCwId?: true
closedByCwId?: true closedByCwId?: true
@@ -288,6 +295,7 @@ export type OpportunityMinAggregateInputType = {
siteName?: true siteName?: true
customerPO?: true customerPO?: true
totalSalesTax?: true totalSalesTax?: true
probability?: true
locationName?: true locationName?: true
locationCwId?: true locationCwId?: true
departmentName?: true departmentName?: true
@@ -337,6 +345,7 @@ export type OpportunityMaxAggregateInputType = {
siteName?: true siteName?: true
customerPO?: true customerPO?: true
totalSalesTax?: true totalSalesTax?: true
probability?: true
locationName?: true locationName?: true
locationCwId?: true locationCwId?: true
departmentName?: true departmentName?: true
@@ -386,6 +395,7 @@ export type OpportunityCountAggregateInputType = {
siteName?: true siteName?: true
customerPO?: true customerPO?: true
totalSalesTax?: true totalSalesTax?: true
probability?: true
locationName?: true locationName?: true
locationCwId?: true locationCwId?: true
departmentName?: true departmentName?: true
@@ -523,6 +533,7 @@ export type OpportunityGroupByOutputType = {
siteName: string | null siteName: string | null
customerPO: string | null customerPO: string | null
totalSalesTax: number totalSalesTax: number
probability: number
locationName: string | null locationName: string | null
locationCwId: number | null locationCwId: number | null
departmentName: string | null departmentName: string | null
@@ -596,6 +607,7 @@ export type OpportunityWhereInput = {
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
probability?: Prisma.FloatFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
@@ -612,6 +624,7 @@ export type OpportunityWhereInput = {
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
} }
@@ -647,6 +660,7 @@ export type OpportunityOrderByWithRelationInput = {
siteName?: Prisma.SortOrderInput | Prisma.SortOrder siteName?: Prisma.SortOrderInput | Prisma.SortOrder
customerPO?: Prisma.SortOrderInput | Prisma.SortOrder customerPO?: Prisma.SortOrderInput | Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrderInput | Prisma.SortOrder locationName?: Prisma.SortOrderInput | Prisma.SortOrder
locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder
departmentName?: Prisma.SortOrderInput | Prisma.SortOrder departmentName?: Prisma.SortOrderInput | Prisma.SortOrder
@@ -663,6 +677,7 @@ export type OpportunityOrderByWithRelationInput = {
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
company?: Prisma.CompanyOrderByWithRelationInput company?: Prisma.CompanyOrderByWithRelationInput
} }
@@ -701,6 +716,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
probability?: Prisma.FloatFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
@@ -717,6 +733,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
}, "id" | "cwOpportunityId"> }, "id" | "cwOpportunityId">
@@ -752,6 +769,7 @@ export type OpportunityOrderByWithAggregationInput = {
siteName?: Prisma.SortOrderInput | Prisma.SortOrder siteName?: Prisma.SortOrderInput | Prisma.SortOrder
customerPO?: Prisma.SortOrderInput | Prisma.SortOrder customerPO?: Prisma.SortOrderInput | Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrderInput | Prisma.SortOrder locationName?: Prisma.SortOrderInput | Prisma.SortOrder
locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder
departmentName?: Prisma.SortOrderInput | Prisma.SortOrder departmentName?: Prisma.SortOrderInput | Prisma.SortOrder
@@ -810,6 +828,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
siteName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null siteName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null customerPO?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number totalSalesTax?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number
probability?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null locationName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null locationCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null departmentName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
@@ -860,6 +879,7 @@ export type OpportunityCreateInput = {
siteName?: string | null siteName?: string | null
customerPO?: string | null customerPO?: string | null
totalSalesTax?: number totalSalesTax?: number
probability?: number
locationName?: string | null locationName?: string | null
locationCwId?: number | null locationCwId?: number | null
departmentName?: string | null departmentName?: string | null
@@ -875,6 +895,7 @@ export type OpportunityCreateInput = {
cwLastUpdated?: Date | string | null cwLastUpdated?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
} }
@@ -910,6 +931,7 @@ export type OpportunityUncheckedCreateInput = {
siteName?: string | null siteName?: string | null
customerPO?: string | null customerPO?: string | null
totalSalesTax?: number totalSalesTax?: number
probability?: number
locationName?: string | null locationName?: string | null
locationCwId?: number | null locationCwId?: number | null
departmentName?: string | null departmentName?: string | null
@@ -926,6 +948,7 @@ export type OpportunityUncheckedCreateInput = {
cwLastUpdated?: Date | string | null cwLastUpdated?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
} }
export type OpportunityUpdateInput = { export type OpportunityUpdateInput = {
@@ -960,6 +983,7 @@ export type OpportunityUpdateInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -975,6 +999,7 @@ export type OpportunityUpdateInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
} }
@@ -1010,6 +1035,7 @@ export type OpportunityUncheckedUpdateInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1026,6 +1052,7 @@ export type OpportunityUncheckedUpdateInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
} }
export type OpportunityCreateManyInput = { export type OpportunityCreateManyInput = {
@@ -1060,6 +1087,7 @@ export type OpportunityCreateManyInput = {
siteName?: string | null siteName?: string | null
customerPO?: string | null customerPO?: string | null
totalSalesTax?: number totalSalesTax?: number
probability?: number
locationName?: string | null locationName?: string | null
locationCwId?: number | null locationCwId?: number | null
departmentName?: string | null departmentName?: string | null
@@ -1110,6 +1138,7 @@ export type OpportunityUpdateManyMutationInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1159,6 +1188,7 @@ export type OpportunityUncheckedUpdateManyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1227,6 +1257,7 @@ export type OpportunityCountOrderByAggregateInput = {
siteName?: Prisma.SortOrder siteName?: Prisma.SortOrder
customerPO?: Prisma.SortOrder customerPO?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrder locationName?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder locationCwId?: Prisma.SortOrder
departmentName?: Prisma.SortOrder departmentName?: Prisma.SortOrder
@@ -1259,6 +1290,7 @@ export type OpportunityAvgOrderByAggregateInput = {
contactCwId?: Prisma.SortOrder contactCwId?: Prisma.SortOrder
siteCwId?: Prisma.SortOrder siteCwId?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder locationCwId?: Prisma.SortOrder
departmentCwId?: Prisma.SortOrder departmentCwId?: Prisma.SortOrder
closedByCwId?: Prisma.SortOrder closedByCwId?: Prisma.SortOrder
@@ -1297,6 +1329,7 @@ export type OpportunityMaxOrderByAggregateInput = {
siteName?: Prisma.SortOrder siteName?: Prisma.SortOrder
customerPO?: Prisma.SortOrder customerPO?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrder locationName?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder locationCwId?: Prisma.SortOrder
departmentName?: Prisma.SortOrder departmentName?: Prisma.SortOrder
@@ -1346,6 +1379,7 @@ export type OpportunityMinOrderByAggregateInput = {
siteName?: Prisma.SortOrder siteName?: Prisma.SortOrder
customerPO?: Prisma.SortOrder customerPO?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationName?: Prisma.SortOrder locationName?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder locationCwId?: Prisma.SortOrder
departmentName?: Prisma.SortOrder departmentName?: Prisma.SortOrder
@@ -1377,12 +1411,18 @@ export type OpportunitySumOrderByAggregateInput = {
contactCwId?: Prisma.SortOrder contactCwId?: Prisma.SortOrder
siteCwId?: Prisma.SortOrder siteCwId?: Prisma.SortOrder
totalSalesTax?: Prisma.SortOrder totalSalesTax?: Prisma.SortOrder
probability?: Prisma.SortOrder
locationCwId?: Prisma.SortOrder locationCwId?: Prisma.SortOrder
departmentCwId?: Prisma.SortOrder departmentCwId?: Prisma.SortOrder
closedByCwId?: Prisma.SortOrder closedByCwId?: Prisma.SortOrder
productSequence?: Prisma.SortOrder productSequence?: Prisma.SortOrder
} }
export type OpportunityScalarRelationFilter = {
is?: Prisma.OpportunityWhereInput
isNot?: Prisma.OpportunityWhereInput
}
export type OpportunityCreateNestedManyWithoutCompanyInput = { export type OpportunityCreateNestedManyWithoutCompanyInput = {
create?: Prisma.XOR<Prisma.OpportunityCreateWithoutCompanyInput, Prisma.OpportunityUncheckedCreateWithoutCompanyInput> | Prisma.OpportunityCreateWithoutCompanyInput[] | Prisma.OpportunityUncheckedCreateWithoutCompanyInput[] create?: Prisma.XOR<Prisma.OpportunityCreateWithoutCompanyInput, Prisma.OpportunityUncheckedCreateWithoutCompanyInput> | Prisma.OpportunityCreateWithoutCompanyInput[] | Prisma.OpportunityUncheckedCreateWithoutCompanyInput[]
connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutCompanyInput | Prisma.OpportunityCreateOrConnectWithoutCompanyInput[] connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutCompanyInput | Prisma.OpportunityCreateOrConnectWithoutCompanyInput[]
@@ -1434,6 +1474,20 @@ export type OpportunityUpdateproductSequenceInput = {
push?: number | number[] 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 = { export type OpportunityCreateWithoutCompanyInput = {
id?: string id?: string
cwOpportunityId: number cwOpportunityId: number
@@ -1466,6 +1520,7 @@ export type OpportunityCreateWithoutCompanyInput = {
siteName?: string | null siteName?: string | null
customerPO?: string | null customerPO?: string | null
totalSalesTax?: number totalSalesTax?: number
probability?: number
locationName?: string | null locationName?: string | null
locationCwId?: number | null locationCwId?: number | null
departmentName?: string | null departmentName?: string | null
@@ -1481,6 +1536,7 @@ export type OpportunityCreateWithoutCompanyInput = {
cwLastUpdated?: Date | string | null cwLastUpdated?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
} }
export type OpportunityUncheckedCreateWithoutCompanyInput = { export type OpportunityUncheckedCreateWithoutCompanyInput = {
@@ -1515,6 +1571,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
siteName?: string | null siteName?: string | null
customerPO?: string | null customerPO?: string | null
totalSalesTax?: number totalSalesTax?: number
probability?: number
locationName?: string | null locationName?: string | null
locationCwId?: number | null locationCwId?: number | null
departmentName?: string | null departmentName?: string | null
@@ -1530,6 +1587,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
cwLastUpdated?: Date | string | null cwLastUpdated?: Date | string | null
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
} }
export type OpportunityCreateOrConnectWithoutCompanyInput = { export type OpportunityCreateOrConnectWithoutCompanyInput = {
@@ -1593,6 +1651,7 @@ export type OpportunityScalarWhereInput = {
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
probability?: Prisma.FloatFilter<"Opportunity"> | number
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
@@ -1611,6 +1670,226 @@ export type OpportunityScalarWhereInput = {
updatedAt?: 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
createdAt?: Date | string
updatedAt?: Date | string
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
}
export type OpportunityUncheckedCreateWithoutGeneratedQuotesInput = {
id?: string
cwOpportunityId: number
name: string
notes?: string | null
typeName?: string | null
typeCwId?: number | null
stageName?: string | null
stageCwId?: number | null
statusName?: string | null
statusCwId?: number | null
priorityName?: string | null
priorityCwId?: number | null
ratingName?: string | null
ratingCwId?: number | null
source?: string | null
campaignName?: string | null
campaignCwId?: number | null
primarySalesRepName?: string | null
primarySalesRepIdentifier?: string | null
primarySalesRepCwId?: number | null
secondarySalesRepName?: string | null
secondarySalesRepIdentifier?: string | null
secondarySalesRepCwId?: number | null
companyCwId?: number | null
companyName?: string | null
contactCwId?: number | null
contactName?: string | null
siteCwId?: number | null
siteName?: string | null
customerPO?: string | null
totalSalesTax?: number
probability?: number
locationName?: string | null
locationCwId?: number | null
departmentName?: string | null
departmentCwId?: number | null
expectedCloseDate?: Date | string | null
pipelineChangeDate?: Date | string | null
dateBecameLead?: Date | string | null
closedDate?: Date | string | null
closedFlag?: boolean
closedByName?: string | null
closedByCwId?: number | null
companyId?: string | null
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
}
export type OpportunityCreateOrConnectWithoutGeneratedQuotesInput = {
where: Prisma.OpportunityWhereUniqueInput
create: Prisma.XOR<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
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
}
export type OpportunityUncheckedUpdateWithoutGeneratedQuotesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
cwOpportunityId?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
typeName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
typeCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
stageName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
stageCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
statusName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
statusCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
priorityName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
priorityCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
ratingName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ratingCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
source?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
campaignName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
campaignCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
primarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
primarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
primarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
secondarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
secondarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
secondarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
contactCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
contactName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
siteCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
departmentCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
expectedCloseDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
pipelineChangeDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
dateBecameLead?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
closedDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
export type OpportunityCreateManyCompanyInput = { export type OpportunityCreateManyCompanyInput = {
id?: string id?: string
cwOpportunityId: number cwOpportunityId: number
@@ -1643,6 +1922,7 @@ export type OpportunityCreateManyCompanyInput = {
siteName?: string | null siteName?: string | null
customerPO?: string | null customerPO?: string | null
totalSalesTax?: number totalSalesTax?: number
probability?: number
locationName?: string | null locationName?: string | null
locationCwId?: number | null locationCwId?: number | null
departmentName?: string | null departmentName?: string | null
@@ -1692,6 +1972,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1707,6 +1988,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
} }
export type OpportunityUncheckedUpdateWithoutCompanyInput = { export type OpportunityUncheckedUpdateWithoutCompanyInput = {
@@ -1741,6 +2023,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1756,6 +2039,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
} }
export type OpportunityUncheckedUpdateManyWithoutCompanyInput = { export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
@@ -1790,6 +2074,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
probability?: Prisma.FloatFieldUpdateOperationsInput | number
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -1808,6 +2093,35 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
} }
/**
* 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<{ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -1841,6 +2155,7 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
siteName?: boolean siteName?: boolean
customerPO?: boolean customerPO?: boolean
totalSalesTax?: boolean totalSalesTax?: boolean
probability?: boolean
locationName?: boolean locationName?: boolean
locationCwId?: boolean locationCwId?: boolean
departmentName?: boolean departmentName?: boolean
@@ -1857,7 +2172,9 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
cwLastUpdated?: boolean cwLastUpdated?: boolean
createdAt?: boolean createdAt?: boolean
updatedAt?: boolean updatedAt?: boolean
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs> company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
_count?: boolean | Prisma.OpportunityCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["opportunity"]> }, ExtArgs["result"]["opportunity"]>
export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@@ -1892,6 +2209,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
siteName?: boolean siteName?: boolean
customerPO?: boolean customerPO?: boolean
totalSalesTax?: boolean totalSalesTax?: boolean
probability?: boolean
locationName?: boolean locationName?: boolean
locationCwId?: boolean locationCwId?: boolean
departmentName?: boolean departmentName?: boolean
@@ -1943,6 +2261,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
siteName?: boolean siteName?: boolean
customerPO?: boolean customerPO?: boolean
totalSalesTax?: boolean totalSalesTax?: boolean
probability?: boolean
locationName?: boolean locationName?: boolean
locationCwId?: boolean locationCwId?: boolean
departmentName?: boolean departmentName?: boolean
@@ -1994,6 +2313,7 @@ export type OpportunitySelectScalar = {
siteName?: boolean siteName?: boolean
customerPO?: boolean customerPO?: boolean
totalSalesTax?: boolean totalSalesTax?: boolean
probability?: boolean
locationName?: boolean locationName?: boolean
locationCwId?: boolean locationCwId?: boolean
departmentName?: boolean departmentName?: boolean
@@ -2012,9 +2332,11 @@ export type OpportunitySelectScalar = {
updatedAt?: boolean updatedAt?: boolean
} }
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "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" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
export type OpportunityInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type OpportunityInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
company?: boolean | Prisma.Opportunity$companyArgs<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> = { export type OpportunityIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs> company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
@@ -2026,6 +2348,7 @@ export type OpportunityIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.
export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "Opportunity" name: "Opportunity"
objects: { objects: {
generatedQuotes: Prisma.$GeneratedQuotesPayload<ExtArgs>[]
company: Prisma.$CompanyPayload<ExtArgs> | null company: Prisma.$CompanyPayload<ExtArgs> | null
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
@@ -2060,6 +2383,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
siteName: string | null siteName: string | null
customerPO: string | null customerPO: string | null
totalSalesTax: number totalSalesTax: number
probability: number
locationName: string | null locationName: string | null
locationCwId: number | null locationCwId: number | null
departmentName: string | null departmentName: string | null
@@ -2470,6 +2794,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> { 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" 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> 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. * Attaches callbacks for the resolution and/or rejection of the Promise.
@@ -2531,6 +2856,7 @@ export interface OpportunityFieldRefs {
readonly siteName: Prisma.FieldRef<"Opportunity", 'String'> readonly siteName: Prisma.FieldRef<"Opportunity", 'String'>
readonly customerPO: Prisma.FieldRef<"Opportunity", 'String'> readonly customerPO: Prisma.FieldRef<"Opportunity", 'String'>
readonly totalSalesTax: Prisma.FieldRef<"Opportunity", 'Float'> readonly totalSalesTax: Prisma.FieldRef<"Opportunity", 'Float'>
readonly probability: Prisma.FieldRef<"Opportunity", 'Float'>
readonly locationName: Prisma.FieldRef<"Opportunity", 'String'> readonly locationName: Prisma.FieldRef<"Opportunity", 'String'>
readonly locationCwId: Prisma.FieldRef<"Opportunity", 'Int'> readonly locationCwId: Prisma.FieldRef<"Opportunity", 'Int'>
readonly departmentName: Prisma.FieldRef<"Opportunity", 'String'> readonly departmentName: Prisma.FieldRef<"Opportunity", 'String'>
@@ -2942,6 +3268,30 @@ export type OpportunityDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.I
limit?: number 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 * Opportunity.company
*/ */
+157
View File
@@ -240,6 +240,7 @@ export type UserWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
roles?: Prisma.RoleListRelationFilter roles?: Prisma.RoleListRelationFilter
sessions?: Prisma.SessionListRelationFilter sessions?: Prisma.SessionListRelationFilter
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
} }
export type UserOrderByWithRelationInput = { export type UserOrderByWithRelationInput = {
@@ -257,6 +258,7 @@ export type UserOrderByWithRelationInput = {
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
roles?: Prisma.RoleOrderByRelationAggregateInput roles?: Prisma.RoleOrderByRelationAggregateInput
sessions?: Prisma.SessionOrderByRelationAggregateInput sessions?: Prisma.SessionOrderByRelationAggregateInput
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
} }
export type UserWhereUniqueInput = Prisma.AtLeast<{ export type UserWhereUniqueInput = Prisma.AtLeast<{
@@ -277,6 +279,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
roles?: Prisma.RoleListRelationFilter roles?: Prisma.RoleListRelationFilter
sessions?: Prisma.SessionListRelationFilter sessions?: Prisma.SessionListRelationFilter
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
}, "id" | "login" | "email" | "userId"> }, "id" | "login" | "email" | "userId">
export type UserOrderByWithAggregationInput = { export type UserOrderByWithAggregationInput = {
@@ -330,6 +333,7 @@ export type UserCreateInput = {
updatedAt?: Date | string updatedAt?: Date | string
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
} }
export type UserUncheckedCreateInput = { export type UserUncheckedCreateInput = {
@@ -347,6 +351,7 @@ export type UserUncheckedCreateInput = {
updatedAt?: Date | string updatedAt?: Date | string
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
} }
export type UserUpdateInput = { export type UserUpdateInput = {
@@ -364,6 +369,7 @@ export type UserUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
} }
export type UserUncheckedUpdateInput = { export type UserUncheckedUpdateInput = {
@@ -381,6 +387,7 @@ export type UserUncheckedUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
} }
export type UserCreateManyInput = { export type UserCreateManyInput = {
@@ -488,6 +495,11 @@ export type UserOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder _count?: Prisma.SortOrder
} }
export type UserNullableScalarRelationFilter = {
is?: Prisma.UserWhereInput | null
isNot?: Prisma.UserWhereInput | null
}
export type UserCreateNestedOneWithoutSessionsInput = { export type UserCreateNestedOneWithoutSessionsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutSessionsInput, Prisma.UserUncheckedCreateWithoutSessionsInput> create?: Prisma.XOR<Prisma.UserCreateWithoutSessionsInput, Prisma.UserUncheckedCreateWithoutSessionsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutSessionsInput connectOrCreate?: Prisma.UserCreateOrConnectWithoutSessionsInput
@@ -544,6 +556,22 @@ export type UserUncheckedUpdateManyWithoutRolesNestedInput = {
deleteMany?: Prisma.UserScalarWhereInput | Prisma.UserScalarWhereInput[] 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 = { export type UserCreateWithoutSessionsInput = {
id?: string id?: string
permissions?: string | null permissions?: string | null
@@ -558,6 +586,7 @@ export type UserCreateWithoutSessionsInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
} }
export type UserUncheckedCreateWithoutSessionsInput = { export type UserUncheckedCreateWithoutSessionsInput = {
@@ -574,6 +603,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
} }
export type UserCreateOrConnectWithoutSessionsInput = { export type UserCreateOrConnectWithoutSessionsInput = {
@@ -606,6 +636,7 @@ export type UserUpdateWithoutSessionsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
} }
export type UserUncheckedUpdateWithoutSessionsInput = { export type UserUncheckedUpdateWithoutSessionsInput = {
@@ -622,6 +653,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
} }
export type UserCreateWithoutRolesInput = { export type UserCreateWithoutRolesInput = {
@@ -638,6 +670,7 @@ export type UserCreateWithoutRolesInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
} }
export type UserUncheckedCreateWithoutRolesInput = { export type UserUncheckedCreateWithoutRolesInput = {
@@ -654,6 +687,7 @@ export type UserUncheckedCreateWithoutRolesInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
} }
export type UserCreateOrConnectWithoutRolesInput = { export type UserCreateOrConnectWithoutRolesInput = {
@@ -695,6 +729,90 @@ export type UserScalarWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string 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 = { export type UserUpdateWithoutRolesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string id?: Prisma.StringFieldUpdateOperationsInput | string
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
@@ -709,6 +827,7 @@ export type UserUpdateWithoutRolesInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
} }
export type UserUncheckedUpdateWithoutRolesInput = { export type UserUncheckedUpdateWithoutRolesInput = {
@@ -725,6 +844,7 @@ export type UserUncheckedUpdateWithoutRolesInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
} }
export type UserUncheckedUpdateManyWithoutRolesInput = { export type UserUncheckedUpdateManyWithoutRolesInput = {
@@ -750,11 +870,13 @@ export type UserUncheckedUpdateManyWithoutRolesInput = {
export type UserCountOutputType = { export type UserCountOutputType = {
roles: number roles: number
sessions: number sessions: number
generatedQuotes: number
} }
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
roles?: boolean | UserCountOutputTypeCountRolesArgs roles?: boolean | UserCountOutputTypeCountRolesArgs
sessions?: boolean | UserCountOutputTypeCountSessionsArgs sessions?: boolean | UserCountOutputTypeCountSessionsArgs
generatedQuotes?: boolean | UserCountOutputTypeCountGeneratedQuotesArgs
} }
/** /**
@@ -781,6 +903,13 @@ export type UserCountOutputTypeCountSessionsArgs<ExtArgs extends runtime.Types.E
where?: Prisma.SessionWhereInput 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<{ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -797,6 +926,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
updatedAt?: boolean updatedAt?: boolean
roles?: boolean | Prisma.User$rolesArgs<ExtArgs> roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs> sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]> }, 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> = { export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
roles?: boolean | Prisma.User$rolesArgs<ExtArgs> roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs> sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
} }
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {} 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: { objects: {
roles: Prisma.$RolePayload<ExtArgs>[] roles: Prisma.$RolePayload<ExtArgs>[]
sessions: Prisma.$SessionPayload<ExtArgs>[] sessions: Prisma.$SessionPayload<ExtArgs>[]
generatedQuotes: Prisma.$GeneratedQuotesPayload<ExtArgs>[]
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
@@ -1269,6 +1401,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
readonly [Symbol.toStringTag]: "PrismaPromise" 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> 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> 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. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @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[] 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 * 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:gen_private_keys": "bun ./utils/genPrivateKeys",
"utils:create_admin_role": "bun ./utils/createAdminRole", "utils:create_admin_role": "bun ./utils/createAdminRole",
"utils:assign_user_role": "bun ./utils/assignUserRole", "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", "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" "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", "ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"keypair": "^1.0.4", "keypair": "^1.0.4",
"pdf-lib": "^1.17.1",
"pdfmake": "^0.3.5",
"prisma": "^7.3.0", "prisma": "^7.3.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"zod": "^4.3.6", "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");
+45 -2
View File
@@ -41,8 +41,9 @@ model User {
sessions Session[] sessions Session[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
generatedQuotes GeneratedQuotes[]
} }
model Role { model Role {
@@ -130,6 +131,8 @@ model Opportunity {
name String name String
notes String? notes String?
generatedQuotes GeneratedQuotes[]
// Stage / status / priority / type / rating stored as JSON references // Stage / status / priority / type / rating stored as JSON references
// so we don't need separate lookup tables for CW enums // so we don't need separate lookup tables for CW enums
typeName String? typeName String?
@@ -165,6 +168,7 @@ model Opportunity {
// Financials // Financials
totalSalesTax Float @default(0) totalSalesTax Float @default(0)
probability Float @default(0)
// Location / department // Location / department
locationName String? locationName String?
@@ -244,3 +248,42 @@ model Credential {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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
}
+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;
+136 -18
View File
@@ -4,8 +4,23 @@ import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions"; 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/:identifier?include=notes,contacts,products,quotes */
export default createRoute( export default createRoute(
"get", "get",
["/opportunities/:identifier"], ["/opportunities/:identifier"],
@@ -19,19 +34,102 @@ export default createRoute(
.filter(Boolean), .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 if (!dbRecord) {
await item.fetchSite(); throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
const gatedData = await processObjectValuePerms( // Compute TTLs from DB state
item.toJson(), const subTtl = computeSubResourceCacheTTL({
"obj.opportunity", closedFlag: dbRecord.closedFlag,
c.get("user"), 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,
});
// Fetch requested sub-resources in parallel // ── Pre-warm sub-resources only on cache miss ───────────────────────
const subResourcePromises: Record<string, Promise<any>> = {}; // 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")) { if (includes.has("notes")) {
subResourcePromises.notes = item.fetchNotes(); subResourcePromises.notes = item.fetchNotes();
} }
@@ -43,22 +141,42 @@ export default createRoute(
.fetchProducts() .fetchProducts()
.then((products) => products.map((p) => p.toJson())); .then((products) => products.map((p) => p.toJson()));
} }
if (includes.has("quotes")) {
subResourcePromises.quotes = generatedQuotes
.fetchByOpportunity(item.id)
.then((quotes) => quotes.map((q) => q.toJson()));
}
const keys = Object.keys(subResourcePromises); const 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(
keys.forEach((k, i) => { 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]; (gatedData as any)[k] = results[i];
}); }
});
if (includes.has("notes")) {
(gatedData as any).opportunityNoteText =
typeof originalOpportunityNoteText === "string"
? originalOpportunityNoteText
: null;
} }
const response = apiResponse.successful( const response = apiResponse.successful(
"Opportunity fetched successfully!", "Opportunity fetched successfully!",
gatedData, gatedData,
); );
return c.json(response, response.status as ContentfulStatusCode); return c.json(response, response.status as ContentfulStatusCode);
}, },
authMiddleware({ permissions: ["sales.opportunity.fetch"] }), authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
+37 -13
View File
@@ -1,31 +1,55 @@
import { default as fetchAll } from "./fetchAll"; import { default as fetchAll } from "./opportunities/fetchAll";
import { default as createOpportunity } from "./opportunities/create";
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes"; import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
import { default as count } from "./count"; import { default as count } from "./opportunities/count";
import { default as fetch } from "./[id]/fetch"; import { default as fetch } from "./opportunities/[id]/fetch";
import { default as refresh } from "./[id]/refresh"; import { default as refresh } from "./opportunities/[id]/refresh";
import { default as products } from "./[id]/products"; import { default as updateOpportunity } from "./opportunities/[id]/update";
import { default as addProduct } from "./[id]/addProduct"; import { default as products } from "./opportunities/[id]/products/fetchAll";
import { default as resequenceProducts } from "./[id]/resequenceProducts"; import { default as addProduct } from "./opportunities/[id]/products/add";
import { default as notes } from "./[id]/notes"; import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
import { default as fetchNote } from "./[id]/fetchNote"; import { default as addLabor } from "./opportunities/[id]/products/addLabor";
import { default as createNote } from "./[id]/createNote"; import { default as laborOptions } from "./opportunities/[id]/products/laborOptions";
import { default as updateNote } from "./[id]/updateNote"; import { default as resequenceProducts } from "./opportunities/[id]/products/resequence";
import { default as deleteNote } from "./[id]/deleteNote"; import { default as updateProduct } from "./opportunities/[id]/products/update";
import { default as contacts } from "./[id]/contacts"; import { default as cancelProduct } from "./opportunities/[id]/products/cancel";
import { default as notes } from "./opportunities/[id]/notes/fetchAll";
import { default as fetchNote } from "./opportunities/[id]/notes/fetch";
import { default as createNote } from "./opportunities/[id]/notes/create";
import { default as updateNote } from "./opportunities/[id]/notes/update";
import { default as deleteNote } from "./opportunities/[id]/notes/delete";
import { default as contacts } from "./opportunities/[id]/contacts";
import { default as commitQuote } from "./opportunities/[id]/quotes/commit";
import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll";
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
export { export {
addProduct, addProduct,
addLabor,
laborOptions,
addSpecialOrderProduct,
count, count,
createOpportunity,
fetch, fetch,
fetchAll, fetchAll,
fetchOpportunityTypes, fetchOpportunityTypes,
products, products,
resequenceProducts, resequenceProducts,
updateProduct,
cancelProduct,
notes, notes,
fetchNote, fetchNote,
createNote, createNote,
updateNote, updateNote,
deleteNote, deleteNote,
contacts, contacts,
commitQuote,
fetchQuotes,
previewQuote,
downloadQuote,
fetchDownloads,
refresh, refresh,
updateOpportunity,
}; };
@@ -1,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/contacts */ /* GET /v1/sales/opportunities/:identifier/contacts */
export default createRoute( export default createRoute(
+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/:identifier?include=notes,contacts,products,quotes */
export default createRoute(
"get",
["/opportunities/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const includeParam = c.req.query("include") ?? "";
const includes = new Set(
includeParam
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
// ── Quick DB lookup (≈3ms) to get cwOpportunityId for pre-warming ──
const isNumeric = /^\d+$/.test(identifier);
const dbRecord = await prisma.opportunity.findFirst({
where: isNumeric
? { cwOpportunityId: Number(identifier) }
: { id: identifier },
select: {
cwOpportunityId: true,
companyCwId: true,
siteCwId: true,
closedFlag: true,
closedDate: true,
expectedCloseDate: true,
cwLastUpdated: true,
statusCwId: true,
},
});
if (!dbRecord) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
// Compute TTLs from DB state
const subTtl = computeSubResourceCacheTTL({
closedFlag: dbRecord.closedFlag,
closedDate: dbRecord.closedDate,
expectedCloseDate: dbRecord.expectedCloseDate,
lastUpdated: dbRecord.cwLastUpdated,
});
const prodTtl = computeProductsCacheTTL({
closedFlag: dbRecord.closedFlag,
closedDate: dbRecord.closedDate,
expectedCloseDate: dbRecord.expectedCloseDate,
lastUpdated: dbRecord.cwLastUpdated,
statusCwId: dbRecord.statusCwId,
});
// ── Pre-warm sub-resources only on cache miss ───────────────────────
// Check Redis first — if the background refresh has kept the keys warm,
// skip the CW calls entirely. Only fetch-and-cache on a miss.
const cwOppId = dbRecord.cwOpportunityId;
const _ignoreErrors = (p: Promise<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,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import { resolveMember } from "../../../modules/cw-utils/members/memberCache"; import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
import { z } from "zod"; import { z } from "zod";
/* POST /v1/sales/opportunities/:identifier/notes */ /* POST /v1/sales/opportunities/:identifier/notes */
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../Errors/GenericError"; import GenericError from "../../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */ /* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
export default createRoute( export default createRoute(
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../Errors/GenericError"; import GenericError from "../../../../../Errors/GenericError";
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */ /* GET /v1/sales/opportunities/:identifier/notes/:noteId */
export default createRoute( export default createRoute(
@@ -1,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/notes */ /* GET /v1/sales/opportunities/:identifier/notes */
export default createRoute( export default createRoute(
@@ -1,10 +1,10 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../Errors/GenericError"; import GenericError from "../../../../../Errors/GenericError";
import { resolveMember } from "../../../modules/cw-utils/members/memberCache"; import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
import { z } from "zod"; import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */ /* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */
@@ -1,9 +1,9 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions"; import { processObjectValuePerms } from "../../../../../modules/permission-utils/processObjectPermissions";
import { z } from "zod"; import { z } from "zod";
const productItemSchema = z const productItemSchema = z
@@ -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/:identifier/products/labor */
export default createRoute(
"post",
["/opportunities/: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/:identifier/products/special-order */
export default createRoute(
"post",
["/opportunities/: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/:identifier/products/:productId/cancel */
export default createRoute(
"patch",
["/opportunities/: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"] }),
);
@@ -1,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/products */ /* GET /v1/sales/opportunities/:identifier/products */
export default createRoute( export default createRoute(
@@ -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/:identifier/products/labor/options */
export default createRoute(
"get",
["/opportunities/: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,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod"; import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */ /* PATCH /v1/sales/opportunities/:identifier/products/sequence */
@@ -0,0 +1,242 @@
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(),
})
.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/:identifier/products/:productId/edit */
export default createRoute(
"patch",
["/opportunities/: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.customerDescription !== undefined &&
input.customerDescription !== null
) {
forecastPatch.customerDescription = input.customerDescription;
}
if (input.unitPrice !== undefined) {
forecastPatch.revenue = Number(
(input.unitPrice * effectiveQuantity).toFixed(2),
);
}
if (input.unitCost !== undefined) {
forecastPatch.cost = Number(
(input.unitCost * effectiveQuantity).toFixed(2),
);
}
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,39 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod";
const commitQuoteSchema = z
.object({
lineItemPricing: z.boolean().optional(),
includeQuoteNarrative: z.boolean().optional(),
includeItemNarratives: z.boolean().optional(),
})
.strict()
.optional();
/* POST /v1/sales/opportunities/:identifier/quote/commit */
export default createRoute(
"post",
["/opportunities/:identifier/quote/commit"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json().catch(() => undefined);
const opts = commitQuoteSchema.parse(body);
const item = await opportunities.fetchRecord(identifier);
const user = c.get("user");
const quote = await item.commitQuote(opts ?? {}, user);
const response = apiResponse.created(
"Quote committed successfully!",
quote.toJson({ includeRegenData: true, includeRegenParams: true }),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.commit"] }),
);
@@ -0,0 +1,55 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { injectPdfMetadata } from "../../../../../modules/pdf-utils";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
const VALID_FETCH_ACTIONS = ["download", "print"] as const;
type FetchAction = (typeof VALID_FETCH_ACTIONS)[number];
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/download?fetchAction=download|print */
export default createRoute(
"get",
["/opportunities/:identifier/quote/:quoteId/download"],
async (c) => {
const quoteId = c.req.param("quoteId");
const user = c.get("user");
const fetchAction = c.req.query("fetchAction") as FetchAction | undefined;
if (!fetchAction || !VALID_FETCH_ACTIONS.includes(fetchAction)) {
throw new GenericError({
status: 400,
name: "InvalidFetchAction",
message: `Query parameter 'fetchAction' is required and must be one of: ${VALID_FETCH_ACTIONS.join(", ")}`,
});
}
const downloadedAt = new Date().toISOString();
const quote = await generatedQuotes.recordDownload(quoteId, {
id: user.id,
name: user.name,
email: user.email,
fetchAction,
});
// Inject download-time metadata into the PDF's document properties
const pdfWithMetadata = await injectPdfMetadata(quote.quoteFile, {
downloadedAt,
downloadedById: user.id,
downloadedByName: user.name ?? undefined,
downloadedByEmail: user.email ?? undefined,
});
const response = apiResponse.successful("Quote downloaded successfully!", {
id: quote.id,
quoteFileName: quote.quoteFileName,
mimeType: "application/pdf",
contentBase64: Buffer.from(pdfWithMetadata).toString("base64"),
});
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.download"] }),
);
@@ -0,0 +1,27 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/quotes */
export default createRoute(
"get",
["/opportunities/:identifier/quotes"],
async (c) => {
const identifier = c.req.param("identifier");
const includeRegenData = c.req.query("includeRegenData") === "true";
const includeRegenParams = c.req.query("includeRegenParams") === "true";
const item = await opportunities.fetchRecord(identifier);
const quotes = await generatedQuotes.fetchByOpportunity(item.id);
const response = apiResponse.successful(
"Committed quotes fetched successfully!",
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams })),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.fetch"] }),
);
@@ -0,0 +1,33 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/quotes/downloads */
export default createRoute(
"get",
["/opportunities/:identifier/quotes/downloads"],
async (c) => {
const identifier = c.req.param("identifier");
const opportunity = await opportunities.fetchRecord(identifier);
const quotes = await generatedQuotes.fetchByOpportunity(opportunity.id);
const data = quotes.map((quote) => ({
quoteId: quote.id,
quoteFileName: quote.quoteFileName,
createdById: quote.createdById,
createdAt: quote.createdAt,
downloads: quote.downloads,
}));
const response = apiResponse.successful(
"Quote download history fetched successfully!",
data,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.fetch_downloads"] }),
);
@@ -0,0 +1,61 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/preview */
export default createRoute(
"get",
["/opportunities/:identifier/quote/:quoteId/preview"],
async (c) => {
const identifier = c.req.param("identifier");
const quoteId = c.req.param("quoteId");
const item = await opportunities.fetchRecord(identifier);
const quote = await generatedQuotes.fetch(quoteId);
const regenData =
quote.quoteRegenData && typeof quote.quoteRegenData === "object"
? (quote.quoteRegenData as Record<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,8 +1,8 @@
import { createRoute } from "../../../modules/api-utils/createRoute"; import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities"; import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization"; import { authMiddleware } from "../../../middleware/authorization";
/* POST /v1/sales/opportunities/:identifier/refresh */ /* POST /v1/sales/opportunities/:identifier/refresh */
export default createRoute( export default createRoute(
@@ -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/:identifier */
export default createRoute(
"patch",
["/opportunities/: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"] }),
);
@@ -1,8 +1,8 @@
import { createRoute } from "../../modules/api-utils/createRoute"; import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../managers/opportunities"; import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization"; import { authMiddleware } from "../../middleware/authorization";
/* GET /v1/sales/opportunities/count */ /* GET /v1/sales/opportunities/count */
export default createRoute( export default createRoute(
+86
View File
@@ -0,0 +1,86 @@
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 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);
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 { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../managers/opportunities"; import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../modules/api-utils/apiResponse"; import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status"; import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization"; import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions"; import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* GET /v1/sales/opportunities */ /* GET /v1/sales/opportunities */
export default createRoute( export default createRoute(
+1
View File
@@ -57,6 +57,7 @@ v1.route("/permissions", require("./routers/permissionRouter").default);
v1.route("/unifi", require("./routers/unifiRouter").default); v1.route("/unifi", require("./routers/unifiRouter").default);
v1.route("/procurement", require("./routers/procurementRouter").default); v1.route("/procurement", require("./routers/procurementRouter").default);
v1.route("/sales", require("./routers/salesRouter").default); v1.route("/sales", require("./routers/salesRouter").default);
v1.route("/cw", require("./routers/cwRouter").default);
app.route("/v1", v1); app.route("/v1", v1);
export default app; 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); const cwCompany = await fetchCwCompanyById(this.cw_CompanyId);
if (!cwCompany) return this; if (!cwCompany) return this;
const contactHref = cwCompany.defaultContact?._info?.contact_href;
const defaultContactData = contactHref
? await connectWiseApi.get(contactHref)
: undefined;
const allContactsData = await connectWiseApi.get( const allContactsData = await connectWiseApi.get(
`${cwCompany._info.contacts_href}&pageSize=1000`, `${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 = { this.cw_Data = {
company: cwCompany, company: cwCompany,
defaultContact: defaultContactData?.data ?? null, defaultContact: defaultContactData,
allContacts: allContactsData.data, 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 catalogItemIdentifier: string | null;
public productDescription: string; public productDescription: string;
public customerDescription: string | null;
public productNarrative: string | null;
public productClass: string; public productClass: string;
public forecastType: string; public forecastType: string;
@@ -74,6 +76,9 @@ export class ForecastProductController {
this.catalogItemIdentifier = data.catalogItem?.identifier ?? null; this.catalogItemIdentifier = data.catalogItem?.identifier ?? null;
this.productDescription = data.productDescription; 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.productClass = data.productClass;
this.forecastType = data.forecastType; this.forecastType = data.forecastType;
@@ -118,6 +123,24 @@ export class ForecastProductController {
* Enriches this forecast product with cancellation data from the * Enriches this forecast product with cancellation data from the
* procurement products endpoint. * 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: { public applyCancellationData(data: {
cancelledFlag?: boolean; cancelledFlag?: boolean;
quantityCancelled?: number; quantityCancelled?: number;
@@ -154,6 +177,38 @@ export class ForecastProductController {
return this.revenue - this.cost; 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 * Cancelled
* *
@@ -201,12 +256,17 @@ export class ForecastProductController {
? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier } ? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier }
: null, : null,
productDescription: this.productDescription, productDescription: this.productDescription,
customerDescription: this.customerDescription,
productNarrative: this.productNarrative,
productClass: this.productClass, productClass: this.productClass,
forecastType: this.forecastType, forecastType: this.forecastType,
revenue: this.revenue, revenue: this.revenue,
cost: this.cost, cost: this.cost,
margin: this.margin, margin: this.margin,
profit: this.profit, profit: this.profit,
effectiveQuantity: this.effectiveQuantity,
effectiveRevenue: this.effectiveRevenue,
effectiveCost: this.effectiveCost,
percentage: this.percentage, percentage: this.percentage,
includeFlag: this.includeFlag, includeFlag: this.includeFlag,
linkFlag: this.linkFlag, 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,
};
}
}
+648 -17
View File
@@ -14,12 +14,20 @@ import {
CWForecastItemCreate, CWForecastItemCreate,
CWOpportunity, CWOpportunity,
CWOpportunityNote, CWOpportunityNote,
CWOpportunityUpdate,
CWProcurementProduct,
CWProcurementProductCreate,
} from "../modules/cw-utils/opportunities/opportunity.types"; } 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 { ForecastProductController } from "./ForecastProductController";
import GenericError from "../Errors/GenericError"; import GenericError from "../Errors/GenericError";
import { computeSubResourceCacheTTL } from "../modules/algorithms/computeSubResourceCacheTTL"; import { computeSubResourceCacheTTL } from "../modules/algorithms/computeSubResourceCacheTTL";
import { computeProductsCacheTTL } from "../modules/algorithms/computeProductsCacheTTL"; import { computeProductsCacheTTL } from "../modules/algorithms/computeProductsCacheTTL";
import UserController from "./UserController";
import { import {
getCachedNotes, getCachedNotes,
getCachedContacts, getCachedContacts,
@@ -32,6 +40,11 @@ import {
invalidateNotesCache, invalidateNotesCache,
invalidateProductsCache, invalidateProductsCache,
} from "../modules/cache/opportunityCache"; } from "../modules/cache/opportunityCache";
import {
generateQuote as generateQuotePdf,
type QuoteMetadata,
} from "../modules/pdf-utils";
import { generatedQuotes } from "../managers/generatedQuotes";
/** /**
* Opportunity Controller * Opportunity Controller
@@ -76,6 +89,7 @@ export class OpportunityController {
public customerPO: string | null; public customerPO: string | null;
public totalSalesTax: number; public totalSalesTax: number;
public probability: number;
public locationName: string | null; public locationName: string | null;
public locationCwId: number | null; public locationCwId: number | null;
@@ -126,6 +140,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( constructor(
data: Opportunity & { company?: Company | null }, data: Opportunity & { company?: Company | null },
opts?: { opts?: {
@@ -169,6 +206,7 @@ export class OpportunityController {
this.customerPO = data.customerPO; this.customerPO = data.customerPO;
this.totalSalesTax = data.totalSalesTax; this.totalSalesTax = data.totalSalesTax;
this.probability = data.probability;
this.locationName = data.locationName; this.locationName = data.locationName;
this.locationCwId = data.locationCwId; this.locationCwId = data.locationCwId;
@@ -198,6 +236,18 @@ export class OpportunityController {
this._activities = opts?.activities ?? null; 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 * Fetch Company
* *
@@ -243,6 +293,30 @@ export class OpportunityController {
return new OpportunityController(updated); return new OpportunityController(updated);
} }
/**
* Update Opportunity
*
* Patches the opportunity in ConnectWise with the provided fields,
* then syncs the updated data back to the local database.
*
* @param data Partial fields to update on the CW opportunity
* @returns A fresh OpportunityController with the updated data
*/
public async updateOpportunity(
data: CWOpportunityUpdate,
): Promise<OpportunityController> {
const cwData = await opportunityCw.update(this.cwOpportunityId, data);
const mapped = OpportunityController.mapCwToDb(cwData);
const updated = await prisma.opportunity.update({
where: { id: this.id },
data: mapped,
include: { company: true },
});
return new OpportunityController(updated);
}
/** /**
* Fetch raw CW data * Fetch raw CW data
* *
@@ -292,6 +366,7 @@ export class OpportunityController {
customerPO: item.customerPO ?? null, customerPO: item.customerPO ?? null,
totalSalesTax: item.totalSalesTax ?? 0, totalSalesTax: item.totalSalesTax ?? 0,
probability: Number(item.probability?.name) || 0,
locationName: item.location?.name ?? null, locationName: item.location?.name ?? null,
locationCwId: item.location?.id ?? null, locationCwId: item.location?.id ?? null,
@@ -429,18 +504,25 @@ export class OpportunityController {
/** Serialize raw CW note data into the API response shape. */ /** Serialize raw CW note data into the API response shape. */
private async _serializeNotes(notes: any[]) { private async _serializeNotes(notes: any[]) {
return Promise.all( // Batch-resolve all member identifiers in a single DB query
notes.map(async (n: any) => ({ const identifiers = notes
id: n.id, .map((n: any) => n.enteredBy as string)
text: n.text, .filter(Boolean);
type: n.type ? { id: n.type.id, name: n.type.name } : null, const memberMap = await resolveMembers(identifiers);
flagged: n.flagged,
dateEntered: n._info?.lastUpdated return notes.map((n: any) => ({
? new Date(n._info.lastUpdated) id: n.id,
: null, text: n.text,
enteredBy: await resolveMember(n.enteredBy), 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: memberMap.get(n.enteredBy) ?? {
id: null,
identifier: n.enteredBy,
name: n.enteredBy,
cwMemberId: null,
},
}));
} }
/** /**
@@ -524,6 +606,372 @@ export class OpportunityController {
return this._buildProductControllers(forecast, procProducts); 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 quoteDescription = this.name;
const primaryContactFullName = [
companyJson?.cw_Data?.primaryContact?.firstName,
companyJson?.cw_Data?.primaryContact?.lastName,
]
.filter(Boolean)
.join(" ")
.trim();
const customerName =
this.contactName ||
primaryContactFullName ||
this.companyName ||
"Customer";
const subTotal = lineItems.reduce(
(sum, item) => sum + item.qty * item.unitPrice,
0,
);
const normalizedTaxRate =
subTotal > 0 ? Math.max(0, this.totalSalesTax / subTotal) : 0;
const taxLabel =
normalizedTaxRate > 0
? `Sales Tax (${(normalizedTaxRate * 100).toFixed(2)}%)`
: "Sales Tax";
await this._hydrateCustomFields();
const quoteNarrative = options.includeQuoteNarrative
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
undefined
: undefined;
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
// Only show attention if it differs from the customer name
const attention =
this.contactName && this.contactName !== customerName
? this.contactName
: undefined;
// Only show company if it's meaningfully different from the customer name
// (catches "Patterson, Diane" vs "Diane Patterson" style duplicates)
const normalise = (s: string) =>
s
.toLowerCase()
.replace(/[,.\s]+/g, " ")
.trim()
.split(" ")
.sort()
.join(" ");
const showCompany = normalise(companyLine) !== normalise(customerName);
return generateQuotePdf(
{
customer: {
name: customerName,
company: showCompany ? companyLine : undefined,
attention,
address:
addressLines.length > 0 ? addressLines : ["Address unavailable"],
},
contact: {
email: companyJson?.cw_Data?.primaryContact?.email ?? undefined,
phone: companyJson?.cw_Data?.primaryContact?.phone ?? undefined,
},
salesRep: await this._resolveSalesRep(),
quote: {
quoteNumber: this.cwOpportunityId.toString(),
date: new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}),
description: quoteDescription,
},
lineItems,
quoteNarrative,
tax: {
rate: normalizedTaxRate,
label: taxLabel,
},
isPreview: options.showPreview,
showLineItemPricing: options.lineItemPricing,
metadata: opts?.metadata,
},
{},
options.logoPath,
);
}
/**
* Commit Quote
*
* Generates a non-preview quote PDF and stores it in the GeneratedQuotes
* table with a full data snapshot for exact reproduction, regeneration
* metadata, and creator attribution.
*/
public async commitQuote(
opts: {
lineItemPricing?: boolean;
includeQuoteNarrative?: boolean;
includeItemNarratives?: boolean;
logoPath?: string;
} = {},
user: UserController,
) {
const quoteOptions = {
lineItemPricing: opts?.lineItemPricing ?? true,
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
includeItemNarratives: opts?.includeItemNarratives ?? true,
logoPath: opts?.logoPath,
};
// ── Fetch all data sources BEFORE generating ──────────────────────
const products = await this.fetchProducts();
const company = await this.fetchCompany();
const companyJson = company?.toJson({
includeAddress: true,
includePrimaryContact: true,
includeAllContacts: false,
});
const site = await this.fetchSite();
const salesRep = await this._resolveSalesRep();
await this._hydrateCustomFields();
const quoteNarrative = quoteOptions.includeQuoteNarrative
? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ??
null)
: null;
// ── Pre-generate IDs & timestamps for metadata ───────────────────
const quoteId = crypto.randomUUID();
const createdAt = new Date().toISOString();
// ── Generate the PDF ──────────────────────────────────────────────
const quoteBuffer = await this.generateQuote({
...quoteOptions,
showPreview: false,
metadata: {
quoteId,
createdById: user.id,
createdByName: user.name ?? undefined,
createdByEmail: user.email ?? undefined,
createdAt,
},
});
const fileTimestamp = createdAt.replace(/[:.]/g, "-");
const quoteFileName = `OPP-${this.cwOpportunityId}-${fileTimestamp}.pdf`;
// ── Build the full data snapshot ──────────────────────────────────
const siteAddress = [
site?.address?.line1,
site?.address?.line2,
[site?.address?.city, site?.address?.state, site?.address?.zip]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const companyAddress = [
companyJson?.cw_Data?.address?.line1,
companyJson?.cw_Data?.address?.line2,
[
companyJson?.cw_Data?.address?.city,
companyJson?.cw_Data?.address?.state,
companyJson?.cw_Data?.address?.zip,
]
.filter(Boolean)
.join(" "),
].filter(Boolean) as string[];
const primaryContactFullName = [
companyJson?.cw_Data?.primaryContact?.firstName,
companyJson?.cw_Data?.primaryContact?.lastName,
]
.filter(Boolean)
.join(" ")
.trim();
const regenData = {
// Generation options
options: {
lineItemPricing: quoteOptions.lineItemPricing,
includeQuoteNarrative: quoteOptions.includeQuoteNarrative,
includeItemNarratives: quoteOptions.includeItemNarratives,
},
// Opportunity metadata
opportunity: {
id: this.id,
cwOpportunityId: this.cwOpportunityId,
name: this.name,
totalSalesTax: this.totalSalesTax,
contactName: this.contactName,
companyName: this.companyName,
},
// Customer / company / site snapshot
customer: {
preparedFor:
this.contactName ||
primaryContactFullName ||
this.companyName ||
"Customer",
companyName: this.companyName ?? company?.name ?? null,
primaryContact: companyJson?.cw_Data?.primaryContact
? {
firstName: companyJson.cw_Data.primaryContact.firstName ?? null,
lastName: companyJson.cw_Data.primaryContact.lastName ?? null,
email: companyJson.cw_Data.primaryContact.email ?? null,
phone: companyJson.cw_Data.primaryContact.phone ?? null,
}
: null,
siteAddress: siteAddress.length > 0 ? siteAddress : null,
companyAddress: companyAddress.length > 0 ? companyAddress : null,
},
// Sales rep snapshot
salesRep: salesRep ?? null,
// Quote narrative
quoteNarrative: quoteNarrative ?? null,
// Full product snapshot
products: products.map((p) => ({
cwForecastId: p.cwForecastId,
forecastDescription: p.forecastDescription,
productDescription: p.productDescription,
customerDescription: p.customerDescription,
productNarrative: p.productNarrative,
productClass: p.productClass,
forecastType: p.forecastType,
catalogItem: p.catalogItemCwId
? { id: p.catalogItemCwId, identifier: p.catalogItemIdentifier }
: null,
quantity: p.quantity,
effectiveQuantity: p.effectiveQuantity,
revenue: p.revenue,
cost: p.cost,
margin: p.margin,
percentage: p.percentage,
includeFlag: p.includeFlag,
taxableFlag: p.taxableFlag,
recurringFlag: p.recurringFlag,
recurringRevenue: p.recurringRevenue,
recurringCost: p.recurringCost,
sequenceNumber: p.sequenceNumber,
cancelledFlag: p.cancelledFlag,
cancellationType: p.cancellationType,
quantityCancelled: p.quantityCancelled,
cancelledReason: p.cancelledReason,
cancelledDate: p.cancelledDate,
})),
// Timestamp of when this snapshot was taken
snapshotTimestamp: new Date().toISOString(),
};
const regenParams = {
opportunityId: this.id,
cwOpportunityId: this.cwOpportunityId,
};
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(JSON.stringify({ regenData, regenParams }));
const quoteRegenHash = hasher.digest("hex");
return generatedQuotes.create({
id: quoteId,
quoteRegenData: regenData,
quoteRegenParams: regenParams,
quoteRegenHash,
quoteFile: quoteBuffer,
quoteFileName,
opportunityId: this.id,
createdById: user.id,
});
}
/** /**
* Build ForecastProductController[] from raw CW data. * Build ForecastProductController[] from raw CW data.
* *
@@ -537,15 +985,25 @@ export class OpportunityController {
// Build a map of forecastDetailId → procurement product cancellation data // Build a map of forecastDetailId → procurement product cancellation data
const cancellationMap = new Map<number, Record<string, unknown>>(); const cancellationMap = new Map<number, Record<string, unknown>>();
for (const pp of procProducts) { for (const pp of procProducts) {
const forecastDetailId = pp.forecastDetailId as number | undefined; const rawForecastDetailId = (pp as any)?.forecastDetailId;
if (forecastDetailId) { const forecastDetailId =
typeof rawForecastDetailId === "number"
? rawForecastDetailId
: Number(rawForecastDetailId);
if (Number.isFinite(forecastDetailId) && forecastDetailId > 0) {
cancellationMap.set(forecastDetailId, pp); 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 // Apply local ordering if productSequence is set, otherwise fall back
// to CW sequenceNumber. // to CW sequenceNumber.
const forecastItems = forecast.forecastItems ?? [];
let ordered: typeof forecastItems; let ordered: typeof forecastItems;
if (this.productSequence.length > 0) { if (this.productSequence.length > 0) {
@@ -571,6 +1029,7 @@ export class OpportunityController {
const procData = cancellationMap.get(item.id); const procData = cancellationMap.get(item.id);
if (procData) { if (procData) {
ctrl.applyCancellationData(procData as any); ctrl.applyCancellationData(procData as any);
ctrl.applyProcurementCustomFields(procData as any);
} }
return ctrl; return ctrl;
}, },
@@ -760,6 +1219,41 @@ export class OpportunityController {
return this.fetchProducts(); 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 * Add Products
* *
@@ -778,6 +1272,7 @@ export class OpportunityController {
this.cwOpportunityId, this.cwOpportunityId,
data, data,
); );
await this.appendProductSequenceIds(created.map((item) => item.id));
await invalidateProductsCache(this.cwOpportunityId); await invalidateProductsCache(this.cwOpportunityId);
return created.map((item) => new ForecastProductController(item)); return created.map((item) => new ForecastProductController(item));
} catch (err: any) { } catch (err: any) {
@@ -806,6 +1301,141 @@ 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,
});
}
}
/**
* 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 * Add Note
* *
@@ -871,7 +1501,7 @@ export class OpportunityController {
id: this.id, id: this.id,
cwOpportunityId: this.cwOpportunityId, cwOpportunityId: this.cwOpportunityId,
name: this.name, name: this.name,
notes: this.notes, description: this.notes,
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null, type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
stage: this.stageCwId stage: this.stageCwId
? { id: this.stageCwId, name: this.stageName } ? { id: this.stageCwId, name: this.stageName }
@@ -922,6 +1552,7 @@ export class OpportunityController {
: null, : null,
customerPO: this.customerPO, customerPO: this.customerPO,
totalSalesTax: this.totalSalesTax, totalSalesTax: this.totalSalesTax,
probability: this.probability,
location: this.locationCwId location: this.locationCwId
? { id: this.locationCwId, name: this.locationName } ? { id: this.locationCwId, name: this.locationName }
: null, : null,
+49 -17
View File
@@ -1,5 +1,6 @@
import { refresh } from "./api/auth"; import { refresh } from "./api/auth";
import app from "./api/server"; import app from "./api/server";
import { setupSockets } from "./api/sockets";
import { import {
engine, engine,
PORT, PORT,
@@ -12,9 +13,11 @@ import { unifiSites } from "./managers/unifiSites";
import { refreshCompanies } from "./modules/cw-utils/refreshCompanies"; import { refreshCompanies } from "./modules/cw-utils/refreshCompanies";
import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog"; import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog";
import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory"; 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 { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
import { refreshOpportunityCache } from "./modules/cache/opportunityCache"; import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers"; import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
import { refreshCwMembers } from "./modules/cw-utils/members/refreshCwMembers";
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields"; import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
import { events, setupEventDebugger } from "./modules/globalEvents"; import { events, setupEventDebugger } from "./modules/globalEvents";
import { signPermissions } from "./modules/permission-utils/signPermissions"; import { signPermissions } from "./modules/permission-utils/signPermissions";
@@ -67,6 +70,9 @@ Bun.serve({
console.log(`[startup] Server listening on port ${PORT}`); console.log(`[startup] Server listening on port ${PORT}`);
setupSockets();
console.log("[startup] Socket namespaces initialized");
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Background initialisation — none of this blocks the server. // Background initialisation — none of this blocks the server.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -105,25 +111,37 @@ setInterval(() => {
); );
}, 60 * 1000); }, 60 * 1000);
// Refresh the internal catalog every minute // Refresh the internal catalog every 30 minutes
await safeStartup("refreshCatalog", refreshCatalog); await safeStartup("refreshCatalog", refreshCatalog);
setInterval(() => { setInterval(
return refreshCatalog().catch((err) => () => {
console.error(`[interval] refreshCatalog failed: ${briefErr(err)}`), return refreshCatalog().catch((err) =>
); console.error(`[interval] refreshCatalog failed: ${briefErr(err)}`),
}, 60 * 1000); );
},
30 * 60 * 1000,
);
// Refresh inventory on hand every 2 minutes // Fallback full inventory sweep every 6 hours (listener handles real-time deltas)
await safeStartup("refreshInventory", refreshInventory);
setInterval( setInterval(
() => { () => {
return refreshInventory().catch((err) => return refreshInventory().catch((err) =>
console.error(`[interval] refreshInventory failed: ${briefErr(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 // Refresh opportunities every minute
await safeStartup("refreshOpportunities", refreshOpportunities); await safeStartup("refreshOpportunities", refreshOpportunities);
setInterval(() => { setInterval(() => {
@@ -132,17 +150,20 @@ setInterval(() => {
); );
}, 60 * 1000); }, 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 // NOTE: Do NOT await — register the interval immediately so the cache refresh
// is never blocked by a slow/stuck startup task above. // is never blocked by a slow/stuck startup task above.
safeStartup("refreshOpportunityCache", refreshOpportunityCache); safeStartup("refreshOpportunityCache", refreshOpportunityCache);
setInterval(() => { setInterval(
return refreshOpportunityCache().catch((err) => { () => {
console.error( return refreshOpportunityCache().catch((err) => {
`[interval] refreshOpportunityCache failed: ${briefErr(err)}`, console.error(
); `[interval] refreshOpportunityCache failed: ${briefErr(err)}`,
}); );
}, 30 * 1000); });
},
20 * 60 * 1000,
);
// Refresh User Defined Fields every 5 minutes // Refresh User Defined Fields every 5 minutes
await safeStartup("refreshUDFs", () => userDefinedFieldsCw.refresh()); await safeStartup("refreshUDFs", () => userDefinedFieldsCw.refresh());
@@ -168,6 +189,17 @@ setInterval(
30 * 60 * 1000, 30 * 60 * 1000,
); );
// Refresh CW members DB table every hour
await safeStartup("refreshCwMembers", refreshCwMembers);
setInterval(
() => {
return refreshCwMembers().catch((err) =>
console.error(`[interval] refreshCwMembers failed: ${briefErr(err)}`),
);
},
60 * 60 * 1000,
);
await safeStartup("syncSites", () => unifiSites.syncSites()); await safeStartup("syncSites", () => unifiSites.syncSites());
setInterval(() => { setInterval(() => {
return unifiSites return unifiSites
+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);
},
};
+84 -36
View File
@@ -6,6 +6,7 @@ import { OpportunityController } from "../controllers/OpportunityController";
import GenericError from "../Errors/GenericError"; import GenericError from "../Errors/GenericError";
import { activityCw } from "../modules/cw-utils/activities/activities"; import { activityCw } from "../modules/cw-utils/activities/activities";
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities"; import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
import { CWOpportunityCreate } from "../modules/cw-utils/opportunities/opportunity.types";
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL"; import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
import { import {
getCachedActivities, getCachedActivities,
@@ -45,13 +46,18 @@ async function buildCompanyController(
const strategy = opts?.strategy ?? "cache-then-cw"; const strategy = opts?.strategy ?? "cache-then-cw";
const ctrl = new CompanyController(company); 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") { if (strategy === "cw-first") {
await ctrl.hydrateCwData(); const blob = opts?.ttlMs
if (ctrl.cw_Data && opts?.ttlMs) { ? await fetchAndCacheCompanyCwData(
await fetchAndCacheCompanyCwData(company.cw_CompanyId, opts.ttlMs).catch( company.cw_CompanyId,
() => {}, opts.ttlMs,
); ).catch(() => null)
: null;
if (blob) {
ctrl.cw_Data = blob;
} else {
await ctrl.hydrateCwData();
} }
return ctrl; return ctrl;
} }
@@ -66,14 +72,16 @@ async function buildCompanyController(
// cache-only stops here — return the bare DB-backed controller // cache-only stops here — return the bare DB-backed controller
if (strategy === "cache-only") return ctrl; 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
await ctrl.hydrateCwData(); if (opts?.ttlMs) {
if (ctrl.cw_Data && opts?.ttlMs) { const blob = await fetchAndCacheCompanyCwData(
await fetchAndCacheCompanyCwData(company.cw_CompanyId, opts.ttlMs).catch( company.cw_CompanyId,
() => {}, opts.ttlMs,
); ).catch(() => null);
if (blob) ctrl.cw_Data = blob;
} else {
await ctrl.hydrateCwData();
} }
return ctrl; return ctrl;
} }
@@ -95,15 +103,11 @@ async function buildActivities(
): Promise<ActivityController[]> { ): Promise<ActivityController[]> {
const strategy = opts?.strategy ?? "cache-then-cw"; 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") { if (strategy === "cw-first") {
const collection = await activityCw.fetchByOpportunity(cwOpportunityId); const arr = opts?.ttlMs
const arr = collection.map((item) => item); ? await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs)
if (opts?.ttlMs) { : await activityCw.fetchByOpportunityDirect(cwOpportunityId);
await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs).catch(
() => {},
);
}
return arr.map((item) => new ActivityController(item)); return arr.map((item) => new ActivityController(item));
} }
@@ -116,16 +120,53 @@ async function buildActivities(
// cache-only stops here — return empty (background job will fill it) // cache-only stops here — return empty (background job will fill it)
if (strategy === "cache-only") return []; if (strategy === "cache-only") return [];
// cache-then-cw: cache miss — fall through to CW // cache-then-cw: cache miss — fetch once and cache in one pass
const collection = await activityCw.fetchByOpportunity(cwOpportunityId); const arr = opts?.ttlMs
const arr = collection.map((item) => item); ? await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs)
if (opts?.ttlMs) { : await activityCw.fetchByOpportunityDirect(cwOpportunityId);
await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs).catch(() => {});
}
return arr.map((item) => new ActivityController(item)); return arr.map((item) => new ActivityController(item));
} }
export const opportunities = { export const opportunities = {
/**
* Create Opportunity
*
* Creates a new opportunity in ConnectWise, then stores the resulting
* record in the local database and returns an OpportunityController.
*
* @param data Fields required by the ConnectWise `POST /sales/opportunities` endpoint
* @returns {Promise<OpportunityController>}
*/
async createItem(data: CWOpportunityCreate): Promise<OpportunityController> {
const cwData = await opportunityCw.create(data);
const mapped = OpportunityController.mapCwToDb(cwData);
// Resolve optional local company relation
const companyId = cwData.company?.id
? ((
await prisma.company.findFirst({
where: { cw_CompanyId: cwData.company.id },
select: { id: true },
})
)?.id ?? null)
: null;
const record = await prisma.opportunity.create({
data: {
cwOpportunityId: cwData.id,
...mapped,
companyId,
},
include: { company: true },
});
return new OpportunityController(record, {
company: record.company
? new CompanyController(record.company)
: undefined,
});
},
/** /**
* Fetch Record (lightweight) * Fetch Record (lightweight)
* *
@@ -233,8 +274,14 @@ export const opportunities = {
cwData = await getCachedOppCwData(existing.cwOpportunityId); cwData = await getCachedOppCwData(existing.cwOpportunityId);
} }
if (!cwData) { // ── Parallel block: CW opp fetch + activities + company ────────────
// Cache miss or forced fresh — fetch from CW and cache // 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 cwData = ttlMs
? await fetchAndCacheOppCwData(existing.cwOpportunityId, ttlMs) ? await fetchAndCacheOppCwData(existing.cwOpportunityId, ttlMs)
: await opportunityCw.fetch(existing.cwOpportunityId); : await opportunityCw.fetch(existing.cwOpportunityId);
@@ -264,13 +311,14 @@ export const opportunities = {
data: { ...mapped, companyId }, data: { ...mapped, companyId },
include: { company: true }, include: { company: true },
}); });
} })();
// Hydrate activities and company in parallel // Hydrate activities and company in parallel with CW opp fetch
const [activities, company] = await Promise.all([ const [, activities, company] = await Promise.all([
buildActivities(record.cwOpportunityId, { strategy, ttlMs }), cwOppPromise,
record.company buildActivities(existing.cwOpportunityId, { strategy, ttlMs }),
? buildCompanyController(record.company, { strategy, ttlMs }) existing.company
? buildCompanyController(existing.company, { strategy, ttlMs })
: Promise.resolve(undefined), : Promise.resolve(undefined),
]); ]);
@@ -369,7 +417,7 @@ export const opportunities = {
include: { company: true }, include: { company: true },
skip, skip,
take: rpp, take: rpp,
orderBy: { expectedCloseDate: "asc" }, orderBy: { createdAt: "desc" },
}); });
return Promise.all( return Promise.all(
+152 -5
View File
@@ -2,9 +2,11 @@ import { prisma } from "../constants";
import { CatalogItemController } from "../controllers/CatalogItemController"; import { CatalogItemController } from "../controllers/CatalogItemController";
import GenericError from "../Errors/GenericError"; import GenericError from "../Errors/GenericError";
import { import {
CATEGORY_TREE,
getSubcategoriesForCategory, getSubcategoriesForCategory,
getSubcategoriesForGroup, getSubcategoriesForGroup,
ECOSYSTEM_TREE, ECOSYSTEM_TREE,
isCategoryGroup,
} from "../modules/catalog-categories/catalogCategories"; } from "../modules/catalog-categories/catalogCategories";
/** /**
@@ -15,6 +17,61 @@ const catalogItemInclude = {
linkedItems: true, linkedItems: true,
} as const; } 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. * Filter options for catalog item queries.
*/ */
@@ -36,22 +93,82 @@ export interface CatalogFilterOpts {
function buildFilterWhere(opts: CatalogFilterOpts = {}) { function buildFilterWhere(opts: CatalogFilterOpts = {}) {
const conditions: Record<string, unknown>[] = []; 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) { if (!opts.includeInactive) {
conditions.push({ inactive: false }); conditions.push({ inactive: false });
} }
if (opts.category) { if (opts.category) {
conditions.push({ category: 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 (opts.subcategory) {
conditions.push({ subcategory: 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) { if (opts.group && opts.category) {
const subcats = getSubcategoriesForGroup(opts.category, opts.group); if (!resolvedCategoryName) {
if (subcats.length > 0) { conditions.push({ category: "__unknown_category__" });
conditions.push({ subcategory: { in: subcats } }); }
if (resolvedCategoryName) {
const subcats = getSubcategoriesForGroup(
resolvedCategoryName,
opts.group,
);
if (subcats.length > 0) {
conditions.push({ subcategory: { in: subcats } });
}
} }
} else if (opts.group && !opts.category) { } else if (opts.group && !opts.category) {
// Try to find the group in any category // Try to find the group in any category
@@ -142,6 +259,36 @@ export const procurement = {
return new CatalogItemController(item); 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) * Fetch All Catalog Items (Paginated)
* *
+8 -6
View File
@@ -16,8 +16,8 @@
* | # | Condition | TTL (ms) | TTL (human) | Rationale | * | # | Condition | TTL (ms) | TTL (human) | Rationale |
* |---|------------------------------------------------------------------|----------|-------------|--------------------------------------------------------------------| * |---|------------------------------------------------------------------|----------|-------------|--------------------------------------------------------------------|
* | 1 | `closedFlag` is `true` | `null` | Do not cache| Closed records are rarely accessed; caching wastes memory. | * | 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.| * | 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**| 60 000 | 60 seconds | Moderate activity still relevant, but changes less often. | * | 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. | * | 4 | Everything else (older than 14 days) | 900 000 | 15 minutes | Low activity safe to serve from cache for longer. |
* *
* ## Evaluation order * ## Evaluation order
@@ -62,11 +62,13 @@
// Constants // Constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** 30 seconds TTL for high-activity records (within 5 days). */ /** 60 seconds TTL for high-activity records (within 5 days).
export const TTL_HIGH_ACTIVITY = 30_000; * 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). */ /** 90 seconds TTL for moderate-activity records (within 14 days). */
export const TTL_MODERATE_ACTIVITY = 60_000; export const TTL_MODERATE_ACTIVITY = 90_000;
/** 15 minutes TTL for low-activity / stale records. */ /** 15 minutes TTL for low-activity / stale records. */
export const TTL_LOW_ACTIVITY = 900_000; 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. | * | 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. | * | 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. | * | 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 * ## Evaluation order
* *
@@ -44,11 +44,13 @@ import { QUOTE_STATUSES } from "../../types/QuoteStatuses";
// Constants // Constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** 15 seconds — TTL for hot products (opportunity updated within 3 days). */ /** 45 seconds TTL for hot products (opportunity updated within 3 days).
export const PRODUCTS_TTL_HOT = 15_000; * 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). */ /** 20 minutes — TTL for on-demand product cache (lazy fallback). */
export const PRODUCTS_TTL_LAZY = 1_800_000; export const PRODUCTS_TTL_LAZY = 1_200_000;
/** 3 days in milliseconds. */ /** 3 days in milliseconds. */
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
+25 -16
View File
@@ -310,25 +310,34 @@ export async function fetchAndCacheCompanyCwData(
ttlMs: number, ttlMs: number,
): Promise<{ company: any; defaultContact: any; allContacts: any[] } | null> { ): Promise<{ company: any; defaultContact: any; allContacts: any[] } | null> {
try { 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; if (!cwCompany) return null;
const contactHref = cwCompany.defaultContact?._info?.contact_href; // Default contact: derive from allContacts instead of making an
const defaultContactData = contactHref // extra serial CW call. The company object carries the default
? await withCwRetry(() => connectWiseApi.get(contactHref), { // contact's ID, so we can pull it from the list we already fetched.
label: `company#${cwCompanyId}/defaultContact`, const defaultContactId = cwCompany.defaultContact?.id;
}) const defaultContactData = defaultContactId
: undefined; ? ((allContactsData.data as any[]).find(
(c: any) => c.id === defaultContactId,
const allContactsData = await withCwRetry( ) ?? null)
() => : null;
connectWiseApi.get(`${cwCompany._info.contacts_href}&pageSize=1000`),
{ label: `company#${cwCompanyId}/allContacts` },
);
const blob = { const blob = {
company: cwCompany, company: cwCompany,
defaultContact: defaultContactData?.data ?? null, defaultContact: defaultContactData,
allContacts: allContactsData.data, allContacts: allContactsData.data,
}; };
@@ -491,11 +500,11 @@ export async function invalidateProductsCache(
} }
/** /**
* Site TTL 30 minutes. Site/address data rarely changes so we cache * Site TTL 20 minutes. Site/address data rarely changes so we cache
* aggressively. The background refresh does NOT proactively warm site keys; * aggressively. The background refresh does NOT proactively warm site keys;
* they are populated lazily on the first detail-view request. * 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. * Fetch a CW company site from ConnectWise and cache the result.
@@ -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 * Fetches every member from ConnectWise using pagination and returns them
* in a Collection keyed by their identifier (e.g. "jroberts"). * in a Collection keyed by their identifier (e.g. "jroberts").
* *
* @param opts.conditions - Optional CW conditions string to filter members
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier * @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
*/ */
export const fetchAllCwMembers = async (): Promise< export const fetchAllCwMembers = async (opts?: {
Collection<string, CWMember> conditions?: string;
> => { }): Promise<Collection<string, CWMember>> => {
const members = new Collection<string, CWMember>(); const members = new Collection<string, CWMember>();
const pageSize = 1000; const pageSize = 1000;
const conditionsParam = opts?.conditions
? `&conditions=${encodeURIComponent(opts.conditions)}`
: "";
const { data: countData } = await connectWiseApi.get("/system/members/count"); const { data: countData } = await connectWiseApi.get(
`/system/members/count${conditionsParam ? `?${conditionsParam.slice(1)}` : ""}`,
);
const totalPages = Math.ceil(countData.count / pageSize); const totalPages = Math.ceil(countData.count / pageSize);
for (let page = 0; page < totalPages; page++) { for (let page = 0; page < totalPages; page++) {
const { data } = await connectWiseApi.get<CWMember[]>( const { data } = await connectWiseApi.get<CWMember[]>(
`/system/members?page=${page + 1}&pageSize=${pageSize}`, `/system/members?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
); );
for (const member of data) { for (const member of data) {
@@ -102,3 +102,40 @@ export const resolveMember = async (
cwMemberId: cwMember?.id ?? null, 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 { connectWiseApi } from "../../../constants";
import { import {
CWOpportunity, CWOpportunity,
CWOpportunityCreate,
CWOpportunitySummary, CWOpportunitySummary,
CWForecast, CWForecast,
CWForecastItem, CWForecastItem,
CWForecastItemCreate, CWForecastItemCreate,
CWProcurementProduct,
CWProcurementProductCreate,
CWOpportunityNote, CWOpportunityNote,
CWOpportunityNoteCreate, CWOpportunityNoteCreate,
CWOpportunityNoteUpdate, CWOpportunityNoteUpdate,
CWOpportunityContact, CWOpportunityContact,
CWOpportunityUpdate,
} from "./opportunity.types"; } from "./opportunity.types";
export const opportunityCw = { export const opportunityCw = {
@@ -98,6 +102,45 @@ export const opportunityCw = {
return response.data; return response.data;
}, },
/**
* Create Opportunity
*
* Creates a new opportunity in ConnectWise via POST.
* Strips null/undefined values from the payload CW rejects
* null reference objects on create; omitting them lets CW apply
* its own defaults.
*/
create: async (data: CWOpportunityCreate): Promise<CWOpportunity> => {
const cleaned = Object.fromEntries(
Object.entries(data).filter(([, v]) => v != null),
);
const response = await connectWiseApi.post("/sales/opportunities", cleaned);
return response.data;
},
/**
* Update Opportunity
*
* Applies a JSON Patch update to an opportunity record in ConnectWise.
* Each key in `data` produces a replace operation.
*/
update: async (
opportunityId: number,
data: CWOpportunityUpdate,
): Promise<CWOpportunity> => {
const operations = Object.entries(data).map(([key, value]) => ({
op: "replace" as const,
path: key,
value,
}));
const response = await connectWiseApi.patch(
`/sales/opportunities/${opportunityId}`,
operations,
);
return response.data;
},
/** /**
* Fetch Opportunities by Company * Fetch Opportunities by Company
* *
@@ -352,8 +395,72 @@ export const opportunityCw = {
opportunityId: number, opportunityId: number,
): Promise<Record<string, unknown>[]> => { ): Promise<Record<string, unknown>[]> => {
const response = await connectWiseApi.get( 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; 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;
},
}; };
@@ -68,6 +68,7 @@ export interface CWOpportunity {
closedDate: string; closedDate: string;
closedBy: CWMemberReference; closedBy: CWMemberReference;
totalSalesTax: number; totalSalesTax: number;
probability: CWReference;
shipToCompany: CWCompanyReference; shipToCompany: CWCompanyReference;
shipToContact: CWContactReference; shipToContact: CWContactReference;
shipToSite: CWSiteReference; shipToSite: CWSiteReference;
@@ -113,6 +114,7 @@ export interface CWForecastItem {
_info?: Record<string, string>; _info?: Record<string, string>;
}; };
productDescription: string; productDescription: string;
customerDescription?: string;
productClass: string; productClass: string;
revenue: number; revenue: number;
cost: number; cost: number;
@@ -129,6 +131,7 @@ export interface CWForecastItem {
sequenceNumber: number; sequenceNumber: number;
subNumber: number; subNumber: number;
taxableFlag: boolean; taxableFlag: boolean;
customFields?: CWCustomField[];
_info?: Record<string, string>; _info?: Record<string, string>;
} }
@@ -210,6 +213,7 @@ export interface CWForecastItemCreate {
catalogItem?: { id: number }; catalogItem?: { id: number };
forecastDescription?: string; forecastDescription?: string;
productDescription?: string; productDescription?: string;
customerDescription?: string;
quantity?: number; quantity?: number;
status?: { id: number }; status?: { id: number };
productClass?: string; productClass?: string;
@@ -224,6 +228,81 @@ export interface CWForecastItemCreate {
recurringCost?: number; recurringCost?: number;
cycles?: number; cycles?: number;
sequenceNumber?: 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 { export interface CWOpportunitySummary {
@@ -14,6 +14,7 @@ export const processOpportunityResponse = (opportunity: CWOpportunity) => ({
expectedCloseDate: opportunity.expectedCloseDate, expectedCloseDate: opportunity.expectedCloseDate,
closedDate: opportunity.closedDate, closedDate: opportunity.closedDate,
closedFlag: opportunity.closedFlag, closedFlag: opportunity.closedFlag,
probability: Number(opportunity.probability?.name) || 0,
type: opportunity.type type: opportunity.type
? { id: opportunity.type.id, name: opportunity.type.name } ? { id: opportunity.type.id, name: opportunity.type.name }
: null, : null,
+22 -4
View File
@@ -66,10 +66,28 @@ export const catalogCw = {
return allItems; return allItems;
}, },
fetchByCatalogId: async (cwCatalogId: number): Promise<CatalogItem> => {
try {
const response = await connectWiseApi.get(
`/procurement/catalog/${cwCatalogId}`,
);
return response.data;
} catch {
const fallback = await connectWiseApi.get(
`/procurement/catalog/items/${cwCatalogId}`,
);
return fallback.data;
}
},
fetch: async (id: string): Promise<CatalogItem> => { fetch: async (id: string): Promise<CatalogItem> => {
const response = await connectWiseApi.get( const numericId = Number(id);
`/procurement/catalog/items/${id}`, if (!Number.isFinite(numericId)) {
); const response = await connectWiseApi.get(
return response.data; `/procurement/catalog/items/${id}`,
);
return response.data;
}
return catalogCw.fetchByCatalogId(numericId);
}, },
}; };
@@ -0,0 +1,469 @@
import { prisma, redis, connectWiseApi } from "../../../constants";
import { withCwRetry } from "../withCwRetry";
import { catalogCw } from "./catalog";
import { CatalogItem } from "./catalog.types";
type JsonObject = Record<string, unknown>;
type TrackedProduct = {
cwCatalogId: number;
product: string;
onHand: string;
inventory: string;
key: string;
};
type AdjustmentSnapshot = {
key: string;
trackedRows: TrackedProduct[];
signature: string;
};
const ADJUSTMENTS_ENDPOINT = "/procurement/adjustments?pageSize=1000";
const CATALOG_ITEM_CACHE_PREFIX = "catalog:item:cw:";
const CATALOG_ITEM_CACHE_TTL_SECONDS = 20 * 60;
const MAX_SYNC_PER_CYCLE = Number(
process.env.CW_ADJUSTMENT_SYNC_MAX_PER_CYCLE ?? "50",
);
const SYNC_COOLDOWN_MS = Number(
process.env.CW_ADJUSTMENT_SYNC_COOLDOWN_MS ?? `${10 * 60 * 1000}`,
);
let previous = new Map<string, AdjustmentSnapshot>();
let previousProductState = new Map<number, string>();
const lastSyncedAt = new Map<number, number>();
let inFlight = false;
const isObject = (value: unknown): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value);
const toObject = (value: unknown): JsonObject => {
if (!isObject(value)) return {};
return value;
};
const stableStringify = (value: unknown): string => {
if (Array.isArray(value)) {
const entries = value.map((entry) => stableStringify(entry)).sort();
return `[${entries.join(",")}]`;
}
if (isObject(value)) {
const keys = Object.keys(value).sort();
const pairs = keys.map(
(key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`,
);
return `{${pairs.join(",")}}`;
}
return JSON.stringify(value);
};
const readPathValue = (obj: JsonObject, path: string): unknown => {
const parts = path.split(".");
let current: unknown = obj;
for (const part of parts) {
if (!isObject(current)) return null;
current = current[part];
}
return current;
};
const firstValue = (obj: JsonObject, paths: string[]): unknown => {
for (const path of paths) {
const value = readPathValue(obj, path);
if (value === null || value === undefined || value === "") continue;
return value;
}
return null;
};
const asNumber = (value: unknown): number | null => {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
};
const asText = (value: unknown): string => {
if (value === null || value === undefined || value === "") return "-";
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return String(value);
}
if (Array.isArray(value)) {
return `[${value.map((entry) => asText(entry)).join(",")}]`;
}
if (!isObject(value)) return String(value);
const preferredFields = ["name", "identifier", "id", "code", "value"];
for (const field of preferredFields) {
const fieldValue = readPathValue(value, field);
if (fieldValue === null || fieldValue === undefined || fieldValue === "")
continue;
if (typeof fieldValue === "object") continue;
return String(fieldValue);
}
return stableStringify(value);
};
const adjustmentKey = (adjustment: JsonObject): string => {
const keyPaths = [
"id",
"adjustmentId",
"procurementAdjustmentId",
"recordId",
"recId",
"_info.id",
"_info.href",
];
for (const path of keyPaths) {
const key = firstValue(adjustment, [path]);
const keyText = asText(key);
if (keyText !== "-") return keyText;
}
return `anon:${stableStringify(adjustment)}`;
};
const trackedRow = (detail: JsonObject): TrackedProduct | null => {
const cwCatalogId = asNumber(
firstValue(detail, [
"catalogItem.id",
"catalogItemId",
"catalog.id",
"catalogId",
"item.id",
"itemId",
"product.id",
"productId",
"id",
]),
);
if (!cwCatalogId) return null;
const onHand = asText(
firstValue(detail, [
"onHand",
"onHandQty",
"onHandQuantity",
"qtyOnHand",
"quantityOnHand",
"quantity.onHand",
]),
);
const inventory = asText(
firstValue(detail, [
"inventory",
"inventoryQty",
"inventoryLevel",
"quantity",
"qty",
]),
);
if (onHand === "-" && inventory === "-") return null;
const product = asText(
firstValue(detail, [
"product.name",
"product.identifier",
"item.name",
"item.identifier",
"catalogItem.name",
"catalogItem.identifier",
"productName",
"productIdentifier",
"sku",
"identifier",
]),
);
return {
cwCatalogId,
product,
onHand,
inventory,
key: `${cwCatalogId}|${product}|${onHand}|${inventory}`,
};
};
const trackedRows = (adjustment: JsonObject): TrackedProduct[] => {
const detailCandidates = [
readPathValue(adjustment, "adjustmentDetails"),
readPathValue(adjustment, "details"),
readPathValue(adjustment, "lineItems"),
];
for (const candidate of detailCandidates) {
if (!Array.isArray(candidate)) continue;
const rows = candidate
.map((entry) => trackedRow(toObject(entry)))
.filter((entry): entry is TrackedProduct => entry !== null)
.sort((a, b) => a.key.localeCompare(b.key));
if (rows.length > 0) return rows;
}
const root = trackedRow(adjustment);
if (!root) return [];
return [root];
};
const snapshot = (rows: unknown[]): Map<string, AdjustmentSnapshot> => {
const out = new Map<string, AdjustmentSnapshot>();
for (const entry of rows) {
const adjustment = toObject(entry);
const key = adjustmentKey(adjustment);
const rowsTracked = trackedRows(adjustment);
const signature = stableStringify(rowsTracked);
out.set(key, {
key,
trackedRows: rowsTracked,
signature,
});
}
return out;
};
const changedCatalogIds = (
before: Map<number, string>,
after: Map<number, string>,
): Set<number> => {
const changed = new Set<number>();
for (const [cwCatalogId, nextSignature] of after) {
const prevSignature = before.get(cwCatalogId);
if (!prevSignature) {
changed.add(cwCatalogId);
continue;
}
if (prevSignature === nextSignature) continue;
changed.add(cwCatalogId);
}
return changed;
};
const productState = (
adjustments: Map<string, AdjustmentSnapshot>,
): Map<number, string> => {
const grouped = new Map<number, Set<string>>();
for (const snapshot of adjustments.values()) {
for (const row of snapshot.trackedRows) {
const rows = grouped.get(row.cwCatalogId) ?? new Set<string>();
rows.add(row.key);
grouped.set(row.cwCatalogId, rows);
}
}
const state = new Map<number, string>();
for (const [cwCatalogId, rows] of grouped) {
state.set(cwCatalogId, stableStringify([...rows].sort()));
}
return state;
};
const applySyncGuards = (ids: number[]): number[] => {
const now = Date.now();
const cooledIds = ids.filter((cwCatalogId) => {
const last = lastSyncedAt.get(cwCatalogId);
if (!last) return true;
return now - last >= SYNC_COOLDOWN_MS;
});
if (cooledIds.length <= MAX_SYNC_PER_CYCLE) return cooledIds;
return cooledIds.slice(0, MAX_SYNC_PER_CYCLE);
};
const fetchAdjustments = async (): Promise<unknown[]> => {
const response = await withCwRetry(
() => connectWiseApi.get(ADJUSTMENTS_ENDPOINT),
{
label: "inventory-adjustments",
maxAttempts: 3,
},
);
const payload = response.data;
if (Array.isArray(payload)) return payload;
if (isObject(payload) && Array.isArray(payload.data)) return payload.data;
return [];
};
const cacheKey = (cwCatalogId: number) =>
`${CATALOG_ITEM_CACHE_PREFIX}${cwCatalogId}`;
const cwLastUpdated = (item: CatalogItem): Date => {
const value = item._info?.lastUpdated;
if (!value) return new Date();
const parsed = new Date(value);
const invalidDate = Number.isNaN(parsed.getTime());
if (invalidDate) return new Date();
return parsed;
};
const syncCatalogItem = async (cwCatalogId: number): Promise<boolean> => {
try {
const item = await withCwRetry(
() => catalogCw.fetchByCatalogId(cwCatalogId),
{
label: `catalog-item:${cwCatalogId}`,
maxAttempts: 3,
},
);
const onHand = await withCwRetry(
() => catalogCw.fetchInventoryOnHand(cwCatalogId),
{
label: `catalog-onhand:${cwCatalogId}`,
maxAttempts: 3,
},
);
const persisted = await prisma.catalogItem.upsert({
where: { cwCatalogId },
create: {
cwCatalogId,
identifier: item.identifier,
name: item.description,
description: item.description,
customerDescription: item.customerDescription,
internalNotes: item.notes,
category: item.category?.name,
categoryCwId: item.category?.id,
subcategory: item.subcategory?.name,
subcategoryCwId: item.subcategory?.id,
manufacturer: item.manufacturer?.name,
manufactureCwId: item.manufacturer?.id,
partNumber: item.manufacturerPartNumber,
vendorName: item.vendor?.name,
vendorSku: item.vendorSku,
vendorCwId: item.vendor?.id,
price: item.price,
cost: item.cost,
inactive: item.inactiveFlag,
salesTaxable: item.taxableFlag,
onHand,
cwLastUpdated: cwLastUpdated(item),
},
update: {
identifier: item.identifier,
name: item.description,
description: item.description,
customerDescription: item.customerDescription,
internalNotes: item.notes,
category: item.category?.name,
categoryCwId: item.category?.id,
subcategory: item.subcategory?.name,
subcategoryCwId: item.subcategory?.id,
manufacturer: item.manufacturer?.name,
manufactureCwId: item.manufacturer?.id,
partNumber: item.manufacturerPartNumber,
vendorName: item.vendor?.name,
vendorSku: item.vendorSku,
vendorCwId: item.vendor?.id,
price: item.price,
cost: item.cost,
inactive: item.inactiveFlag,
salesTaxable: item.taxableFlag,
onHand,
cwLastUpdated: cwLastUpdated(item),
},
});
await redis.set(
cacheKey(cwCatalogId),
JSON.stringify({
cwCatalogId,
onHand,
cwItem: item,
dbItem: persisted,
syncedAt: new Date().toISOString(),
}),
"EX",
CATALOG_ITEM_CACHE_TTL_SECONDS,
);
return true;
} catch (err) {
console.error(
`[inventory-adjustments] failed to sync catalog item ${cwCatalogId}`,
err,
);
return false;
}
};
export const listenInventoryAdjustments = async (): Promise<void> => {
if (inFlight) return;
inFlight = true;
try {
const rows = await fetchAdjustments();
const current = snapshot(rows);
const currentProductState = productState(current);
if (previous.size === 0) {
previous = current;
previousProductState = currentProductState;
console.log(
`[inventory-adjustments] baseline captured (${current.size} adjustments, ${currentProductState.size} products)`,
);
return;
}
const changedIds = [
...changedCatalogIds(previousProductState, currentProductState),
].sort((a, b) => a - b);
const guardedIds = applySyncGuards(changedIds);
previous = current;
previousProductState = currentProductState;
if (guardedIds.length === 0) return;
let successCount = 0;
for (const cwCatalogId of guardedIds) {
const ok = await syncCatalogItem(cwCatalogId);
if (!ok) continue;
lastSyncedAt.set(cwCatalogId, Date.now());
successCount += 1;
}
const skippedByCooldown = changedIds.length - guardedIds.length;
console.log(
`[inventory-adjustments] inventory changed for ${changedIds.length} products, queued ${guardedIds.length}, synced ${successCount}, cooldown/cap skipped ${skippedByCooldown}`,
);
} catch (err) {
console.error("[inventory-adjustments] listener failed", err);
} finally {
inFlight = false;
}
};
@@ -2,6 +2,31 @@ import { prisma } from "../../../constants";
import { events } from "../../globalEvents"; import { events } from "../../globalEvents";
import { catalogCw } from "./catalog"; import { catalogCw } from "./catalog";
const CONCURRENCY = 6;
const BATCH_DELAY_MS = 250;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const runSlowParallel = async (
tasks: Array<() => Promise<void>>,
): Promise<number> => {
let failureCount = 0;
for (let i = 0; i < tasks.length; i += CONCURRENCY) {
const batch = tasks.slice(i, i + CONCURRENCY);
const results = await Promise.allSettled(batch.map((task) => task()));
for (const result of results) {
if (result.status === "rejected") failureCount += 1;
}
if (i + CONCURRENCY >= tasks.length) continue;
await sleep(BATCH_DELAY_MS);
}
return failureCount;
};
export const refreshCatalog = async () => { export const refreshCatalog = async () => {
events.emit("cw:catalog:refresh:check"); events.emit("cw:catalog:refresh:check");
@@ -46,101 +71,104 @@ export const refreshCatalog = async () => {
staleCount: staleIds.length, staleCount: staleIds.length,
}); });
// 4. Fetch full catalog data, then filter to only stale items // 4. Fetch full CW item data for stale IDs using slow, bounded concurrency
const staleIdSet = new Set(staleIds); const cwItemMap = new Map<number, any>();
const allCwItems = await catalogCw.fetchAllItemsFromCw(); const itemFetchTasks: Array<() => Promise<void>> = staleIds.map(
const allStaleItems = new Map<number, any>(); (cwId) => async () => {
const item = await catalogCw.fetchByCatalogId(cwId);
cwItemMap.set(cwId, item);
},
);
const itemFetchFailures = await runSlowParallel(itemFetchTasks);
for (const [id, item] of allCwItems) { // 5. Fetch inventory onHand for stale IDs using the same slow parallel strategy
if (staleIdSet.has(id)) {
allStaleItems.set(id, item);
}
}
// 5. Batch fetch inventory onHand for stale items (50 concurrent)
const onHandMap = new Map<number, number>(); const onHandMap = new Map<number, number>();
const batchSize = 50; const inventoryTasks: Array<() => Promise<void>> = staleIds.map(
(cwId) => async () => {
try {
const onHand = await catalogCw.fetchInventoryOnHand(cwId);
onHandMap.set(cwId, onHand);
} catch {
onHandMap.set(cwId, 0);
}
},
);
const inventoryFailures = await runSlowParallel(inventoryTasks);
for (let i = 0; i < staleIds.length; i += batchSize) { // 6. Upsert stale/new items with bounded slow parallel execution
const batch = staleIds.slice(i, i + batchSize); let updatedCount = 0;
await Promise.all( const upsertTasks: Array<() => Promise<void>> = staleIds.map(
batch.map(async (cwId) => { (cwId) => async () => {
try { const item = cwItemMap.get(cwId);
const onHand = await catalogCw.fetchInventoryOnHand(cwId); if (!item) return;
onHandMap.set(cwId, onHand);
} catch { const cwLastUpdated = item._info?.lastUpdated
onHandMap.set(cwId, 0); ? new Date(item._info.lastUpdated)
} : new Date();
}), const onHand = onHandMap.get(cwId) ?? 0;
await prisma.catalogItem.upsert({
where: { cwCatalogId: cwId },
create: {
cwCatalogId: cwId,
identifier: item.identifier,
name: item.description,
description: item.description,
customerDescription: item.customerDescription,
internalNotes: item.notes,
category: item.category?.name,
categoryCwId: item.category?.id,
subcategory: item.subcategory?.name,
subcategoryCwId: item.subcategory?.id,
manufacturer: item.manufacturer?.name,
manufactureCwId: item.manufacturer?.id,
partNumber: item.manufacturerPartNumber,
vendorName: item.vendor?.name,
vendorSku: item.vendorSku,
vendorCwId: item.vendor?.id,
price: item.price,
cost: item.cost,
inactive: item.inactiveFlag,
salesTaxable: item.taxableFlag,
onHand,
cwLastUpdated,
},
update: {
name: item.description,
identifier: item.identifier,
description: item.description,
customerDescription: item.customerDescription,
internalNotes: item.notes,
category: item.category?.name,
categoryCwId: item.category?.id,
subcategory: item.subcategory?.name,
subcategoryCwId: item.subcategory?.id,
manufacturer: item.manufacturer?.name,
manufactureCwId: item.manufacturer?.id,
partNumber: item.manufacturerPartNumber,
vendorName: item.vendor?.name,
vendorSku: item.vendorSku,
vendorCwId: item.vendor?.id,
price: item.price,
cost: item.cost,
inactive: item.inactiveFlag,
salesTaxable: item.taxableFlag,
onHand,
cwLastUpdated,
},
});
updatedCount += 1;
},
);
const upsertFailures = await runSlowParallel(upsertTasks);
const failedTasks = itemFetchFailures + inventoryFailures + upsertFailures;
if (failedTasks > 0) {
console.warn(
`[catalog-refresh] ${failedTasks} slow-parallel task(s) failed; remaining items will retry next cycle`,
); );
} }
// 6. Upsert only the stale/new items
const updatedCount = (
await Promise.all(
staleIds.map(async (cwId) => {
const item = allStaleItems.get(cwId);
if (!item) return null;
const cwLastUpdated = item._info?.lastUpdated
? new Date(item._info.lastUpdated)
: new Date();
const onHand = onHandMap.get(cwId) ?? 0;
return await prisma.catalogItem.upsert({
where: { cwCatalogId: cwId },
create: {
cwCatalogId: cwId,
identifier: item.identifier,
name: item.description,
description: item.description,
customerDescription: item.customerDescription,
internalNotes: item.notes,
category: item.category?.name,
categoryCwId: item.category?.id,
subcategory: item.subcategory?.name,
subcategoryCwId: item.subcategory?.id,
manufacturer: item.manufacturer?.name,
manufactureCwId: item.manufacturer?.id,
partNumber: item.manufacturerPartNumber,
vendorName: item.vendor?.name,
vendorSku: item.vendorSku,
vendorCwId: item.vendor?.id,
price: item.price,
cost: item.cost,
inactive: item.inactiveFlag,
salesTaxable: item.taxableFlag,
onHand,
cwLastUpdated,
},
update: {
name: item.description,
identifier: item.identifier,
description: item.description,
customerDescription: item.customerDescription,
internalNotes: item.notes,
category: item.category?.name,
categoryCwId: item.category?.id,
subcategory: item.subcategory?.name,
subcategoryCwId: item.subcategory?.id,
manufacturer: item.manufacturer?.name,
manufactureCwId: item.manufacturer?.id,
partNumber: item.manufacturerPartNumber,
vendorName: item.vendor?.name,
vendorSku: item.vendorSku,
vendorCwId: item.vendor?.id,
price: item.price,
cost: item.cost,
inactive: item.inactiveFlag,
salesTaxable: item.taxableFlag,
onHand,
cwLastUpdated,
},
});
}),
)
).filter(Boolean).length;
events.emit("cw:catalog:refresh:completed", { events.emit("cw:catalog:refresh:completed", {
totalCw: cwSummaries.size, totalCw: cwSummaries.size,
totalDb: dbItems.length, totalDb: dbItems.length,
@@ -2,6 +2,11 @@ import { prisma } from "../../../constants";
import { events } from "../../globalEvents"; import { events } from "../../globalEvents";
import { catalogCw } from "./catalog"; import { catalogCw } from "./catalog";
const CONCURRENCY = 6;
const BATCH_DELAY_MS = 250;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const refreshInventory = async () => { export const refreshInventory = async () => {
events.emit("cw:inventory:refresh:check"); events.emit("cw:inventory:refresh:check");
@@ -23,13 +28,13 @@ export const refreshInventory = async () => {
totalItems: dbItems.length, totalItems: dbItems.length,
}); });
// 2. Batch fetch inventory onHand for all items (50 concurrent) // 2. Slow-parallel fetch inventory onHand for all items
const onHandMap = new Map<number, number>(); const onHandMap = new Map<number, number>();
const batchSize = 150; let failedCount = 0;
for (let i = 0; i < dbItems.length; i += batchSize) { for (let i = 0; i < dbItems.length; i += CONCURRENCY) {
const batch = dbItems.slice(i, i + batchSize); const batch = dbItems.slice(i, i + CONCURRENCY);
await Promise.all( const results = await Promise.allSettled(
batch.map(async (item) => { batch.map(async (item) => {
try { try {
const onHand = await catalogCw.fetchInventoryOnHand(item.cwCatalogId); const onHand = await catalogCw.fetchInventoryOnHand(item.cwCatalogId);
@@ -39,6 +44,13 @@ export const refreshInventory = async () => {
} }
}), }),
); );
for (const result of results) {
if (result.status === "rejected") failedCount += 1;
}
if (i + CONCURRENCY >= dbItems.length) continue;
await sleep(BATCH_DELAY_MS);
} }
// 3. Only update items where onHand has changed // 3. Only update items where onHand has changed
@@ -71,4 +83,10 @@ export const refreshInventory = async () => {
totalItems: dbItems.length, totalItems: dbItems.length,
updatedCount, updatedCount,
}); });
if (failedCount > 0) {
console.warn(
`[inventory-refresh] ${failedCount} task(s) failed; fallback values were used and will retry next sweep`,
);
}
}; };
+19
View File
@@ -205,6 +205,25 @@ interface EventTypes {
totalUsers: number; totalUsers: number;
usersUpdated: number; usersUpdated: number;
}) => void; }) => void;
// ConnectWise Members DB Sync Events
"cw:members:db:refresh:check": () => void;
"cw:members:db:refresh:started": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
}) => void;
"cw:members:db:refresh:completed": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
itemsUpdated: number;
}) => void;
"cw:members:db:refresh:skipped": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
}) => void;
} }
export const events = new Eventra<EventTypes>(); export const events = new Eventra<EventTypes>();
+782
View File
@@ -0,0 +1,782 @@
import PdfPrinter from "pdfmake/src/Printer";
import { readFileSync } from "node:fs";
import { join } from "node:path";
export interface QuoteLineItem {
qty: number;
description: string;
unitPrice: number;
narrative?: string;
}
export interface CustomerInfo {
name: string;
company?: string;
attention?: string;
address: string[];
}
export interface CustomerContact {
email?: string;
phone?: string;
}
export interface QuoteDetails {
quoteNumber: string;
date: string;
description: string;
}
export interface TaxConfig {
rate: number;
label: string;
}
export interface SalesRepInfo {
name: string;
email?: string;
}
export interface QuoteMetadata {
quoteId?: string;
createdById?: string;
createdByName?: string;
createdByEmail?: string;
createdAt?: string;
downloadedAt?: string;
downloadedById?: string;
downloadedByName?: string;
downloadedByEmail?: string;
}
export interface QuoteData {
customer: CustomerInfo;
contact: CustomerContact;
quote: QuoteDetails;
lineItems: QuoteLineItem[];
tax: TaxConfig;
salesRep?: SalesRepInfo;
quoteNarrative?: string;
isPreview?: boolean;
showLineItemPricing?: boolean;
metadata?: QuoteMetadata;
}
export interface QuoteTheme {
brandPrimary: string;
brandDark: string;
brandLight: string;
accent: string;
headerBg: string;
footerBg: string;
}
const DEFAULT_THEME: QuoteTheme = {
brandPrimary: "#8B5E0B",
brandDark: "#5C3D07",
brandLight: "#F5EDE0",
accent: "#C67F17",
headerBg: "#2D2317",
footerBg: "#F5EDE0",
};
const SLATE = "#3A3A3A";
const SLATE_MID = "#636363";
const SLATE_LIGHT = "#8E8E8E";
const WHITE = "#FFFFFF";
const ROW_ALT = "#FAF7F2";
const DIVIDER = "#D4C5A9";
const PAGE_H = 792;
const PAGE_W = 612;
const MARGIN_L = 40;
const MARGIN_R = 40;
const MARGIN_TOP = 26;
const MARGIN_BOTTOM = 65;
const CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R;
const DEFAULT_DISCLAIMER =
"Prices valid for 30 days from quote date. Taxes invoiced per jurisdiction regardless of presence on this quote.";
const COMPANY = {
name: "Total Tech Solutions LLC",
contactPerson: "Courtney Stevens",
address: ["PO Box 331", "Murray, KY 42071"],
phone: "(270) 761-8324",
email: "courtney.stevens@totaltech.net",
licenseInfo: "Licensed in Kentucky & Tennessee · TN License #2173",
} as const;
const DEFAULT_LOGO_PATH = join(process.cwd(), "logo.png");
const fontDir = join(process.cwd(), "node_modules/pdfmake/build/fonts/Roboto");
const fonts = {
Roboto: {
normal: join(fontDir, "Roboto-Regular.ttf"),
bold: join(fontDir, "Roboto-Medium.ttf"),
italics: join(fontDir, "Roboto-Italic.ttf"),
bolditalics: join(fontDir, "Roboto-MediumItalic.ttf"),
},
};
const printer = new PdfPrinter(fonts as never);
const fmt = (n: number) =>
"$" + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const hr = (color = DIVIDER, weight = 0.75) => ({
canvas: [
{
type: "line",
x1: 0,
y1: 0,
x2: CONTENT_W,
y2: 0,
lineWidth: weight,
lineColor: color,
},
],
});
function loadLogoDataUrl(logoPath: string): string | null {
try {
const raw = readFileSync(logoPath);
const ext = logoPath.toLowerCase().endsWith(".png") ? "png" : "jpeg";
return `data:image/${ext};base64,${raw.toString("base64")}`;
} catch {
return null;
}
}
export async function generateQuote(
data: QuoteData,
theme: Partial<QuoteTheme> = {},
logoPath = DEFAULT_LOGO_PATH,
): Promise<Buffer> {
const t: QuoteTheme = { ...DEFAULT_THEME, ...theme };
const subTotal = data.lineItems.reduce(
(sum, item) => sum + item.qty * item.unitPrice,
0,
);
const taxAmount = subTotal * data.tax.rate;
const total = subTotal + taxAmount;
const logoDataUrl = loadLogoDataUrl(logoPath);
const showPricing = data.showLineItemPricing ?? false;
const tableHeader = [
{ text: "Qty", style: "thCell", alignment: "center" },
{ text: "Description", style: "thCell" },
...(showPricing
? [
{ text: "Unit Price", style: "thCell", alignment: "right" },
{ text: "Total", style: "thCell", alignment: "right" },
]
: []),
];
const colCount = showPricing ? 4 : 2;
const tableRows: Record<string, unknown>[][] = [];
for (const item of data.lineItems) {
// Build the description cell — stack description + narrative so they
// are a single cell and pdfmake never splits them across pages.
const descriptionCell: Record<string, unknown> = item.narrative
? {
stack: [
{ text: item.description, style: "tdCell" },
{
text: item.narrative,
style: "narrative",
margin: [0, 2, 8, 0],
},
],
}
: { text: item.description, style: "tdCell" };
tableRows.push([
{ text: String(item.qty), style: "tdCell", alignment: "center" },
descriptionCell,
...(showPricing
? [
{
text: fmt(item.unitPrice),
style: "tdCell",
alignment: "right",
noWrap: true,
},
{
text: fmt(item.qty * item.unitPrice),
style: "tdCell",
alignment: "right",
noWrap: true,
},
]
: []),
]);
}
const headerImage = logoDataUrl
? { image: logoDataUrl, width: 200 }
: {
stack: [{ text: COMPANY.name, style: "companyName" }],
width: 200,
};
const docDefinition = {
pageSize: "LETTER" as const,
pageMargins: [MARGIN_L, MARGIN_TOP, MARGIN_R, MARGIN_BOTTOM] as [
number,
number,
number,
number,
],
info: {
title: `Quote ${data.quote.quoteNumber}`,
author: data.metadata?.createdByName ?? COMPANY.name,
subject: data.quote.description,
creator: COMPANY.name,
producer: COMPANY.name,
keywords: [
data.metadata?.quoteId ? `quoteId:${data.metadata.quoteId}` : null,
data.metadata?.createdById
? `createdById:${data.metadata.createdById}`
: null,
data.metadata?.createdByEmail
? `createdByEmail:${data.metadata.createdByEmail}`
: null,
data.metadata?.createdAt
? `createdAt:${data.metadata.createdAt}`
: null,
data.metadata?.downloadedAt
? `downloadedAt:${data.metadata.downloadedAt}`
: null,
data.metadata?.downloadedById
? `downloadedById:${data.metadata.downloadedById}`
: null,
data.metadata?.downloadedByName
? `downloadedByName:${data.metadata.downloadedByName}`
: null,
data.metadata?.downloadedByEmail
? `downloadedByEmail:${data.metadata.downloadedByEmail}`
: null,
data.isPreview ? "preview:true" : null,
]
.filter(Boolean)
.join("; "),
},
defaultStyle: {
font: "Roboto",
fontSize: 9.5,
color: SLATE,
lineHeight: 1.3,
},
styles: {
companyName: { fontSize: 18, bold: true, color: t.brandDark },
quoteLabel: { fontSize: 24, color: t.accent, bold: true, opacity: 0.12 },
sectionTitle: {
fontSize: 8.5,
bold: true,
color: t.brandPrimary,
characterSpacing: 1.2,
},
sectionBody: { fontSize: 9, color: SLATE },
sectionMuted: { fontSize: 8.5, color: SLATE_MID },
infoLabel: {
fontSize: 8,
bold: true,
color: SLATE_LIGHT,
characterSpacing: 0.5,
},
infoValue: { fontSize: 10, bold: true, color: t.brandDark },
contactLabel: { fontSize: 8, bold: true, color: SLATE_LIGHT },
contactValue: { fontSize: 9, color: SLATE },
thCell: {
fontSize: 8.5,
bold: true,
color: WHITE,
characterSpacing: 0.5,
},
tdCell: { fontSize: 9, color: SLATE },
narrative: {
fontSize: 8,
color: SLATE_MID,
italics: true,
lineHeight: 1.2,
},
totalsLabel: { fontSize: 9, color: SLATE_MID },
totalsValue: { fontSize: 9, color: SLATE, bold: true },
totalFinalLabel: { fontSize: 11, bold: true, color: WHITE },
totalFinalValue: { fontSize: 12, bold: true, color: t.brandDark },
footerText: { fontSize: 7.5, color: SLATE_MID },
footerBold: { fontSize: 7.5, color: t.brandPrimary, bold: true },
disclaimer: { fontSize: 7, color: SLATE_LIGHT, italics: true },
},
...(data.isPreview
? {
watermark: {
text: "PREVIEW",
color: t.brandDark,
opacity: 0.15,
bold: true,
},
}
: {}),
background: () => ({
canvas: [
{ type: "rect", x: 0, y: 0, w: PAGE_W, h: 6, color: t.accent },
{ type: "rect", x: 0, y: 6, w: 4, h: 786, color: t.brandLight },
],
}),
content: [
{
margin: [0, 4, 0, 0],
columns: [
headerImage,
{
stack: [
{ text: COMPANY.name, style: "companyName", alignment: "right" },
{
text: "QUOTE",
style: "quoteLabel",
alignment: "right",
margin: [0, -4, 0, 0],
},
],
width: "*",
},
],
},
{ ...hr(t.accent, 1.5), margin: [0, 8, 0, 0] },
{
margin: [0, 7, 0, 7],
columns: [
{
width: "auto",
stack: [
{ text: "QUOTE NUMBER", style: "infoLabel" },
{
text: data.quote.quoteNumber,
style: "infoValue",
margin: [0, 2, 0, 0],
},
],
},
{
width: "auto",
margin: [30, 0, 0, 0],
stack: [
{ text: "DATE", style: "infoLabel" },
{
text: data.quote.date,
style: "infoValue",
margin: [0, 2, 0, 0],
},
],
},
{
width: "*",
margin: [30, 0, 0, 0],
stack: [
{ text: "DESCRIPTION", style: "infoLabel" },
{
text: data.quote.description,
style: "infoValue",
margin: [0, 2, 0, 0],
},
],
},
],
},
{ ...hr(), margin: [0, 0, 0, 10] },
{
columns: [
{
width: 155,
stack: [
{ text: "FROM", style: "sectionTitle", margin: [0, 0, 0, 6] },
{
text: data.salesRep?.name ?? COMPANY.contactPerson,
style: "sectionBody",
bold: true,
},
{
text: COMPANY.name,
style: "sectionMuted",
margin: [0, 2, 0, 0],
},
...COMPANY.address.map((line) => ({
text: line,
style: "sectionMuted",
})),
{
text: COMPANY.phone,
style: "sectionBody",
margin: [0, 4, 0, 0],
},
{
text: data.salesRep?.email ?? COMPANY.email,
style: "sectionMuted",
margin: [0, 1, 0, 0],
},
],
},
{
width: 175,
margin: [25, 0, 0, 0],
stack: [
{
text: "PREPARED FOR",
style: "sectionTitle",
margin: [0, 0, 0, 6],
},
{ text: data.customer.name, style: "sectionBody", bold: true },
...(data.customer.company
? [
{
text: data.customer.company,
style: "sectionMuted",
margin: [0, 2, 0, 0],
},
]
: []),
...(data.customer.attention
? [{ text: data.customer.attention, style: "sectionMuted" }]
: []),
...data.customer.address.map((line) => ({
text: line,
style: "sectionMuted",
})),
],
},
...(data.contact.email || data.contact.phone
? [
{
width: "*" as const,
margin: [20, 0, 0, 0] as [number, number, number, number],
stack: [
{
text: "CONTACT",
style: "sectionTitle",
margin: [0, 0, 0, 6],
},
...(data.contact.email
? [
{
columns: [
{
text: "Email",
style: "contactLabel",
width: 40,
},
{
text: data.contact.email,
style: "contactValue",
width: "*",
},
],
},
]
: []),
...(data.contact.phone
? [
{
columns: [
{
text: "Mobile",
style: "contactLabel",
width: 40,
},
{
text: data.contact.phone,
style: "contactValue",
width: "*",
},
],
margin: [0, 4, 0, 0],
},
]
: []),
],
},
]
: []),
],
},
{ ...hr(), margin: [0, 10, 0, 0] },
...(data.quoteNarrative
? [
{
margin: [0, 8, 0, 6] as [number, number, number, number],
table: {
widths: [2, "*"],
body: [
[
{
text: "",
fillColor: t.accent,
border: [false, false, false, false],
},
{
text: data.quoteNarrative,
fontSize: 9,
color: SLATE_MID,
italics: true,
lineHeight: 1.4,
margin: [8, 6, 8, 6],
fillColor: ROW_ALT,
border: [false, false, false, false],
},
],
],
},
layout: {
hLineWidth: () => 0,
vLineWidth: () => 0,
paddingLeft: () => 0,
paddingRight: () => 0,
paddingTop: () => 0,
paddingBottom: () => 0,
},
},
]
: []),
{
margin: [0, 10, 0, 0],
table: {
headerRows: 1,
dontBreakRows: true,
widths: showPricing ? [40, "*", 75, 75] : [40, "*"],
body: [tableHeader, ...tableRows],
},
layout: {
fillColor: (rowIndex: number) => {
if (rowIndex === 0) return t.headerBg;
return rowIndex % 2 === 0 ? ROW_ALT : null;
},
hLineWidth: (i: number, node: { table: { body: unknown[] } }) => {
if (i === 0 || i === 1) return 0;
if (i === node.table.body.length) return 1;
return 0.5;
},
vLineWidth: () => 0,
hLineColor: (i: number, node: { table: { body: unknown[] } }) =>
i === node.table.body.length ? t.headerBg : "#E8E0D0",
paddingLeft: (col: number) => (col === 0 ? 6 : 8),
paddingRight: () => 8,
paddingTop: () => 4,
paddingBottom: () => 4,
},
},
{
unbreakable: true,
stack: [
{
margin: [0, 6, 0, 0],
columns: [
{ width: "*", text: "" },
{
width: 250,
table: {
widths: ["*", 110],
body: [
[
{
text: "Subtotal",
style: "totalsLabel",
margin: [0, 5, 0, 5],
border: [false, false, false, true],
},
{
text: fmt(subTotal),
style: "totalsValue",
alignment: "right",
noWrap: true,
margin: [0, 5, 0, 5],
border: [false, false, false, true],
},
],
[
{
text: data.tax.label,
style: "totalsLabel",
margin: [0, 5, 0, 5],
border: [false, false, false, true],
},
{
text: fmt(taxAmount),
style: "totalsValue",
alignment: "right",
noWrap: true,
margin: [0, 5, 0, 5],
border: [false, false, false, true],
},
],
[
{
text: "TOTAL",
style: "totalFinalLabel",
fillColor: t.headerBg,
margin: [10, 8, 6, 8],
border: [false, false, false, false],
},
{
text: fmt(total),
style: "totalFinalValue",
alignment: "right",
noWrap: true,
fillColor: t.brandLight,
margin: [6, 7, 8, 7],
border: [false, false, false, false],
},
],
],
},
layout: {
hLineWidth: (i: number) => (i >= 1 && i <= 2 ? 0.5 : 0),
vLineWidth: () => 0,
hLineColor: () => "#E0D6C6",
},
},
],
},
{
margin: [0, 40, 0, 0],
columns: [
{
width: "50%",
stack: [
{
canvas: [
{
type: "line",
x1: 0,
y1: 0,
x2: 220,
y2: 0,
lineWidth: 0.75,
lineColor: "#999",
},
],
},
{
text: "Authorized Signature",
fontSize: 7,
color: "#888",
margin: [0, 3, 0, 0],
},
],
},
{
width: "50%",
stack: [
{
canvas: [
{
type: "line",
x1: 0,
y1: 0,
x2: 160,
y2: 0,
lineWidth: 0.75,
lineColor: "#999",
},
],
},
{
text: "Date",
fontSize: 7,
color: "#888",
margin: [0, 3, 0, 0],
},
],
},
],
},
],
},
],
footer: (currentPage: number, pageCount: number) => ({
margin: [0, 0, 0, 0],
stack: [
{
canvas: [
{ type: "rect", x: 0, y: 0, w: PAGE_W, h: 44, color: t.footerBg },
],
},
{
margin: [MARGIN_L, -38, MARGIN_R, 0],
columns: [
{
width: "*",
stack: [
{
text: [
{ text: COMPANY.name, style: "footerBold" },
{
text: ` · ${COMPANY.licenseInfo}`,
style: "footerText",
},
],
},
],
},
{
width: "auto",
text: `Page ${currentPage} of ${pageCount}`,
style: "footerText",
alignment: "right",
},
],
},
{
margin: [MARGIN_L, 4, MARGIN_R, 0],
text: DEFAULT_DISCLAIMER,
style: "disclaimer",
},
],
}),
};
const maybeDoc = printer.createPdfKitDocument(docDefinition as never) as any;
const pdfDoc =
maybeDoc && typeof maybeDoc.then === "function" ? await maybeDoc : maybeDoc;
if (!pdfDoc || typeof pdfDoc.on !== "function") {
throw new Error("Failed to initialize PDF document stream");
}
return await new Promise<Buffer>((resolve, reject) => {
try {
const chunks: Buffer[] = [];
pdfDoc.on("data", (chunk: Buffer) => chunks.push(chunk));
pdfDoc.on("end", () => resolve(Buffer.concat(chunks)));
pdfDoc.on("error", reject);
if (typeof pdfDoc.end === "function") {
pdfDoc.end();
} else {
reject(new Error("PDF document stream does not support end()"));
}
} catch (err) {
reject(err);
}
});
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./generateQuote";
export * from "./injectPdfMetadata";
@@ -0,0 +1,48 @@
import { PDFDocument } from "pdf-lib";
export interface DownloadMetadata {
downloadedAt: string;
downloadedById: string;
downloadedByName?: string;
downloadedByEmail?: string;
}
/**
* Injects download-time metadata into an existing PDF's document properties.
*
* Appends download-specific key:value pairs to the PDF's Keywords field
* (matching the semicolon-delimited format used at commit time) and updates
* the ModificationDate.
*
* Returns the modified PDF as a `Uint8Array`.
*/
export async function injectPdfMetadata(
pdfBytes: Buffer | Uint8Array,
metadata: DownloadMetadata,
): Promise<Uint8Array> {
const pdfDoc = await PDFDocument.load(pdfBytes);
// Build new keyword entries in the same format used by generateQuote
const newKeywordPairs = [
`downloadedAt:${metadata.downloadedAt}`,
`downloadedById:${metadata.downloadedById}`,
metadata.downloadedByName
? `downloadedByName:${metadata.downloadedByName}`
: null,
metadata.downloadedByEmail
? `downloadedByEmail:${metadata.downloadedByEmail}`
: null,
].filter(Boolean) as string[];
// Append to existing keywords (preserve commit-time metadata)
const existingKeywords = pdfDoc.getKeywords() ?? "";
const separator = existingKeywords.length > 0 ? "; " : "";
pdfDoc.setKeywords([
existingKeywords + separator + newKeywordPairs.join("; "),
]);
// Update modification date to download time
pdfDoc.setModificationDate(new Date(metadata.downloadedAt));
return pdfDoc.save();
}
+105 -13
View File
@@ -383,6 +383,13 @@ export const PERMISSION_NODES = {
], ],
}, },
cwCallbacks: {
name: "ConnectWise Callback Routes",
description:
"Inbound ConnectWise callback endpoints. These routes are intentionally unauthenticated and do not require permission nodes.",
permissions: [],
},
sales: { sales: {
name: "Sales Permissions", name: "Sales Permissions",
description: "Permissions for accessing and managing sales opportunities", description: "Permissions for accessing and managing sales opportunities",
@@ -392,11 +399,12 @@ export const PERMISSION_NODES = {
description: description:
"Fetch a single opportunity and its sub-resources (products, notes, contacts)", "Fetch a single opportunity and its sub-resources (products, notes, contacts)",
usedIn: [ usedIn: [
"src/api/sales/[id]/fetch.ts", "src/api/sales/opportunities/[id]/fetch.ts",
"src/api/sales/[id]/products.ts", "src/api/sales/opportunities/[id]/products/fetchAll.ts",
"src/api/sales/[id]/notes.ts", "src/api/sales/opportunities/[id]/notes/fetchAll.ts",
"src/api/sales/[id]/fetchNote.ts", "src/api/sales/opportunities/[id]/notes/fetch.ts",
"src/api/sales/[id]/contacts.ts", "src/api/sales/opportunities/[id]/contacts.ts",
"src/api/sockets/events/liveQuotePreview.ts",
], ],
}, },
{ {
@@ -404,47 +412,63 @@ export const PERMISSION_NODES = {
description: description:
"Fetch multiple opportunities, count, or opportunity types", "Fetch multiple opportunities, count, or opportunity types",
usedIn: [ usedIn: [
"src/api/sales/fetchAll.ts", "src/api/sales/opportunities/fetchAll.ts",
"src/api/sales/count.ts", "src/api/sales/opportunities/count.ts",
"src/api/sales/fetchOpportunityTypes.ts", "src/api/sales/fetchOpportunityTypes.ts",
], ],
}, },
{ {
node: "sales.opportunity.refresh", node: "sales.opportunity.refresh",
description: "Refresh a single opportunity from ConnectWise", description: "Refresh a single opportunity from ConnectWise",
usedIn: ["src/api/sales/[id]/refresh.ts"], usedIn: ["src/api/sales/opportunities/[id]/refresh.ts"],
dependencies: ["sales.opportunity.fetch"], dependencies: ["sales.opportunity.fetch"],
}, },
{
node: "sales.opportunity.update",
description:
"Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise",
usedIn: ["src/api/sales/opportunities/[id]/update.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.create",
description: "Create a new opportunity in ConnectWise",
usedIn: ["src/api/sales/opportunities/create.ts"],
},
{ {
node: "sales.opportunity.note.create", node: "sales.opportunity.note.create",
description: "Create a new note on an opportunity", description: "Create a new note on an opportunity",
usedIn: ["src/api/sales/[id]/createNote.ts"], usedIn: ["src/api/sales/opportunities/[id]/notes/create.ts"],
dependencies: ["sales.opportunity.fetch"], dependencies: ["sales.opportunity.fetch"],
}, },
{ {
node: "sales.opportunity.note.update", node: "sales.opportunity.note.update",
description: "Update an existing note on an opportunity", description: "Update an existing note on an opportunity",
usedIn: ["src/api/sales/[id]/updateNote.ts"], usedIn: ["src/api/sales/opportunities/[id]/notes/update.ts"],
dependencies: ["sales.opportunity.fetch"], dependencies: ["sales.opportunity.fetch"],
}, },
{ {
node: "sales.opportunity.note.delete", node: "sales.opportunity.note.delete",
description: "Delete a note from an opportunity", description: "Delete a note from an opportunity",
usedIn: ["src/api/sales/[id]/deleteNote.ts"], usedIn: ["src/api/sales/opportunities/[id]/notes/delete.ts"],
dependencies: ["sales.opportunity.fetch"], dependencies: ["sales.opportunity.fetch"],
}, },
{ {
node: "sales.opportunity.product.update", node: "sales.opportunity.product.update",
description: description:
"Update products (forecast items) on an opportunity, including resequencing", "Update products (forecast items) on an opportunity, including resequencing",
usedIn: ["src/api/sales/[id]/resequenceProducts.ts"], usedIn: [
"src/api/sales/opportunities/[id]/products/resequence.ts",
"src/api/sales/opportunities/[id]/products/update.ts",
"src/api/sales/opportunities/[id]/products/cancel.ts",
],
dependencies: ["sales.opportunity.fetch"], dependencies: ["sales.opportunity.fetch"],
}, },
{ {
node: "sales.opportunity.product.add", node: "sales.opportunity.product.add",
description: description:
"Add a new product (forecast item) to an opportunity. Individual fields are gated by sales.opportunity.product.field.<field> permissions.", "Add a new product (forecast item) to an opportunity. Individual fields are gated by sales.opportunity.product.field.<field> permissions.",
usedIn: ["src/api/sales/[id]/addProduct.ts"], usedIn: ["src/api/sales/opportunities/[id]/products/add.ts"],
dependencies: ["sales.opportunity.fetch"], dependencies: ["sales.opportunity.fetch"],
fieldLevelPermissions: [ fieldLevelPermissions: [
"sales.opportunity.product.field.catalogItem", "sales.opportunity.product.field.catalogItem",
@@ -466,6 +490,73 @@ export const PERMISSION_NODES = {
"sales.opportunity.product.field.sequenceNumber", "sales.opportunity.product.field.sequenceNumber",
], ],
}, },
{
node: "sales.opportunity.product.add.specialOrder",
description:
'Add one or more "SPECIAL ORDER" products to an opportunity via the dedicated special-order route.',
usedIn: [
"src/api/sales/opportunities/[id]/products/addSpecialOrder.ts",
],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.product.add.labor",
description:
"Add labor products to an opportunity using the dedicated labor route with Field/Tech catalog selection and pricing inputs.",
usedIn: [
"src/api/sales/opportunities/[id]/products/addLabor.ts",
"src/api/sales/opportunities/[id]/products/laborOptions.ts",
],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.quote.fetch",
description: "Fetch all committed quotes for an opportunity.",
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchAll.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.quote.commit",
description:
"Generate and store a finalized quote PDF for an opportunity with regeneration metadata and creator attribution.",
usedIn: ["src/api/sales/opportunities/[id]/quotes/commit.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.quote.preview",
description:
"Generate a preview-stamped quote PDF for an opportunity without storing it.",
usedIn: ["src/api/sales/opportunities/[id]/quotes/preview.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.quote.download",
description:
"Download a committed quote PDF. Each download is recorded with timestamp and user info.",
usedIn: ["src/api/sales/opportunities/[id]/quotes/download.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.quote.fetch_downloads",
description:
"Fetch download/print history for all quotes on an opportunity. Admin-level permission.",
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.view_margin",
description:
"View margin and markup data on opportunity products. Controls visibility of margin %, markup %, and related progress bars in the UI.",
usedIn: [],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.view_cost",
description:
"View cost data on opportunity products. Controls visibility of unit cost, total cost, and recurring cost in the UI.",
usedIn: [],
dependencies: ["sales.opportunity.fetch"],
},
], ],
}, },
@@ -926,6 +1017,7 @@ export const PERMISSION_NODES = {
"obj.opportunity.site", "obj.opportunity.site",
"obj.opportunity.customerPO", "obj.opportunity.customerPO",
"obj.opportunity.totalSalesTax", "obj.opportunity.totalSalesTax",
"obj.opportunity.probability",
"obj.opportunity.location", "obj.opportunity.location",
"obj.opportunity.department", "obj.opportunity.department",
"obj.opportunity.expectedCloseDate", "obj.opportunity.expectedCloseDate",
+600
View File
@@ -0,0 +1,600 @@
/**
* Test Script: CW Forecast Item Edit & Partial Cancellation
*
* This script performs read-write operations against the ConnectWise API:
*
* 1. Search all open opportunities for a forecast item with description
* matching "labor Special Order" (case-insensitive).
* 2. Report the current state of that item (price, cost, qty, etc.).
* 3. PATCH the item: revenue 72,000 | cost 8,500 | quantity 67
* 4. Verify the update by re-fetching the forecast.
* 5. Cancel 13 units via the linked procurement product
* (partial cancellation: quantityCancelled = 13).
* 6. Verify the cancellation by re-fetching procurement data.
* 7. Report on every step.
*
* Usage: bun run test-cw-edit-item.ts
*/
import axios from "axios";
const cw = axios.create({
baseURL: "https://ttscw.totaltech.net/v4_6_release/apis/3.0/",
headers: {
Authorization: `Basic ${process.env.CW_BASIC_TOKEN}`,
clientId: `${process.env.CW_CLIENT_ID}`,
"Content-Type": "application/json",
},
timeout: 30_000,
});
// ── Helpers ───────────────────────────────────────────────────────────────────
const log = (label: string, ...args: unknown[]) =>
console.log(`\n[${label}]`, ...args);
const divider = () => console.log("─".repeat(72));
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const fmt = (n: number) =>
n.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
// ── Types (minimal, for this script) ──────────────────────────────────────────
interface ForecastItem {
id: number;
forecastDescription: string;
productDescription: string;
quantity: number;
revenue: number;
cost: number;
margin: number;
forecastType: string;
sequenceNumber: number;
catalogItem?: { id: number; identifier: string };
status?: { id: number; name: string };
opportunity?: { id: number; name: string };
[key: string]: unknown;
}
interface Forecast {
id: number;
forecastItems: ForecastItem[];
[key: string]: unknown;
}
interface ProcurementProduct {
id: number;
forecastDetailId: number;
description: string;
quantity: number;
price: number;
cost: number;
cancelledFlag: boolean;
quantityCancelled: number;
cancelledReason: string | null;
cancelledBy: string | null;
cancelledDate: string | null;
opportunity?: { id: number };
[key: string]: unknown;
}
// ── Main ──────────────────────────────────────────────────────────────────────
async function main() {
divider();
log("START", "CW Forecast Item Edit & Cancellation Test");
log("START", `Timestamp: ${new Date().toISOString()}`);
divider();
// ── Step 1: Find the "labor Special Order" forecast item ────────────────
const OPP_ID = 5150;
log(
"SEARCH",
`Looking for forecast item matching "labor Special Order" on opportunity ${OPP_ID}...`,
);
// Fetch the forecast for opportunity 5150 directly
let targetOppId: number = OPP_ID;
let targetItem: ForecastItem | null = null;
let targetForecast: Forecast | null = null;
const forecastRes = await cw.get(`/sales/opportunities/${OPP_ID}/forecast`);
targetForecast = forecastRes.data as Forecast;
const match = (targetForecast.forecastItems ?? []).find(
(fi: ForecastItem) =>
fi.forecastDescription?.toLowerCase().includes("special order") ||
fi.productDescription?.toLowerCase().includes("special order"),
);
if (match) {
targetItem = match;
log("SEARCH", `✓ FOUND forecast item on opportunity ${OPP_ID}`);
}
if (!targetItem || !targetForecast) {
log(
"SEARCH",
`✗ No "labor Special Order" item found on opportunity ${OPP_ID}.`,
);
log("SEARCH", "All forecast items on this opportunity:");
for (const fi of targetForecast.forecastItems ?? []) {
console.log(
` id=${fi.id} "${fi.forecastDescription}" / "${fi.productDescription}"`,
);
}
log("SEARCH", "Aborting.");
process.exit(1);
}
// ── Step 2: Report current state ────────────────────────────────────────
divider();
log("CURRENT STATE", "Forecast item details BEFORE edit:");
console.log(` Opportunity ID: ${targetOppId}`);
console.log(` Forecast Item ID: ${targetItem.id}`);
console.log(` Forecast Description: ${targetItem.forecastDescription}`);
console.log(` Product Description: ${targetItem.productDescription}`);
console.log(
` Catalog Item: ${targetItem.catalogItem?.identifier ?? "(none)"} (cwId=${targetItem.catalogItem?.id ?? "N/A"})`,
);
console.log(` Forecast Type: ${targetItem.forecastType}`);
console.log(
` Status: ${targetItem.status?.name ?? "?"} (id=${targetItem.status?.id ?? "?"})`,
);
console.log(` Sequence Number: ${targetItem.sequenceNumber}`);
console.log(` ──────────────────────────────────`);
console.log(` Quantity: ${targetItem.quantity}`);
console.log(` Revenue (Price): $${fmt(targetItem.revenue)}`);
console.log(` Cost: $${fmt(targetItem.cost)}`);
console.log(` Margin: $${fmt(targetItem.margin)}`);
// Also report all items on this opportunity for context
const allItems = targetForecast.forecastItems ?? [];
log(
"CONTEXT",
`Total forecast items on this opportunity: ${allItems.length}`,
);
for (const fi of allItems) {
const marker = fi.id === targetItem.id ? " ◀ TARGET" : "";
console.log(
` [${fi.sequenceNumber}] id=${fi.id} "${fi.forecastDescription}" ` +
`qty=${fi.quantity} rev=$${fmt(fi.revenue)} cost=$${fmt(fi.cost)}${marker}`,
);
}
// ── Step 3: PATCH the forecast item ─────────────────────────────────────
divider();
const UNIT_PRICE = 72_000;
const UNIT_COST = 8_500;
const QTY = 67;
const TOTAL_REVENUE = UNIT_PRICE * QTY; // $4,824,000
const TOTAL_COST = UNIT_COST * QTY; // $569,500
log("EDIT", "Patching forecast item...");
log(
"EDIT",
` Unit price: $${fmt(UNIT_PRICE)} × ${QTY} = $${fmt(TOTAL_REVENUE)} (revenue)`,
);
log(
"EDIT",
` Unit cost: $${fmt(UNIT_COST)} × ${QTY} = $${fmt(TOTAL_COST)} (cost)`,
);
log("EDIT", ` Quantity: ${QTY}`);
// Find the index of our target item in the forecast array
const forecastItems = targetForecast.forecastItems ?? [];
const targetIdx = forecastItems.findIndex((fi) => fi.id === targetItem!.id);
if (targetIdx === -1) {
log(
"EDIT",
"✗ Could not find target item index in forecast array. Aborting.",
);
process.exit(1);
}
log("EDIT", `Target item is at index ${targetIdx} in forecastItems array.`);
const patchOps = [
{
op: "replace",
path: `/forecastItems/${targetIdx}/revenue`,
value: TOTAL_REVENUE,
},
{
op: "replace",
path: `/forecastItems/${targetIdx}/cost`,
value: TOTAL_COST,
},
{ op: "replace", path: `/forecastItems/${targetIdx}/quantity`, value: QTY },
];
log("EDIT", "Patch operations:");
for (const op of patchOps) {
console.log(` ${op.op} ${op.path}${op.value}`);
}
try {
const patchRes = await cw.patch(
`/sales/opportunities/${targetOppId}/forecast`,
patchOps,
);
const updatedForecast: Forecast = patchRes.data;
const updatedItem = (updatedForecast.forecastItems ?? [])[targetIdx];
if (!updatedItem) {
log("EDIT", "✗ Item not found at expected index after PATCH.");
} else {
log("EDIT", "✓ PATCH successful. Updated item:");
console.log(` Forecast Item ID: ${updatedItem.id}`);
console.log(` Forecast Description: ${updatedItem.forecastDescription}`);
console.log(` Quantity: ${updatedItem.quantity}`);
console.log(` Revenue (Price): $${fmt(updatedItem.revenue)}`);
console.log(` Cost: $${fmt(updatedItem.cost)}`);
console.log(` Margin: $${fmt(updatedItem.margin)}`);
// Verify values match what we set
const checks = [
{
field: "revenue",
expected: TOTAL_REVENUE,
actual: updatedItem.revenue,
},
{ field: "cost", expected: TOTAL_COST, actual: updatedItem.cost },
{ field: "quantity", expected: QTY, actual: updatedItem.quantity },
];
log("VERIFY EDIT", "Checking values match requested:");
for (const check of checks) {
const ok = check.actual === check.expected;
console.log(
` ${ok ? "✓" : "✗"} ${check.field}: expected=${check.expected}, actual=${check.actual}`,
);
}
// Update our reference for the cancellation step
targetItem = updatedItem;
}
} catch (err: any) {
log("EDIT", `✗ PATCH failed: ${err.response?.status ?? err.message}`);
if (err.response?.data) {
console.log(" Response:", JSON.stringify(err.response.data, null, 2));
}
// If quantity PATCH failed (read-only), try without quantity
if (err.response?.status === 400 || err.response?.status === 422) {
log(
"EDIT",
"Retrying without quantity (may be read-only on forecast items)...",
);
const retryOps = patchOps.filter((op) => !op.path.endsWith("/quantity"));
try {
const retryRes = await cw.patch(
`/sales/opportunities/${targetOppId}/forecast`,
retryOps,
);
const retryForecast: Forecast = retryRes.data;
const retryItem = (retryForecast.forecastItems ?? [])[targetIdx];
if (retryItem) {
log(
"EDIT",
"✓ Retry PATCH successful (without quantity). Updated item:",
);
console.log(
` Quantity: ${retryItem.quantity} (unchanged — read-only)`,
);
console.log(` Revenue (Price): $${fmt(retryItem.revenue)}`);
console.log(` Cost: $${fmt(retryItem.cost)}`);
console.log(` Margin: $${fmt(retryItem.margin)}`);
targetItem = retryItem;
}
} catch (retryErr: any) {
log(
"EDIT",
`✗ Retry also failed: ${retryErr.response?.status ?? retryErr.message}`,
);
if (retryErr.response?.data) {
console.log(
" Response:",
JSON.stringify(retryErr.response.data, null, 2),
);
}
}
}
}
// ── Step 4: Re-fetch and confirm final forecast state ───────────────────
divider();
log("RE-FETCH", "Fetching forecast to confirm final state...");
await sleep(500);
const confirmRes = await cw.get(
`/sales/opportunities/${targetOppId}/forecast`,
);
const confirmedForecast: Forecast = confirmRes.data;
const confirmedItem = (confirmedForecast.forecastItems ?? []).find(
(fi) => fi.id === targetItem!.id,
);
if (confirmedItem) {
log("CONFIRMED STATE", "Forecast item after edit:");
console.log(` Forecast Item ID: ${confirmedItem.id}`);
console.log(` Forecast Description: ${confirmedItem.forecastDescription}`);
console.log(` Quantity: ${confirmedItem.quantity}`);
console.log(` Revenue (Price): $${fmt(confirmedItem.revenue)}`);
console.log(` Cost: $${fmt(confirmedItem.cost)}`);
console.log(` Margin: $${fmt(confirmedItem.margin)}`);
} else {
log(
"CONFIRMED STATE",
"⚠ Could not find item by original ID — it may have been regenerated.",
);
log("CONFIRMED STATE", "All current forecast items:");
for (const fi of confirmedForecast.forecastItems ?? []) {
console.log(
` id=${fi.id} "${fi.forecastDescription}" qty=${fi.quantity} rev=$${fmt(fi.revenue)} cost=$${fmt(fi.cost)}`,
);
}
}
// ── Step 5: Cancel 13 items via procurement product ─────────────────────
divider();
log("CANCEL", "Cancelling 13 units on this item via procurement product...");
// First, find existing procurement products linked to this opportunity
const procRes = await cw.get(
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${targetOppId}`)}&pageSize=1000`,
);
const procProducts: ProcurementProduct[] = procRes.data;
log(
"CANCEL",
`Found ${procProducts.length} procurement product(s) on this opportunity.`,
);
if (procProducts.length > 0) {
for (const pp of procProducts) {
console.log(
` Proc id=${pp.id} forecastDetailId=${pp.forecastDetailId} ` +
`"${pp.description}" qty=${pp.quantity} price=$${fmt(pp.price ?? 0)} ` +
`cancelled=${pp.cancelledFlag} qtyCancelled=${pp.quantityCancelled}`,
);
}
}
// Find the procurement product linked to our forecast item
const linkedProc = procProducts.find(
(pp) => pp.forecastDetailId === targetItem!.id,
);
if (linkedProc) {
log("CANCEL", `Found linked procurement product: id=${linkedProc.id}`);
log(
"CANCEL",
`Current state: cancelled=${linkedProc.cancelledFlag}, quantityCancelled=${linkedProc.quantityCancelled}`,
);
log("CANCEL", "Patching: quantityCancelled → 13, cancelledFlag → true");
try {
const cancelRes = await cw.patch(
`/procurement/products/${linkedProc.id}`,
[
{ op: "replace", path: "cancelledFlag", value: true },
{ op: "replace", path: "quantityCancelled", value: 13 },
{
op: "replace",
path: "cancelledReason",
value: "Test cancellation — 13 units",
},
],
);
log("CANCEL", "✓ Cancellation PATCH successful.");
console.log(` cancelledFlag: ${cancelRes.data.cancelledFlag}`);
console.log(` quantityCancelled: ${cancelRes.data.quantityCancelled}`);
console.log(` cancelledReason: ${cancelRes.data.cancelledReason}`);
console.log(
` cancelledBy: ${cancelRes.data.cancelledBy ?? "N/A"}`,
);
console.log(
` cancelledDate: ${cancelRes.data.cancelledDate ?? "N/A"}`,
);
} catch (err: any) {
log(
"CANCEL",
`✗ Cancellation PATCH failed: ${err.response?.status ?? err.message}`,
);
if (err.response?.data) {
console.log(" Response:", JSON.stringify(err.response.data, null, 2));
}
}
} else {
log(
"CANCEL",
`No procurement product linked to forecast item id=${targetItem!.id}.`,
);
log(
"CANCEL",
"Creating a procurement product first, then cancelling 13...",
);
try {
// Create a procurement product linked to this forecast item
const createProcRes = await cw.post("/procurement/products", {
catalogItem: targetItem!.catalogItem?.id
? { id: targetItem!.catalogItem.id }
: undefined,
description:
targetItem!.forecastDescription || targetItem!.productDescription,
quantity: targetItem!.quantity || 67,
price: targetItem!.revenue || 72_000,
cost: targetItem!.cost || 8_500,
billableOption: "Billable",
opportunity: { id: targetOppId },
forecastDetailId: targetItem!.id,
});
const newProc = createProcRes.data;
log("CANCEL", `✓ Created procurement product id=${newProc.id}`);
console.log(` forecastDetailId: ${newProc.forecastDetailId}`);
console.log(` description: ${newProc.description}`);
console.log(` quantity: ${newProc.quantity}`);
console.log(` price: $${fmt(newProc.price ?? 0)}`);
console.log(` cost: $${fmt(newProc.cost ?? 0)}`);
// Now cancel 13 units
log("CANCEL", "Patching procurement product: quantityCancelled → 13...");
const cancelRes = await cw.patch(`/procurement/products/${newProc.id}`, [
{ op: "replace", path: "cancelledFlag", value: true },
{ op: "replace", path: "quantityCancelled", value: 13 },
{
op: "replace",
path: "cancelledReason",
value: "Test cancellation — 13 units",
},
]);
log("CANCEL", "✓ Cancellation PATCH successful.");
console.log(` cancelledFlag: ${cancelRes.data.cancelledFlag}`);
console.log(` quantityCancelled: ${cancelRes.data.quantityCancelled}`);
console.log(` cancelledReason: ${cancelRes.data.cancelledReason}`);
console.log(
` cancelledBy: ${cancelRes.data.cancelledBy ?? "N/A"}`,
);
console.log(
` cancelledDate: ${cancelRes.data.cancelledDate ?? "N/A"}`,
);
} catch (err: any) {
log("CANCEL", `✗ Failed: ${err.response?.status ?? err.message}`);
if (err.response?.data) {
console.log(" Response:", JSON.stringify(err.response.data, null, 2));
}
}
}
// ── Step 6: Final verification ──────────────────────────────────────────
divider();
log("FINAL VERIFY", "Re-fetching all data for final report...");
await sleep(500);
// Re-fetch forecast
const finalForecastRes = await cw.get(
`/sales/opportunities/${targetOppId}/forecast`,
);
const finalForecast: Forecast = finalForecastRes.data;
const finalItem =
(finalForecast.forecastItems ?? []).find(
(fi) => fi.id === targetItem!.id,
) ??
(finalForecast.forecastItems ?? []).find(
(fi) =>
fi.forecastDescription?.toLowerCase().includes("special order") ||
fi.productDescription?.toLowerCase().includes("special order"),
);
// Re-fetch procurement
const finalProcRes = await cw.get(
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${targetOppId}`)}&pageSize=1000`,
);
const finalProcs: ProcurementProduct[] = finalProcRes.data;
log("FINAL STATE — FORECAST ITEM", "");
if (finalItem) {
console.log(` Forecast Item ID: ${finalItem.id}`);
console.log(` Forecast Description: ${finalItem.forecastDescription}`);
console.log(` Quantity: ${finalItem.quantity}`);
console.log(` Revenue (Price): $${fmt(finalItem.revenue)}`);
console.log(` Cost: $${fmt(finalItem.cost)}`);
console.log(` Margin: $${fmt(finalItem.margin)}`);
} else {
console.log(" ⚠ Target item not found in final forecast.");
}
log("FINAL STATE — PROCUREMENT", `${finalProcs.length} product(s):`);
for (const pp of finalProcs) {
console.log(
` id=${pp.id} forecastDetailId=${pp.forecastDetailId} ` +
`"${pp.description}" qty=${pp.quantity} cancelled=${pp.cancelledFlag} ` +
`qtyCancelled=${pp.quantityCancelled} reason="${pp.cancelledReason ?? ""}"`,
);
}
// ── Summary ─────────────────────────────────────────────────────────────
divider();
log("SUMMARY", "");
// After cancelling 13 of 67, CW recalculates totals for remaining 54 units
const expectedFinalRevenue = Math.round(UNIT_PRICE * (QTY - 13) * 100) / 100;
const expectedFinalCost = Math.round(UNIT_COST * (QTY - 13) * 100) / 100;
const editOk = finalItem
? Math.abs(finalItem.revenue - expectedFinalRevenue) < 1 &&
Math.abs(finalItem.cost - expectedFinalCost) < 1
: false;
const qtyOk = finalItem ? finalItem.quantity === QTY : false;
if (finalItem) {
console.log(
` Expected final revenue ($${fmt(UNIT_PRICE)} × ${QTY - 13}): $${fmt(expectedFinalRevenue)}`,
);
console.log(
` Actual final revenue: $${fmt(finalItem.revenue)}`,
);
console.log(
` Expected final cost ($${fmt(UNIT_COST)} × ${QTY - 13}): $${fmt(expectedFinalCost)}`,
);
console.log(
` Actual final cost: $${fmt(finalItem.cost)}`,
);
}
const cancelOk = finalProcs.some(
(pp) =>
pp.forecastDetailId === targetItem!.id &&
pp.cancelledFlag === true &&
pp.quantityCancelled === 13,
);
console.log(
` Unit price $${fmt(UNIT_PRICE)}/ea: `,
editOk ? "✓ PASS" : "✗ FAIL",
);
console.log(
` Unit cost $${fmt(UNIT_COST)}/ea: `,
editOk ? "✓ PASS" : "✗ FAIL",
);
console.log(
` Quantity set to ${QTY}: `,
qtyOk ? "✓ PASS" : "✗ FAIL (may be read-only)",
);
console.log(
" 13 units cancelled: ",
cancelOk ? "✓ PASS" : "✗ FAIL",
);
const allPass = editOk && qtyOk && cancelOk;
divider();
log(
"RESULT",
allPass
? "✓ ALL CHECKS PASSED"
: "⚠ SOME CHECKS DID NOT PASS — review output above",
);
divider();
}
main().catch((err) => {
console.error("\n[FATAL]", err.response?.data ?? err.message);
process.exit(1);
});
+17
View File
@@ -368,6 +368,23 @@ export function buildMockCWForecastItem(overrides: Record<string, any> = {}) {
}; };
} }
/** Build a minimal Prisma-shaped GeneratedQuotes row. */
export function buildMockGeneratedQuote(overrides: Record<string, any> = {}) {
return {
id: "quote-1",
quoteRegenData: { theme: "default" },
quoteFile: new Uint8Array([0x25, 0x50, 0x44, 0x46]),
quoteFileName: "Quote-TestOpp.pdf",
opportunityId: "opp-1",
createdById: "user-1",
createdAt: new Date("2026-03-01"),
updatedAt: new Date("2026-03-01"),
opportunity: null,
createdBy: null,
...overrides,
};
}
/** Build a minimal Prisma-shaped CatalogItem row. */ /** Build a minimal Prisma-shaped CatalogItem row. */
export function buildMockCatalogItem(overrides: Record<string, any> = {}) { export function buildMockCatalogItem(overrides: Record<string, any> = {}) {
return { return {
+4 -4
View File
@@ -11,12 +11,12 @@ const DAY_MS = 24 * 60 * 60 * 1000;
describe("computeProductsCacheTTL", () => { describe("computeProductsCacheTTL", () => {
// -- Constants ---------------------------------------------------------- // -- Constants ----------------------------------------------------------
test("PRODUCTS_TTL_HOT is 15 seconds", () => { test("PRODUCTS_TTL_HOT is 45 seconds", () => {
expect(PRODUCTS_TTL_HOT).toBe(15_000); expect(PRODUCTS_TTL_HOT).toBe(45_000);
}); });
test("PRODUCTS_TTL_LAZY is 30 minutes", () => { test("PRODUCTS_TTL_LAZY is 20 minutes", () => {
expect(PRODUCTS_TTL_LAZY).toBe(1_800_000); expect(PRODUCTS_TTL_LAZY).toBe(1_200_000);
}); });
// -- Won/Lost status set ------------------------------------------------ // -- Won/Lost status set ------------------------------------------------
@@ -0,0 +1,223 @@
import { describe, test, expect } from "bun:test";
import { CatalogItemController } from "../../../src/controllers/CatalogItemController";
import { buildMockCatalogItem } from "../../setup";
describe("CatalogItemController", () => {
// -------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------
describe("constructor", () => {
test("sets core identification fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.id).toBe("cat-1");
expect(ctrl.cwCatalogId).toBe(500);
expect(ctrl.identifier).toBe("USW-Pro-24");
});
test("sets name and description fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.name).toBe("UniFi Switch Pro 24");
expect(ctrl.description).toBe("24-port managed switch");
expect(ctrl.customerDescription).toBe("Enterprise switch");
expect(ctrl.internalNotes).toBeNull();
});
test("sets category and subcategory fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.category).toBe("Technology");
expect(ctrl.categoryCwId).toBe(18);
expect(ctrl.subcategory).toBe("Network-Switch");
expect(ctrl.subcategoryCwId).toBe(112);
});
test("sets manufacturer fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.manufacturer).toBe("Ubiquiti");
expect(ctrl.manufactureCwId).toBe(248);
expect(ctrl.partNumber).toBe("USW-Pro-24");
});
test("sets vendor fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.vendorName).toBe("Ubiquiti Inc");
expect(ctrl.vendorSku).toBe("USW-Pro-24");
expect(ctrl.vendorCwId).toBe(100);
});
test("sets financial fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.price).toBe(500.0);
expect(ctrl.cost).toBe(360.0);
});
test("sets boolean flags", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.inactive).toBe(false);
expect(ctrl.salesTaxable).toBe(true);
});
test("sets inventory fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.onHand).toBe(10);
});
test("sets timestamps", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.cwLastUpdated).toBeInstanceOf(Date);
expect(ctrl.createdAt).toBeInstanceOf(Date);
expect(ctrl.updatedAt).toBeInstanceOf(Date);
});
test("builds linked items recursively", () => {
const linked = buildMockCatalogItem({
id: "cat-2",
name: "Linked Item",
linkedItems: undefined,
});
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked] }),
);
const items = ctrl.getLinkedItems();
expect(items).toHaveLength(1);
expect(items[0].id).toBe("cat-2");
expect(items[0]).toBeInstanceOf(CatalogItemController);
});
test("defaults to empty linked items when undefined", () => {
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: undefined }),
);
expect(ctrl.getLinkedItems()).toHaveLength(0);
});
test("handles null optional fields", () => {
const ctrl = new CatalogItemController(
buildMockCatalogItem({
description: null,
customerDescription: null,
identifier: null,
category: null,
categoryCwId: null,
subcategory: null,
subcategoryCwId: null,
manufacturer: null,
manufactureCwId: null,
partNumber: null,
vendorName: null,
vendorSku: null,
vendorCwId: null,
cwLastUpdated: null,
}),
);
expect(ctrl.description).toBeNull();
expect(ctrl.customerDescription).toBeNull();
expect(ctrl.identifier).toBeNull();
expect(ctrl.category).toBeNull();
expect(ctrl.manufacturer).toBeNull();
expect(ctrl.cwLastUpdated).toBeNull();
});
});
// -------------------------------------------------------------------
// getLinkedItems
// -------------------------------------------------------------------
describe("getLinkedItems()", () => {
test("returns empty array when no linked items", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
expect(ctrl.getLinkedItems()).toEqual([]);
});
test("returns array of CatalogItemController instances", () => {
const linked1 = buildMockCatalogItem({ id: "cat-2", name: "Item 2" });
const linked2 = buildMockCatalogItem({ id: "cat-3", name: "Item 3" });
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked1, linked2] }),
);
const items = ctrl.getLinkedItems();
expect(items).toHaveLength(2);
expect(items[0].name).toBe("Item 2");
expect(items[1].name).toBe("Item 3");
});
});
// -------------------------------------------------------------------
// toJson
// -------------------------------------------------------------------
describe("toJson()", () => {
test("returns all core fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.id).toBe("cat-1");
expect(json.cwCatalogId).toBe(500);
expect(json.identifier).toBe("USW-Pro-24");
expect(json.name).toBe("UniFi Switch Pro 24");
expect(json.description).toBe("24-port managed switch");
expect(json.customerDescription).toBe("Enterprise switch");
expect(json.internalNotes).toBeNull();
});
test("returns classification fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.category).toBe("Technology");
expect(json.categoryCwId).toBe(18);
expect(json.subcategory).toBe("Network-Switch");
expect(json.subcategoryCwId).toBe(112);
expect(json.manufacturer).toBe("Ubiquiti");
expect(json.manufactureCwId).toBe(248);
expect(json.partNumber).toBe("USW-Pro-24");
});
test("returns financial fields", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.price).toBe(500.0);
expect(json.cost).toBe(360.0);
});
test("returns boolean flags", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.inactive).toBe(false);
expect(json.salesTaxable).toBe(true);
});
test("returns timestamps", () => {
const ctrl = new CatalogItemController(buildMockCatalogItem());
const json = ctrl.toJson();
expect(json.createdAt).toBeInstanceOf(Date);
expect(json.updatedAt).toBeInstanceOf(Date);
expect(json.cwLastUpdated).toBeInstanceOf(Date);
});
test("excludes linkedItems when includeLinkedItems not set", () => {
const linked = buildMockCatalogItem({ id: "cat-2" });
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked] }),
);
const json = ctrl.toJson();
expect(json.linkedItems).toBeUndefined();
});
test("includes linkedItems when includeLinkedItems is true", () => {
const linked = buildMockCatalogItem({ id: "cat-2", name: "Linked" });
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked] }),
);
const json = ctrl.toJson({ includeLinkedItems: true });
expect(json.linkedItems).toHaveLength(1);
expect(json.linkedItems[0].id).toBe("cat-2");
expect(json.linkedItems[0].name).toBe("Linked");
});
test("linked items toJson does not recursively include their linked items", () => {
const linked = buildMockCatalogItem({ id: "cat-2" });
const ctrl = new CatalogItemController(
buildMockCatalogItem({ linkedItems: [linked] }),
);
const json = ctrl.toJson({ includeLinkedItems: true });
// Nested linked items called without opts, so linkedItems is undefined
expect(json.linkedItems[0].linkedItems).toBeUndefined();
});
});
});
@@ -138,6 +138,40 @@ describe("ForecastProductController", () => {
}); });
}); });
// -------------------------------------------------------------------
// applyProcurementCustomFields
// -------------------------------------------------------------------
describe("applyProcurementCustomFields()", () => {
test("sets productNarrative from custom field id 46", () => {
const ctrl = new ForecastProductController(buildMockCWForecastItem());
ctrl.applyProcurementCustomFields({
customFields: [{ id: 46, value: "Custom narrative text" }],
});
expect(ctrl.productNarrative).toBe("Custom narrative text");
});
test("does not overwrite productNarrative when field 46 is missing", () => {
const ctrl = new ForecastProductController(buildMockCWForecastItem());
ctrl.productNarrative = "existing";
ctrl.applyProcurementCustomFields({
customFields: [{ id: 99, value: "other" }],
});
expect(ctrl.productNarrative).toBe("existing");
});
test("handles empty customFields array", () => {
const ctrl = new ForecastProductController(buildMockCWForecastItem());
ctrl.applyProcurementCustomFields({ customFields: [] });
expect(ctrl.productNarrative).toBeNull();
});
test("handles undefined customFields", () => {
const ctrl = new ForecastProductController(buildMockCWForecastItem());
ctrl.applyProcurementCustomFields({});
expect(ctrl.productNarrative).toBeNull();
});
});
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// applyInventoryData // applyInventoryData
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -203,6 +237,67 @@ describe("ForecastProductController", () => {
}); });
expect(ctrl.cancellationType).toBe("partial"); expect(ctrl.cancellationType).toBe("partial");
}); });
test("effectiveQuantity returns full quantity when not cancelled", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5 }),
);
expect(ctrl.effectiveQuantity).toBe(5);
});
test("effectiveQuantity returns reduced quantity for partial cancel", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 2 });
expect(ctrl.effectiveQuantity).toBe(3);
});
test("effectiveQuantity returns 0 for full cancellation", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 5 });
expect(ctrl.effectiveQuantity).toBe(0);
});
test("effectiveRevenue returns full revenue when not cancelled", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, revenue: 2500 }),
);
expect(ctrl.effectiveRevenue).toBe(2500);
});
test("effectiveRevenue returns proportional revenue for partial cancel", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, revenue: 2500 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 2 });
expect(ctrl.effectiveRevenue).toBe(1500);
});
test("effectiveRevenue returns 0 for full cancellation", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, revenue: 2500 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 5 });
expect(ctrl.effectiveRevenue).toBe(0);
});
test("effectiveCost returns full cost when not cancelled", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, cost: 1800 }),
);
expect(ctrl.effectiveCost).toBe(1800);
});
test("effectiveCost returns 0 for full cancellation", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, cost: 1800 }),
);
ctrl.applyCancellationData({ cancelledFlag: true, quantityCancelled: 5 });
expect(ctrl.effectiveCost).toBe(0);
});
}); });
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -279,5 +374,24 @@ describe("ForecastProductController", () => {
expect(json.subNumber).toBe(0); expect(json.subNumber).toBe(0);
expect(json.cwLastUpdated).toBeInstanceOf(Date); expect(json.cwLastUpdated).toBeInstanceOf(Date);
}); });
test("includes customerDescription and productNarrative", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ customerDescription: "Customer desc" }),
);
const json = ctrl.toJson();
expect(json.customerDescription).toBe("Customer desc");
expect(json.productNarrative).toBeNull();
});
test("includes effective* computed fields", () => {
const ctrl = new ForecastProductController(
buildMockCWForecastItem({ quantity: 5, revenue: 2500, cost: 1800 }),
);
const json = ctrl.toJson();
expect(json.effectiveQuantity).toBe(5);
expect(json.effectiveRevenue).toBe(2500);
expect(json.effectiveCost).toBe(1800);
});
}); });
}); });
@@ -0,0 +1,171 @@
import { describe, test, expect } from "bun:test";
import { GeneratedQuoteController } from "../../../src/controllers/GeneratedQuoteController";
import {
buildMockGeneratedQuote,
buildMockOpportunity,
buildMockUser,
} from "../../setup";
describe("GeneratedQuoteController", () => {
// -------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------
describe("constructor", () => {
test("sets core identification fields", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
expect(ctrl.id).toBe("quote-1");
expect(ctrl.quoteFileName).toBe("Quote-TestOpp.pdf");
expect(ctrl.opportunityId).toBe("opp-1");
expect(ctrl.createdById).toBe("user-1");
});
test("sets quoteRegenData", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
expect(ctrl.quoteRegenData).toEqual({ theme: "default" });
});
test("sets quoteFile as Uint8Array", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
expect(ctrl.quoteFile).toBeInstanceOf(Uint8Array);
expect(ctrl.quoteFile.length).toBe(4);
});
test("sets timestamps", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
expect(ctrl.createdAt).toBeInstanceOf(Date);
expect(ctrl.updatedAt).toBeInstanceOf(Date);
});
test("wraps included opportunity in OpportunityController", () => {
const opp = buildMockOpportunity();
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ opportunity: opp }),
);
const json = ctrl.toJson({ includeOpportunity: true });
expect(json.opportunity).toBeDefined();
expect(json.opportunity.id).toBe("opp-1");
});
test("wraps included createdBy in UserController", () => {
const user = buildMockUser({ roles: [] });
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdBy: user }),
);
const json = ctrl.toJson({ includeCreatedBy: true });
expect(json.createdBy).toBeDefined();
expect(json.createdBy.id).toBe("user-1");
});
test("sets _opportunity to null when opportunity not included", () => {
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ opportunity: null }),
);
const json = ctrl.toJson({ includeOpportunity: true });
expect(json.opportunity).toBeUndefined();
});
test("sets _createdBy to null when createdBy not included", () => {
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdBy: null }),
);
const json = ctrl.toJson({ includeCreatedBy: true });
expect(json.createdBy).toBeUndefined();
});
test("handles null createdById", () => {
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdById: null }),
);
expect(ctrl.createdById).toBeNull();
});
});
// -------------------------------------------------------------------
// toJson
// -------------------------------------------------------------------
describe("toJson()", () => {
test("returns core fields by default", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson();
expect(json.id).toBe("quote-1");
expect(json.quoteFileName).toBe("Quote-TestOpp.pdf");
expect(json.opportunityId).toBe("opp-1");
expect(json.createdById).toBe("user-1");
expect(json.createdAt).toBeInstanceOf(Date);
expect(json.updatedAt).toBeInstanceOf(Date);
});
test("excludes quoteFile by default", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson();
expect(json.quoteFile).toBeUndefined();
});
test("excludes quoteRegenData by default", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson();
expect(json.quoteRegenData).toBeUndefined();
});
test("includes quoteRegenData when requested", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson({ includeRegenData: true });
expect(json.quoteRegenData).toEqual({ theme: "default" });
});
test("includes quoteFile as raw Uint8Array when includeFile is true", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson({ includeFile: true });
expect(json.quoteFile).toBeInstanceOf(Uint8Array);
});
test("includes quoteFile as base64 when both flags set", () => {
const ctrl = new GeneratedQuoteController(buildMockGeneratedQuote());
const json = ctrl.toJson({
includeFile: true,
encodeFileAsBase64: true,
});
expect(typeof json.quoteFile).toBe("string");
// Should be valid base64
expect(() => Buffer.from(json.quoteFile, "base64")).not.toThrow();
});
test("excludes opportunity when not requested", () => {
const opp = buildMockOpportunity();
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ opportunity: opp }),
);
const json = ctrl.toJson();
expect(json.opportunity).toBeUndefined();
});
test("includes opportunity when requested and available", () => {
const opp = buildMockOpportunity();
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ opportunity: opp }),
);
const json = ctrl.toJson({ includeOpportunity: true });
expect(json.opportunity).toBeDefined();
expect(json.opportunity.name).toBe("Test Opportunity");
});
test("excludes createdBy when not requested", () => {
const user = buildMockUser({ roles: [] });
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdBy: user }),
);
const json = ctrl.toJson();
expect(json.createdBy).toBeUndefined();
});
test("includes createdBy when requested and available", () => {
const user = buildMockUser({ roles: [] });
const ctrl = new GeneratedQuoteController(
buildMockGeneratedQuote({ createdBy: user }),
);
const json = ctrl.toJson({ includeCreatedBy: true });
expect(json.createdBy).toBeDefined();
expect(json.createdBy.name).toBe("Test User");
});
});
});
+241
View File
@@ -0,0 +1,241 @@
import { describe, test, expect, beforeEach } from "bun:test";
import { Hono } from "hono";
import { apiResponse } from "../../src/modules/api-utils/apiResponse";
import type { ContentfulStatusCode } from "hono/utils/http-status";
/**
* Tests for the CW callback route handler.
*
* We import the route handler and mount it on a Hono app to test via
* the app.request() convenience method.
*/
// We need to test the internal helper functions. Since they are not
// exported, we test them through the route handler's observable behavior.
import callbackRoute from "../../src/api/cw/callback";
describe("CW callback route handler", () => {
let app: Hono;
beforeEach(() => {
app = new Hono();
// Replicate the error handling from server.ts
app.onError((err, c) => {
if ((err as any).status) {
const body = apiResponse.error(err);
return c.json(body, body.status as ContentfulStatusCode);
}
return c.json(apiResponse.internalError(), 500);
});
app.route("/", callbackRoute);
// Clear the env var before each test
delete (process.env as Record<string, any>).CW_CALLBACK_SECRET;
});
// -------------------------------------------------------------------
// Secret validation
// -------------------------------------------------------------------
test("rejects when secret does not match CW_CALLBACK_SECRET", async () => {
process.env.CW_CALLBACK_SECRET = "correct-secret";
const res = await app.request("/callback/wrong-secret/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body.message).toContain("Invalid callback secret");
});
test("accepts when secret matches CW_CALLBACK_SECRET", async () => {
process.env.CW_CALLBACK_SECRET = "correct-secret";
const res = await app.request("/callback/correct-secret/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ Action: "updated", ID: 123 }),
});
expect(res.status).toBe(200);
});
test("accepts any secret when CW_CALLBACK_SECRET is not configured", async () => {
const res = await app.request("/callback/any-secret/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ Action: "created" }),
});
expect(res.status).toBe(200);
});
// -------------------------------------------------------------------
// Resource validation
// -------------------------------------------------------------------
test("accepts 'opportunity' resource", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.resource).toBe("opportunity");
});
test("accepts 'ticket' resource", async () => {
const res = await app.request("/callback/test/ticket", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.resource).toBe("ticket");
});
test("accepts 'company' resource", async () => {
const res = await app.request("/callback/test/company", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.resource).toBe("company");
});
test("accepts 'activity' resource", async () => {
const res = await app.request("/callback/test/activity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.resource).toBe("activity");
});
test("rejects invalid resource type", async () => {
const res = await app.request("/callback/test/invalidtype", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
// Zod validation should fail
expect(res.status).toBeGreaterThanOrEqual(400);
});
// -------------------------------------------------------------------
// Body parsing
// -------------------------------------------------------------------
test("parses JSON body fields", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Action: "updated",
Type: "opportunity",
ID: 42,
MemberId: "jroberts",
MessageId: "msg-123",
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary.action).toBe("updated");
expect(body.data.summary.type).toBe("opportunity");
expect(body.data.summary.id).toBe(42);
expect(body.data.summary.memberId).toBe("jroberts");
expect(body.data.summary.messageId).toBe("msg-123");
});
test("parses Entity field from JSON string", async () => {
const entity = {
CompanyName: "Acme Corp",
StatusName: "Active",
UpdatedBy: "admin",
};
const res = await app.request("/callback/test/company", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Action: "updated",
Entity: JSON.stringify(entity),
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary.entitySummary).toBe("Acme Corp");
expect(body.data.summary.entityStatus).toBe("Active");
expect(body.data.summary.entityUpdatedBy).toBe("admin");
});
test("handles Entity as inline object", async () => {
const res = await app.request("/callback/test/company", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Action: "created",
Entity: { CompanyName: "Direct Corp", Status: "New" },
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary.entitySummary).toBe("Direct Corp");
expect(body.data.summary.entityStatus).toBe("New");
});
test("returns secretValidated field based on env presence", async () => {
delete (process.env as Record<string, any>).CW_CALLBACK_SECRET;
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body = await res.json();
expect(body.data.secretValidated).toBe(false);
process.env.CW_CALLBACK_SECRET = "secret";
const res2 = await app.request("/callback/secret/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body2 = await res2.json();
expect(body2.data.secretValidated).toBe(true);
});
test("returns receivedAt timestamp", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const body = await res.json();
expect(body.data.receivedAt).toBeDefined();
// Should be a valid ISO date string
expect(new Date(body.data.receivedAt).toISOString()).toBe(
body.data.receivedAt,
);
});
test("handles non-JSON body gracefully", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: "this is not json",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary).toBeNull();
});
test("handles empty body gracefully", async () => {
const res = await app.request("/callback/test/opportunity", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "",
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary).toBeNull();
});
});
+184
View File
@@ -0,0 +1,184 @@
import { describe, test, expect, mock } from "bun:test";
import { attachCwConcurrencyLimiter } from "../../src/modules/cw-utils/cwConcurrencyLimiter";
/**
* Build a minimal fake Axios instance with interceptor registration.
* Collect registered interceptors so we can invoke them in tests.
*/
function createMockAxios() {
const requestHandlers: Array<(config: any) => any> = [];
const responseSuccessHandlers: Array<(res: any) => any> = [];
const responseErrorHandlers: Array<(err: any) => any> = [];
return {
interceptors: {
request: {
use(fn: (config: any) => any) {
requestHandlers.push(fn);
},
},
response: {
use(onSuccess: (res: any) => any, onError: (err: any) => any) {
responseSuccessHandlers.push(onSuccess);
responseErrorHandlers.push(onError);
},
},
},
_requestHandlers: requestHandlers,
_responseSuccessHandlers: responseSuccessHandlers,
_responseErrorHandlers: responseErrorHandlers,
};
}
describe("attachCwConcurrencyLimiter", () => {
test("attaches request and response interceptors", () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any);
expect(api._requestHandlers).toHaveLength(1);
expect(api._responseSuccessHandlers).toHaveLength(1);
expect(api._responseErrorHandlers).toHaveLength(1);
});
test("request interceptor resolves immediately when under limit", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 2);
const config = { url: "/test" };
const result = await api._requestHandlers[0](config);
expect(result).toEqual(config);
});
test("response success interceptor passes through response", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 2);
// Acquire a slot first
await api._requestHandlers[0]({});
const response = { data: "ok", status: 200 };
const result = api._responseSuccessHandlers[0](response);
expect(result).toEqual(response);
});
test("response error interceptor rejects with the error and releases slot", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 2);
// Acquire a slot
await api._requestHandlers[0]({});
const error = new Error("fail");
try {
await api._responseErrorHandlers[0](error);
expect(true).toBe(false); // should not reach
} catch (e) {
expect(e).toBe(error);
}
});
test("queues requests when at max concurrency", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 1);
// First request acquires the single slot
await api._requestHandlers[0]({ id: 1 });
// Second request should be queued (not resolved yet)
let secondResolved = false;
const secondPromise = api._requestHandlers[0]({ id: 2 }).then(
(config: any) => {
secondResolved = true;
return config;
},
);
// Give the event loop a tick — second should still be pending
await new Promise((r) => setTimeout(r, 10));
expect(secondResolved).toBe(false);
// Release the first slot via response handler
api._responseSuccessHandlers[0]({ status: 200 });
// Now the second should resolve
const result = await secondPromise;
expect(secondResolved).toBe(true);
expect(result).toEqual({ id: 2 });
});
test("multiple requests under limit all proceed immediately", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 3);
const results = await Promise.all([
api._requestHandlers[0]({ id: 1 }),
api._requestHandlers[0]({ id: 2 }),
api._requestHandlers[0]({ id: 3 }),
]);
expect(results).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
});
test("FIFO ordering: queued requests resolve in order", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 1);
// Fill the single slot
await api._requestHandlers[0]({ id: 1 });
const order: number[] = [];
const p2 = api._requestHandlers[0]({ id: 2 }).then(() => order.push(2));
const p3 = api._requestHandlers[0]({ id: 3 }).then(() => order.push(3));
// Release slot → should wake request 2
api._responseSuccessHandlers[0]({});
await p2;
// Release again → should wake request 3
api._responseSuccessHandlers[0]({});
await p3;
expect(order).toEqual([2, 3]);
});
test("error release also unblocks queued requests", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any, 1);
await api._requestHandlers[0]({ id: 1 });
let secondResolved = false;
const secondPromise = api._requestHandlers[0]({ id: 2 }).then(() => {
secondResolved = true;
});
// Release via error path
try {
await api._responseErrorHandlers[0](new Error("fail"));
} catch {}
await secondPromise;
expect(secondResolved).toBe(true);
});
test("defaults to max 6 concurrency", async () => {
const api = createMockAxios();
attachCwConcurrencyLimiter(api as any); // default max = 6
// 6 requests should all proceed immediately
const promises = [];
for (let i = 0; i < 6; i++) {
promises.push(api._requestHandlers[0]({ id: i }));
}
const results = await Promise.all(promises);
expect(results).toHaveLength(6);
// 7th should queue
let seventhResolved = false;
const seventh = api._requestHandlers[0]({ id: 7 }).then(() => {
seventhResolved = true;
});
await new Promise((r) => setTimeout(r, 10));
expect(seventhResolved).toBe(false);
// Release one to unblock
api._responseSuccessHandlers[0]({});
await seventh;
expect(seventhResolved).toBe(true);
});
});
+261
View File
@@ -0,0 +1,261 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { GeneratedQuoteController } from "../../src/controllers/GeneratedQuoteController";
import {
buildMockGeneratedQuote,
buildMockOpportunity,
buildMockUser,
} from "../setup";
/**
* The Proxy-based prisma mock in setup.ts creates a fresh mock() for every
* property access, so we cannot use `(prisma.x.findFirst as any).mockReturnValueOnce()`
* because the manager's import access gets a different mock.
*
* Instead, we mock the entire constants module per-test with stable mock functions.
*/
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy(
{},
{
get() {
return mock(() => Promise.resolve(null));
},
},
);
},
},
);
}
describe("generatedQuotes manager", () => {
// -------------------------------------------------------------------
// fetch
// -------------------------------------------------------------------
describe("fetch()", () => {
test("returns a GeneratedQuoteController when found", async () => {
const mockData = buildMockGeneratedQuote();
const findFirst = mock(() => Promise.resolve(mockData));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findFirst },
}),
}));
// Re-import to pick up the fresh mock
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.fetch("quote-1");
expect(result).toBeInstanceOf(GeneratedQuoteController);
expect(result.id).toBe("quote-1");
});
test("throws GenericError with 404 when not found", async () => {
const findFirst = mock(() => Promise.resolve(null));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findFirst },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
try {
await generatedQuotes.fetch("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("GeneratedQuoteNotFound");
expect(e.status).toBe(404);
}
});
});
// -------------------------------------------------------------------
// fetchByOpportunity
// -------------------------------------------------------------------
describe("fetchByOpportunity()", () => {
test("returns array of GeneratedQuoteController", async () => {
const rows = [
buildMockGeneratedQuote({ id: "q-1" }),
buildMockGeneratedQuote({ id: "q-2" }),
];
const findMany = mock(() => Promise.resolve(rows));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findMany },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.fetchByOpportunity("opp-1");
expect(result).toHaveLength(2);
expect(result[0]).toBeInstanceOf(GeneratedQuoteController);
expect(result[0].id).toBe("q-1");
});
test("returns empty array when none found", async () => {
const findMany = mock(() => Promise.resolve([]));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findMany },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.fetchByOpportunity("opp-999");
expect(result).toEqual([]);
});
});
// -------------------------------------------------------------------
// fetchByCreator
// -------------------------------------------------------------------
describe("fetchByCreator()", () => {
test("returns array of GeneratedQuoteController", async () => {
const rows = [buildMockGeneratedQuote({ id: "q-1" })];
const findMany = mock(() => Promise.resolve(rows));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findMany },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.fetchByCreator("user-1");
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(GeneratedQuoteController);
});
});
// -------------------------------------------------------------------
// create
// -------------------------------------------------------------------
describe("create()", () => {
test("throws 404 when opportunity not found", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
opportunity: { findFirst: mock(() => Promise.resolve(null)) },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
try {
await generatedQuotes.create({
quoteRegenData: {},
quoteFile: Buffer.from("pdf"),
quoteFileName: "test.pdf",
opportunityId: "nonexistent-opp",
createdById: "user-1",
});
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("OpportunityNotFound");
expect(e.status).toBe(404);
}
});
test("throws 404 when user not found", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
opportunity: {
findFirst: mock(() => Promise.resolve({ id: "opp-1" })),
},
user: { findFirst: mock(() => Promise.resolve(null)) },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
try {
await generatedQuotes.create({
quoteRegenData: {},
quoteFile: Buffer.from("pdf"),
quoteFileName: "test.pdf",
opportunityId: "opp-1",
createdById: "nonexistent-user",
});
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("UserNotFound");
expect(e.status).toBe(404);
}
});
test("creates and returns GeneratedQuoteController", async () => {
const mockQuote = buildMockGeneratedQuote();
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
opportunity: {
findFirst: mock(() => Promise.resolve({ id: "opp-1" })),
},
user: { findFirst: mock(() => Promise.resolve({ id: "user-1" })) },
generatedQuotes: { create: mock(() => Promise.resolve(mockQuote)) },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
const result = await generatedQuotes.create({
quoteRegenData: { theme: "default" },
quoteFile: Buffer.from("pdf-content"),
quoteFileName: "Quote.pdf",
opportunityId: "opp-1",
createdById: "user-1",
});
expect(result).toBeInstanceOf(GeneratedQuoteController);
expect(result.id).toBe("quote-1");
});
});
// -------------------------------------------------------------------
// delete
// -------------------------------------------------------------------
describe("delete()", () => {
test("throws 404 when quote not found", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: { findFirst: mock(() => Promise.resolve(null)) },
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
try {
await generatedQuotes.delete("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("GeneratedQuoteNotFound");
expect(e.status).toBe(404);
}
});
test("deletes successfully when quote exists", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
generatedQuotes: {
findFirst: mock(() => Promise.resolve({ id: "quote-1" })),
delete: mock(() => Promise.resolve(undefined)),
},
}),
}));
const { generatedQuotes } =
await import("../../src/managers/generatedQuotes");
await expect(generatedQuotes.delete("quote-1")).resolves.toBeUndefined();
});
});
});
+8
View File
@@ -32,9 +32,17 @@ describe("PermissionNodes", () => {
expect(PERMISSION_NODES).toHaveProperty("global"); expect(PERMISSION_NODES).toHaveProperty("global");
expect(PERMISSION_NODES).toHaveProperty("company"); expect(PERMISSION_NODES).toHaveProperty("company");
expect(PERMISSION_NODES).toHaveProperty("credential"); expect(PERMISSION_NODES).toHaveProperty("credential");
expect(PERMISSION_NODES).toHaveProperty("credentialType");
expect(PERMISSION_NODES).toHaveProperty("sales"); expect(PERMISSION_NODES).toHaveProperty("sales");
expect(PERMISSION_NODES).toHaveProperty("procurement"); expect(PERMISSION_NODES).toHaveProperty("procurement");
expect(PERMISSION_NODES).toHaveProperty("objectTypes"); expect(PERMISSION_NODES).toHaveProperty("objectTypes");
expect(PERMISSION_NODES).toHaveProperty("permission");
expect(PERMISSION_NODES).toHaveProperty("role");
expect(PERMISSION_NODES).toHaveProperty("user");
expect(PERMISSION_NODES).toHaveProperty("uiNavigation");
expect(PERMISSION_NODES).toHaveProperty("adminUI");
expect(PERMISSION_NODES).toHaveProperty("cwCallbacks");
expect(PERMISSION_NODES).toHaveProperty("unifi");
}); });
test("each category has name, description, and permissions", () => { test("each category has name, description, and permissions", () => {

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