Compare commits

..

8 Commits

Author SHA1 Message Date
HoloPanio c0a4d4f919 feat: add CW members, opportunity create/update, and integrator interceptor 2026-03-07 18:15:17 -06:00
HoloPanio 0ce1eda606 fix: add missing GeneratedQuotes columns migration 2026-03-07 00:14:26 -06:00
HoloPanio 6c310ed753 fix: add missing probability column migration for Opportunity 2026-03-07 00:07:10 -06:00
HoloPanio 1907bb433b feat: restructure sales, add PDF quote generation and WebSocket support 2026-03-06 23:25:37 -06:00
HoloPanio 4efca6cc53 Add sales item labor/product route updates and permission docs 2026-03-04 18:43:54 -06:00
HoloPanio d5c22c8eff Add special-order product flow for sales opportunities 2026-03-04 00:11:40 -06:00
HoloPanio a048e1e824 feat: add CW callback route and optimize cache refresh workflows 2026-03-03 19:46:48 -06:00
HoloPanio 6d935e7180 feat: Redis opportunity cache, CW API retry/logging, adaptive TTLs
- Add Redis-backed opportunity cache with background refresh (30s interval)
- Fix concurrency bug: use lazy thunks instead of eager promises for batching
- Add withCwRetry utility with exponential backoff for transient CW errors
- Add adaptive TTL algorithms (primary, sub-resource, products) based on opportunity activity
- Add include query param on GET /sales/opportunities/:id (notes,contacts,products)
- Add opt-in CW API logger (LOG_CW_API env var) with timestamped files in cw-api-logs/
- Add debug-scripts/analyze-cw-calls.py for API call analysis
- Add computeSubResourceCacheTTL and computeProductsCacheTTL algorithms with tests
- Increase CW API timeout from 15s to 30s
- Unblock cache refresh from startup chain (remove await)
- Prioritize recently updated opportunities in refresh cycle
- Add CACHING.md documentation
- Update API_ROUTES.md with caching details and include param
- Update copilot instructions to require CACHING.md sync
- Add dev:log script for CW API call logging during development
2026-03-02 23:23:24 -06:00
119 changed files with 17733 additions and 459 deletions
+5 -1
View File
@@ -198,7 +198,11 @@ Whenever you add, remove, or modify API routes or permission nodes, you **must**
2. `PERMISSIONS.md` — human-readable documentation of all permission nodes; must strictly reflect the data in `PermissionNodes.ts`. 2. `PERMISSIONS.md` — human-readable documentation of all permission nodes; must strictly reflect the data in `PermissionNodes.ts`.
3. `API_ROUTES.md` — comprehensive documentation of all API routes, including method, path, auth requirements, permissions, request/response examples. 3. `API_ROUTES.md` — comprehensive documentation of all API routes, including method, path, auth requirements, permissions, request/response examples.
Always verify that new routes have their required permissions listed in `PermissionNodes.ts`, that `PERMISSIONS.md` tables match the TS file exactly, and that `API_ROUTES.md` includes full documentation for every mounted route. Run through all three files at the end of any route or permission change to catch discrepancies. Additionally, whenever you add, remove, or modify **caching logic** (TTL algorithms, cache key patterns, background refresh mechanics, retry settings, or invalidation behavior), you **must** update:
4. `CACHING.md` — comprehensive documentation of the Redis-backed opportunity cache, TTL algorithms, background refresh mechanics, retry logic, and debugging tools.
Always verify that new routes have their required permissions listed in `PermissionNodes.ts`, that `PERMISSIONS.md` tables match the TS file exactly, that `API_ROUTES.md` includes full documentation for every mounted route, and that `CACHING.md` accurately reflects any caching changes. Run through all relevant files at the end of any route, permission, or caching change to catch discrepancies.
--- ---
+2
View File
@@ -1,6 +1,8 @@
# Logs # Logs
logs logs
*.log *.log
*.jsonl
cw-api-logs/
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
+1091 -9
View File
File diff suppressed because it is too large Load Diff
+348
View File
@@ -0,0 +1,348 @@
# Caching Architecture
This document describes the caching layer used in the Optima API, covering the Redis-backed opportunity cache, TTL algorithms, background refresh mechanics, retry logic, and debugging tools.
---
## Overview
The API caches expensive ConnectWise (CW) API responses in **Redis** to reduce latency and avoid CW rate limits. The primary cache layer is the **opportunity cache** (`src/modules/cache/opportunityCache.ts`), which proactively warms data for all non-closed opportunities on a background interval.
### Key design principles
- **Adaptive TTLs** — cache durations are computed dynamically based on how "hot" an opportunity is (recently updated = shorter TTL = fresher data).
- **Background refresh** — a 20-minute interval scans all open opportunities and re-fetches only expired cache keys.
- **Bounded concurrency** — CW API calls are throttled via thunk-based batching to prevent overwhelming the upstream API.
- **Graceful degradation** — transient CW errors (timeouts, network failures) are caught, logged, and retried on the next cycle rather than crashing the process.
- **Priority ordering** — most recently updated opportunities are refreshed first so active deals get fresh data before stale ones.
---
## What is cached
Each non-closed opportunity can have up to 7 cached payloads in Redis:
| Cache Key Pattern | Data | Source |
| ----------------------------------- | ------------------------------------ | --------------------------------------------------------------------- |
| `opp:cw-data:{cwOpportunityId}` | Raw CW opportunity response | `GET /sales/opportunities/:id` |
| `opp:activities:{cwOpportunityId}` | CW activities array | `GET /sales/activities?conditions=opportunity/id=:id` |
| `opp:notes:{cwOpportunityId}` | CW notes array | `GET /sales/opportunities/:id/notes` |
| `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:company-cw:{cw_CompanyId}` | Hydrated company + contacts blob | `GET /company/companies/:id` + contacts endpoints |
| `opp:site:{cwCompanyId}:{cwSiteId}` | Company site data | `GET /company/companies/:id/sites/:siteId` |
Inventory-adjustment-driven catalog sync adds a targeted product cache:
| Cache Key Pattern | Data | Source |
| ------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `catalog:item:cw:{cwId}` | Full CW catalog item + computed `onHand` + DB row snapshot | `GET /procurement/adjustments` + `GET /procurement/catalog/:id` + catalog inventory endpoint |
---
## TTL Algorithms
Three algorithms compute cache TTLs. All share the same input signals:
- `closedFlag` — whether the opportunity is closed
- `closedDate` — when it was closed
- `expectedCloseDate` — projected close date (forward-looking signal)
- `lastUpdated` — last CW modification date (backward-looking signal)
### Primary TTL (`computeCacheTTL`)
**File:** `src/modules/algorithms/computeCacheTTL.ts`
Used for: opportunity CW data, activities, company CW data.
| # | Condition | TTL | Human |
| --- | ------------------------------------------------------- | ---------- | ------------ |
| 1a | Closed > 30 days ago | `null` | Do not cache |
| 1b | Closed within 30 days | 900,000 ms | 15 minutes |
| 2 | `expectedCloseDate` or `lastUpdated` within **5 days** | 30,000 ms | 30 seconds |
| 3 | `expectedCloseDate` or `lastUpdated` within **14 days** | 60,000 ms | 60 seconds |
| 4 | Everything else | 900,000 ms | 15 minutes |
Rules are evaluated top-to-bottom; first match wins.
### Sub-Resource TTL (`computeSubResourceCacheTTL`)
**File:** `src/modules/algorithms/computeSubResourceCacheTTL.ts`
Used for: notes, contacts.
| # | Condition | TTL | Human |
| --- | --------------------- | ---------- | ------------ |
| 1a | Closed > 30 days ago | `null` | Do not cache |
| 1b | Closed within 30 days | 300,000 ms | 5 minutes |
| 2 | Within **5 days** | 60,000 ms | 60 seconds |
| 3 | Within **14 days** | 120,000 ms | 2 minutes |
| 4 | Everything else | 300,000 ms | 5 minutes |
### Products TTL (`computeProductsCacheTTL`)
**File:** `src/modules/algorithms/computeProductsCacheTTL.ts`
Used for: forecast + procurement products.
| # | Condition | TTL | Human |
| --- | ------------------------------------------- | ------------ | ---------- |
| 1 | Status is Won/Lost/Pending Won/Pending Lost | `null` | No cache |
| 2 | Main cache TTL is `null` | `null` | No cache |
| 3 | `lastUpdated` within **3 days** | 15,000 ms | 15 seconds |
| 4 | Everything else | 1,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 20 minutes.
### Site TTL
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.
---
## Background Refresh
**Function:** `refreshOpportunityCache()` in `src/modules/cache/opportunityCache.ts`
**Interval:** Every 20 minutes, triggered from `src/index.ts`.
### Refresh cycle
1. **Query DB** — fetch all non-closed opportunities + recently closed (within 30 days), ordered by `cwLastUpdated DESC` (most recently active first).
2. **Batch EXISTS check** — use a single Redis pipeline to check which cache keys already exist (5 EXISTS commands per opportunity: oppCwData, activities, notes, contacts, products).
3. **Build thunk list** — for each opportunity with missing keys, push a **thunk** (lazy function) into the task list. No HTTP requests fire at this point.
4. **Execute with bounded concurrency** — process thunks in batches of `CONCURRENCY` (currently **6**), with a `BATCH_DELAY_MS` (currently **250ms**) pause between batches. Each thunk is only invoked inside the batch loop.
5. **Emit events**`cache:opportunities:refresh:started` and `cache:opportunities:refresh:completed` events are emitted for the event debugger.
### Inventory-adjustment listener cycle
**Function:** `listenInventoryAdjustments()` in `src/modules/cw-utils/procurement/listenInventoryAdjustments.ts`
**Interval:** Every 60 seconds, triggered from `src/index.ts`.
1. Fetch `GET /procurement/adjustments?pageSize=1000`.
2. Build a normalized snapshot of tracked inventory rows (`cwCatalogId`, `onHand`, `inventory`) per adjustment.
3. Compare to previous snapshot; extract only changed product IDs.
4. For each changed product ID, fetch fresh CW catalog item + current on-hand.
5. Upsert `CatalogItem` in Postgres and write Redis key `catalog:item:cw:{cwId}` with a 20-minute TTL.
Guardrails to prevent request storms:
- Diffing is computed at **product state** level (grouped by `cwCatalogId`), not raw adjustment-row churn.
- Per-cycle syncs are capped (`CW_ADJUSTMENT_SYNC_MAX_PER_CYCLE`, default `50`).
- Product resync cooldown is enforced (`CW_ADJUSTMENT_SYNC_COOLDOWN_MS`, default `600000` ms / 10 min).
This avoids full-catalog sweeps for small inventory movements and updates only the products implicated by adjustments.
### Full procurement catalog refresh
**Function:** `refreshCatalog()` in `src/modules/cw-utils/procurement/refreshCatalog.ts`
**Interval:** Every 30 minutes, triggered from `src/index.ts`.
The full catalog cache/DB sync uses the same slow-parallel thunk strategy as opportunity cache refreshes:
- Build arrays of thunk tasks (`() => Promise<void>`) for CW item fetches, inventory fetches, and DB upserts.
- Execute with bounded concurrency (`CONCURRENCY=6`).
- Pause between batches (`BATCH_DELAY_MS=250`) to avoid CW burst pressure.
- Log task failures and retry naturally on the next cycle.
This keeps full-catalog refresh conservative while inventory-adjustment listener handles near-real-time targeted updates.
### Full inventory sweep fallback
`refreshInventory()` remains as a safety net but is intentionally infrequent:
- Runs every **6 hours** from `src/index.ts` (no startup-time full sweep).
- Uses the same slow-parallel pattern (`CONCURRENCY=6`, `BATCH_DELAY_MS=250`) to avoid burst traffic.
Most on-hand freshness now comes from the 60-second adjustment listener plus 30-minute full catalog refresh.
### Concurrency control
The thunk pattern is critical. Previously, tasks were pushed as already-executing promises (`refreshTasks.push(fetchAndCache(...))`), which meant all HTTP requests fired simultaneously regardless of the batching loop. The fix was changing the array type from `Promise<void>[]` to `(() => Promise<void>)[]` so requests only start when explicitly invoked: `batch.map((fn) => fn())`.
### Current tuning
| Parameter | Value | Effect |
| ---------------- | ---------- | ------------------------------------------ |
| `CONCURRENCY` | 6 | Max simultaneous CW API requests per batch |
| `BATCH_DELAY_MS` | 250 | Milliseconds between batches |
| 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.
---
## Retry Logic (`withCwRetry`)
**File:** `src/modules/cw-utils/withCwRetry.ts`
Wraps CW API calls with exponential backoff retry on transient errors.
### Retryable errors
- `ECONNABORTED` (timeout)
- `ECONNRESET`
- `ETIMEDOUT`
- `ECONNREFUSED`
- `ERR_NETWORK`
- `ENETUNREACH`
- HTTP 5xx server errors
### Default configuration
| Parameter | Default | Description |
| ------------- | ------- | ----------------------------------------------------------- |
| `maxAttempts` | 3 | Total attempts including the first |
| `baseDelayMs` | 1,000 | Delay before first retry (doubles each retry: 1s → 2s → 4s) |
| `label` | — | Optional tag for log messages |
### Usage
```ts
import { withCwRetry } from "./withCwRetry";
const response = await withCwRetry(
() => connectWiseApi.get(`/company/companies/${id}`),
{ label: `fetchCompany#${id}`, maxAttempts: 3, baseDelayMs: 1_500 },
);
```
Non-transient errors (404, 400, etc.) are re-thrown immediately without retry.
---
## CW API Logger
**File:** `src/modules/cw-utils/cwApiLogger.ts`
Axios interceptor that logs every CW API call to a JSONL file. Logging is **opt-in** — set the `LOG_CW_API` environment variable to enable it. Each process start creates a new timestamped file in the `cw-api-logs/` directory (e.g., `cw-api-logs/2026-03-02T14-30-05.123Z.jsonl`).
### Enabling logging
```bash
# Via the dev:log shorthand script
bun run dev:log
# Or manually with any command
LOG_CW_API=1 bun run dev
```
### Log entry fields
| Field | Type | Description |
| ------------ | ----------------- | ----------------------------------- |
| `timestamp` | string (ISO-8601) | When the request completed |
| `method` | string | HTTP method |
| `url` | string | Request URL (relative or absolute) |
| `baseURL` | string | Axios baseURL |
| `status` | number \| null | HTTP status (null on network error) |
| `durationMs` | number | Wall-clock time in milliseconds |
| `error` | string \| null | Error code + message, if any |
| `timeout` | number | Configured timeout in ms |
### Analysis
Run the analyzer script to analyze the most recent log file:
```bash
bun run utils:analyze_cw
```
Or specify a particular file:
```bash
python3 debug-scripts/analyze-cw-calls.py cw-api-logs/2026-03-02T14-30-05.123Z.jsonl
```
This executes `debug-scripts/analyze-cw-calls.py` which produces:
- Overview (total calls, error rate, time span)
- Duration statistics (min, max, mean, p50, p90, p95, p99, distribution histogram)
- Error breakdown by type and endpoint
- Top 20 slowest calls
- Per-endpoint stats (count, errors, mean, p50, p95, max, total time)
- Timeline (per-minute throughput and errors)
- Concurrency hotspot detection
- Summary with recommendations
To clear all logs:
```bash
rm -rf cw-api-logs/
```
---
## Cache Invalidation
Mutation endpoints invalidate the relevant cache keys so the next read fetches fresh data from CW:
| Mutation | Cache invalidated |
| ------------------------------ | ---------------------------------------------------------------- |
| Create/update/delete note | `opp:notes:{cwOpportunityId}` via `invalidateNotesCache()` |
| Create/update/delete contact | `opp:contacts:{cwOpportunityId}` via `invalidateContactsCache()` |
| Add/update/resequence products | `opp:products:{cwOpportunityId}` via `invalidateProductsCache()` |
| Refresh opportunity | All keys for that opportunity (via re-fetch) |
---
## ConnectWise API Configuration
The shared Axios instance (`connectWiseApi`) is configured in `src/constants.ts`:
| Setting | Value | Purpose |
| --------- | ---------------------------------------------------- | ------------------------------ |
| `baseURL` | `https://ttscw.totaltech.net/v4_6_release/apis/3.0/` | CW API base |
| `timeout` | 30,000 ms (30s) | Per-request timeout |
| Logger | `attachCwApiLogger()` | Writes to `cw-api-calls.jsonl` |
---
## Architecture diagram
```
src/index.ts
├─ setInterval(refreshOpportunityCache, 20m)
└─► src/modules/cache/opportunityCache.ts
├─ prisma.opportunity.findMany(orderBy: cwLastUpdated DESC)
├─ redis.pipeline().exists(...) ← batch key check
├─ Build thunk list (lazy functions)
└─ Execute thunks with CONCURRENCY=6, DELAY=250ms
├─► fetchAndCacheOppCwData() ─► opportunityCw.fetch()
├─► fetchAndCacheActivities() ─► activityCw.fetchByOpportunityDirect()
├─► fetchAndCacheNotes() ─► opportunityCw.fetchNotes()
├─► fetchAndCacheContacts() ─► opportunityCw.fetchContacts()
├─► fetchAndCacheProducts() ─► opportunityCw.fetchProducts() + fetchProcurementProducts()
├─► fetchAndCacheCompanyCwData() ─► fetchCwCompanyById() + contacts
└─► fetchAndCacheSite() ─► fetchCompanySite() (lazy only)
└─► connectWiseApi.get(...) ← withCwRetry + cwApiLogger interceptors
└─► Redis SET with computed TTL
```
---
## File reference
| File | Purpose |
| ---------------------------------------------------------------- | ------------------------------------------------------------- |
| `src/modules/cache/opportunityCache.ts` | Cache read/write helpers, background refresh logic |
| `src/modules/algorithms/computeCacheTTL.ts` | Primary adaptive TTL algorithm |
| `src/modules/algorithms/computeSubResourceCacheTTL.ts` | Sub-resource (notes, contacts) TTL algorithm |
| `src/modules/algorithms/computeProductsCacheTTL.ts` | Products TTL algorithm |
| `src/modules/cw-utils/withCwRetry.ts` | Retry wrapper with exponential backoff |
| `src/modules/cw-utils/cwApiLogger.ts` | Axios interceptor for JSONL call logging |
| `src/modules/cw-utils/fetchCompany.ts` | Company fetch with retry |
| `src/modules/cw-utils/procurement/listenInventoryAdjustments.ts` | Adjustment listener for targeted catalog-item cache + DB sync |
| `src/constants.ts` | CW Axios instance config (timeout, logger) |
| `src/index.ts` | Refresh interval registration |
| `debug-scripts/analyze-cw-calls.py` | CW API call analysis script |
+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=="],
} }
} }
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""
Analyze ConnectWise API call logs.
Looks for the most recent log file in cw-api-logs/ by default,
or accepts an explicit path as an argument.
Usage:
python3 analyze-cw-calls.py # latest file in cw-api-logs/
python3 analyze-cw-calls.py cw-api-logs/specific.jsonl
"""
import json
import sys
import os
import glob
import statistics
from collections import Counter, defaultdict
from datetime import datetime, timedelta
# ── Colours ──────────────────────────────────────────────────────────────────
RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
def colour_duration(ms: float) -> str:
if ms >= 10_000:
return f"{RED}{ms:,.0f}ms{RESET}"
if ms >= 5_000:
return f"{YELLOW}{ms:,.0f}ms{RESET}"
return f"{GREEN}{ms:,.0f}ms{RESET}"
def header(title: str) -> str:
return f"\n{BOLD}{CYAN}{'' * 60}\n {title}\n{'' * 60}{RESET}"
# ── Resolve log file ────────────────────────────────────────────────────────
def find_latest_log() -> str:
"""Find the most recent .jsonl file in cw-api-logs/."""
log_dir = os.path.join(os.getcwd(), "cw-api-logs")
files = sorted(glob.glob(os.path.join(log_dir, "*.jsonl")))
if not files:
print(f"{RED}No log files found in cw-api-logs/{RESET}")
print(f"Run {BOLD}bun run dev:log{RESET} to start logging CW API calls.")
sys.exit(1)
return files[-1]
if len(sys.argv) > 1:
log_path = sys.argv[1]
else:
log_path = find_latest_log()
print(f"{DIM}Reading: {log_path}{RESET}")
entries = []
parse_errors = 0
with open(log_path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
parse_errors += 1
if not entries:
print("No entries found. Check the log file path.")
sys.exit(1)
# ── Derived fields ───────────────────────────────────────────────────────────
durations = [e["durationMs"] for e in entries]
errors = [e for e in entries if e.get("error")]
successes = [e for e in entries if not e.get("error")]
timestamps = [datetime.fromisoformat(e["timestamp"].replace("Z", "+00:00")) for e in entries]
time_span = (timestamps[-1] - timestamps[0]) if len(timestamps) > 1 else timedelta(0)
# Normalise the URL to a route pattern for grouping
def normalise_url(url: str) -> str:
parts = url.split("?")[0].rstrip("/").split("/")
normalised = []
for p in parts:
if p.isdigit():
normalised.append(":id")
else:
normalised.append(p)
return "/".join(normalised)
# ── 1. Overview ──────────────────────────────────────────────────────────────
print(header("OVERVIEW"))
print(f" Log file : {log_path}")
print(f" Total calls : {BOLD}{len(entries):,}{RESET}")
print(f" Successes : {GREEN}{len(successes):,}{RESET}")
print(f" Failures : {RED}{len(errors):,}{RESET} ({len(errors)/len(entries)*100:.1f}%)")
print(f" Time span : {time_span}")
if time_span.total_seconds() > 0:
rps = len(entries) / time_span.total_seconds()
print(f" Avg req/sec : {rps:.2f}")
if parse_errors:
print(f" Parse errors : {YELLOW}{parse_errors}{RESET}")
# ── 2. Duration stats ───────────────────────────────────────────────────────
print(header("DURATION STATS (all calls)"))
sorted_dur = sorted(durations)
p50 = sorted_dur[len(sorted_dur) * 50 // 100]
p90 = sorted_dur[len(sorted_dur) * 90 // 100]
p95 = sorted_dur[len(sorted_dur) * 95 // 100]
p99 = sorted_dur[len(sorted_dur) * 99 // 100]
print(f" Min : {colour_duration(min(durations))}")
print(f" Max : {colour_duration(max(durations))}")
print(f" Mean : {colour_duration(statistics.mean(durations))}")
print(f" Median (p50) : {colour_duration(p50)}")
print(f" p90 : {colour_duration(p90)}")
print(f" p95 : {colour_duration(p95)}")
print(f" p99 : {colour_duration(p99)}")
print(f" Std dev : {statistics.stdev(durations):,.0f}ms" if len(durations) > 1 else "")
# Duration buckets
buckets = {"<500ms": 0, "500ms-1s": 0, "1-3s": 0, "3-5s": 0, "5-10s": 0, "10-20s": 0, "20s+": 0}
for d in durations:
if d < 500: buckets["<500ms"] += 1
elif d < 1000: buckets["500ms-1s"] += 1
elif d < 3000: buckets["1-3s"] += 1
elif d < 5000: buckets["3-5s"] += 1
elif d < 10000: buckets["5-10s"] += 1
elif d < 20000: buckets["10-20s"] += 1
else: buckets["20s+"] += 1
print(f"\n {BOLD}Distribution:{RESET}")
max_bar = 40
max_count = max(buckets.values()) if buckets else 1
for label, count in buckets.items():
bar_len = int(count / max_count * max_bar) if max_count else 0
pct = count / len(durations) * 100
bar = "" * bar_len
clr = GREEN if "500" in label or "<" in label else (YELLOW if "1-3" in label or "3-5" in label else RED)
print(f" {label:>10s} {clr}{bar}{RESET} {count:>5,} ({pct:5.1f}%)")
# ── 3. Errors breakdown ─────────────────────────────────────────────────────
print(header("ERROR BREAKDOWN"))
if not errors:
print(f" {GREEN}No errors! 🎉{RESET}")
else:
error_codes = Counter()
for e in errors:
err_str = e.get("error", "unknown")
code = err_str.split(":")[0] if ":" in err_str else err_str
error_codes[code] += 1
for code, count in error_codes.most_common():
print(f" {RED}{code:<30s}{RESET} {count:>5,} ({count/len(entries)*100:.1f}%)")
# Errored URLs
errored_urls = Counter(normalise_url(e["url"]) for e in errors)
print(f"\n {BOLD}Top errored endpoints:{RESET}")
for url, count in errored_urls.most_common(10):
print(f" {count:>5,} {url}")
# ── 4. Slowest individual calls ─────────────────────────────────────────────
print(header("TOP 20 SLOWEST CALLS"))
slowest = sorted(entries, key=lambda e: e["durationMs"], reverse=True)[:20]
for i, e in enumerate(slowest, 1):
status = e.get("status") or f"{RED}ERR{RESET}"
err_tag = f" {RED}[{e['error'].split(':')[0]}]{RESET}" if e.get("error") else ""
print(f" {i:>2}. {colour_duration(e['durationMs']):>20s} {e['method']:>4s} {e['url'][:60]:<60s} {DIM}{status}{RESET}{err_tag}")
# ── 5. Per-endpoint stats ───────────────────────────────────────────────────
print(header("PER-ENDPOINT STATS (by route pattern)"))
by_route = defaultdict(list)
for e in entries:
route = normalise_url(e["url"])
by_route[route].append(e)
# Sort by total time spent descending (most impactful)
route_stats = []
for route, calls in by_route.items():
durs = [c["durationMs"] for c in calls]
errs = sum(1 for c in calls if c.get("error"))
sorted_d = sorted(durs)
route_stats.append({
"route": route,
"count": len(calls),
"errors": errs,
"total_ms": sum(durs),
"mean": statistics.mean(durs),
"p50": sorted_d[len(sorted_d) * 50 // 100],
"p95": sorted_d[len(sorted_d) * 95 // 100],
"max": max(durs),
})
route_stats.sort(key=lambda r: r["total_ms"], reverse=True)
print(f" {'Route':<55s} {'Count':>6s} {'Errs':>5s} {'Mean':>8s} {'p50':>8s} {'p95':>8s} {'Max':>8s} {'Total':>10s}")
print(f" {'' * 55} {'' * 6} {'' * 5} {'' * 8} {'' * 8} {'' * 8} {'' * 8} {'' * 10}")
for r in route_stats[:25]:
err_str = f"{RED}{r['errors']}{RESET}" if r['errors'] else f"{DIM}0{RESET}"
print(
f" {r['route']:<55s} {r['count']:>6,} {err_str:>14s} "
f"{r['mean']:>7,.0f}ms {r['p50']:>7,.0f}ms {r['p95']:>7,.0f}ms "
f"{r['max']:>7,.0f}ms {r['total_ms']/1000:>8,.1f}s"
)
# ── 6. HTTP method breakdown ────────────────────────────────────────────────
print(header("BY HTTP METHOD"))
by_method = defaultdict(list)
for e in entries:
by_method[e["method"]].append(e["durationMs"])
print(f" {'Method':<8s} {'Count':>7s} {'Mean':>9s} {'p95':>9s} {'Max':>9s}")
print(f" {'' * 8} {'' * 7} {'' * 9} {'' * 9} {'' * 9}")
for method in sorted(by_method.keys()):
durs = by_method[method]
sd = sorted(durs)
print(
f" {method:<8s} {len(durs):>7,} "
f"{statistics.mean(durs):>8,.0f}ms "
f"{sd[len(sd)*95//100]:>8,.0f}ms "
f"{max(durs):>8,.0f}ms"
)
# ── 7. Timeline (calls per minute) ──────────────────────────────────────────
if time_span.total_seconds() > 60:
print(header("TIMELINE (per-minute throughput & errors)"))
by_minute = defaultdict(lambda: {"count": 0, "errors": 0, "dur_sum": 0})
for e in entries:
ts = e["timestamp"][:16] # YYYY-MM-DDTHH:MM
by_minute[ts]["count"] += 1
by_minute[ts]["dur_sum"] += e["durationMs"]
if e.get("error"):
by_minute[ts]["errors"] += 1
for minute in sorted(by_minute.keys()):
m = by_minute[minute]
avg = m["dur_sum"] / m["count"] if m["count"] else 0
err_part = f" {RED}({m['errors']} errs){RESET}" if m["errors"] else ""
bar = "" * min(m["count"] // 5, 50)
avg_clr = colour_duration(avg)
print(f" {minute} {m['count']:>5,} reqs avg {avg_clr:>20s} {bar}{err_part}")
# ── 8. Concurrency hotspots ─────────────────────────────────────────────────
print(header("CONCURRENCY HOTSPOTS (calls starting within 100ms of each other)"))
ts_ms = [int(t.timestamp() * 1000) for t in timestamps]
bursts = []
i = 0
while i < len(ts_ms):
j = i
while j < len(ts_ms) - 1 and ts_ms[j + 1] - ts_ms[j] < 100:
j += 1
burst_size = j - i + 1
if burst_size >= 5:
burst_entries = entries[i:j + 1]
avg_dur = statistics.mean(e["durationMs"] for e in burst_entries)
bursts.append((burst_size, entries[i]["timestamp"], avg_dur, burst_entries))
i = j + 1
bursts.sort(key=lambda b: b[0], reverse=True)
if bursts:
print(f" Found {len(bursts)} burst(s) of ≥5 concurrent requests\n")
for size, ts, avg, _ in bursts[:10]:
print(f" {YELLOW}{size:>3} concurrent{RESET} at {ts} avg {colour_duration(avg)}")
else:
print(f" {GREEN}No major concurrency bursts detected.{RESET}")
# ── 9. Summary / recommendations ────────────────────────────────────────────
print(header("SUMMARY"))
err_rate = len(errors) / len(entries) * 100
slow_5s = sum(1 for d in durations if d >= 5000)
slow_pct = slow_5s / len(entries) * 100
if err_rate > 5:
print(f" {RED}⚠ Error rate is {err_rate:.1f}% — CW API is struggling{RESET}")
elif err_rate > 1:
print(f" {YELLOW}⚠ Error rate is {err_rate:.1f}% — some instability{RESET}")
else:
print(f" {GREEN}✓ Error rate is {err_rate:.1f}% — acceptable{RESET}")
if slow_pct > 10:
print(f" {RED}{slow_5s:,} calls ({slow_pct:.1f}%) took >5s — CW is slow or rate-limiting{RESET}")
elif slow_pct > 2:
print(f" {YELLOW}{slow_5s:,} calls ({slow_pct:.1f}%) took >5s{RESET}")
else:
print(f" {GREEN}✓ Only {slow_5s:,} calls ({slow_pct:.1f}%) over 5s{RESET}")
if bursts:
max_burst = max(b[0] for b in bursts)
print(f" {YELLOW}⚠ Max concurrency burst: {max_burst} simultaneous requests — consider lowering CONCURRENCY{RESET}")
total_time_s = sum(durations) / 1000
print(f"\n Total wall-clock time spent waiting on CW: {BOLD}{total_time_s:,.1f}s{RESET} ({total_time_s/60:,.1f} min)")
print()
+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

+6
View File
@@ -19,6 +19,7 @@
}, },
"scripts": { "scripts": {
"dev": "NODE_ENV=development bun --watch src/index.ts", "dev": "NODE_ENV=development bun --watch src/index.ts",
"dev:log": "LOG_CW_API=1 NODE_ENV=development bun --watch src/index.ts",
"test": "bun test --preload ./tests/setup.ts", "test": "bun test --preload ./tests/setup.ts",
"db:gen": "prisma generate", "db:gen": "prisma generate",
"db:push": "prisma migrate dev --skip-generate", "db:push": "prisma migrate dev --skip-generate",
@@ -27,6 +28,9 @@
"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",
"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"
}, },
"dependencies": { "dependencies": {
@@ -44,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;
+154 -5
View File
@@ -4,30 +4,179 @@ 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 */ /* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
export default createRoute( export default createRoute(
"get", "get",
["/opportunities/:identifier"], ["/opportunities/:identifier"],
async (c) => { async (c) => {
const identifier = c.req.param("identifier"); 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),
);
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,
});
}
// 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")) {
subResourcePromises.quotes = generatedQuotes
.fetchByOpportunity(item.id)
.then((quotes) => quotes.map((q) => q.toJson()));
}
const keys = Object.keys(subResourcePromises);
const results = await Promise.all(keys.map((k) => subResourcePromises[k]));
// Apply toJson after site is hydrated (side-effect from fetchSite)
const gatedData = await processObjectValuePerms( const gatedData = await processObjectValuePerms(
item.toJson(), item.toJson(),
"obj.opportunity", "obj.opportunity",
c.get("user"), 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( 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(
@@ -10,7 +10,7 @@ export default createRoute(
["/opportunities/:identifier/contacts"], ["/opportunities/:identifier/contacts"],
async (c) => { async (c) => {
const identifier = c.req.param("identifier"); const identifier = c.req.param("identifier");
const item = await opportunities.fetchItem(identifier); const item = await opportunities.fetchRecord(identifier);
const data = await item.fetchContacts(); const data = await item.fetchContacts();
+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 */
@@ -21,7 +21,7 @@ export default createRoute(
const data = schema.parse(body); const data = schema.parse(body);
const item = await opportunities.fetchItem(identifier); const item = await opportunities.fetchRecord(identifier);
const user = c.get("user"); const user = c.get("user");
const created = await item.addNote(data.text, user.login, { const created = await item.addNote(data.text, user.login, {
@@ -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(
@@ -20,7 +20,7 @@ export default createRoute(
message: "Note ID must be a number", message: "Note ID must be a number",
}); });
const item = await opportunities.fetchItem(identifier); const item = await opportunities.fetchRecord(identifier);
await item.deleteNote(noteId); await item.deleteNote(noteId);
const response = apiResponse.successful( const response = apiResponse.successful(
@@ -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(
@@ -20,7 +20,7 @@ export default createRoute(
message: "Note ID must be a number", message: "Note ID must be a number",
}); });
const item = await opportunities.fetchItem(identifier); const item = await opportunities.fetchRecord(identifier);
const data = await item.fetchNote(noteId); const data = await item.fetchNote(noteId);
const response = apiResponse.successful( const response = apiResponse.successful(
@@ -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(
@@ -10,7 +10,7 @@ export default createRoute(
["/opportunities/:identifier/notes"], ["/opportunities/:identifier/notes"],
async (c) => { async (c) => {
const identifier = c.req.param("identifier"); const identifier = c.req.param("identifier");
const item = await opportunities.fetchItem(identifier); const item = await opportunities.fetchRecord(identifier);
const data = await item.fetchNotes(); const data = await item.fetchNotes();
@@ -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 */
@@ -35,7 +35,7 @@ export default createRoute(
const data = schema.parse(body); const data = schema.parse(body);
const item = await opportunities.fetchItem(identifier); const item = await opportunities.fetchRecord(identifier);
const updated = await item.updateNote(noteId, data); const updated = await item.updateNote(noteId, data);
const response = apiResponse.successful( const response = apiResponse.successful(
@@ -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
@@ -53,7 +53,7 @@ export default createRoute(
), ),
); );
const item = await opportunities.fetchItem(identifier); const item = await opportunities.fetchRecord(identifier);
const created = await item.addProducts(gatedItems); const created = await item.addProducts(gatedItems);
const isBatch = Array.isArray(body); const isBatch = Array.isArray(body);
@@ -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(
@@ -10,7 +10,7 @@ export default createRoute(
["/opportunities/:identifier/products"], ["/opportunities/:identifier/products"],
async (c) => { async (c) => {
const identifier = c.req.param("identifier"); const identifier = c.req.param("identifier");
const item = await opportunities.fetchItem(identifier); const item = await opportunities.fetchRecord(identifier);
const data = await item.fetchProducts(); const data = await item.fetchProducts();
@@ -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 */
@@ -21,7 +21,7 @@ export default createRoute(
const { orderedIds } = schema.parse(body); const { orderedIds } = schema.parse(body);
const item = await opportunities.fetchItem(identifier); const item = await opportunities.fetchRecord(identifier);
const updated = await item.resequenceProducts(orderedIds); const updated = await item.resequenceProducts(orderedIds);
const response = apiResponse.successful( const response = apiResponse.successful(
@@ -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;
};
+4
View File
@@ -6,6 +6,7 @@ import { Server } from "socket.io";
import { Server as Engine } from "@socket.io/bun-engine"; import { Server as Engine } from "@socket.io/bun-engine";
import axios from "axios"; import axios from "axios";
import { UnifiClient } from "./modules/unifi-api/UnifiClient"; import { UnifiClient } from "./modules/unifi-api/UnifiClient";
import { attachCwApiLogger } from "./modules/cw-utils/cwApiLogger";
import Redis from "ioredis"; import Redis from "ioredis";
const connectionString = `${process.env.DATABASE_URL}`; const connectionString = `${process.env.DATABASE_URL}`;
@@ -81,8 +82,11 @@ const connectWiseApi = axios.create({
clientId: `${process.env.CW_CLIENT_ID}`, clientId: `${process.env.CW_CLIENT_ID}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
timeout: 30_000, // 30 s — prevents indefinite hangs on CW API
}); });
attachCwApiLogger(connectWiseApi);
export { connectWiseApi }; export { connectWiseApi };
// Unifi API Constants // Unifi API Constants
+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,
};
}
}
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -33,6 +33,9 @@ export class RoleController {
private _permissionsToken: string; private _permissionsToken: string;
private _users: (User & { roles: Role[] })[]; private _users: (User & { roles: Role[] })[];
/** Cached result of JWT verification — avoids repeated RSA verify calls. */
private _cachedVerifiedPermissions: { permissions: string[] } | null = null;
public readonly createdAt: Date; public readonly createdAt: Date;
public updatedAt: Date; public updatedAt: Date;
@@ -62,6 +65,14 @@ export class RoleController {
* @returns - Verified object with permissions in it. * @returns - Verified object with permissions in it.
*/ */
private _verifyPermissions(permissionsToken: string) { private _verifyPermissions(permissionsToken: string) {
// Return cached result if the token hasn't changed
if (
this._cachedVerifiedPermissions &&
permissionsToken === this._permissionsToken
) {
return this._cachedVerifiedPermissions;
}
let perms: DecodedPermissionsBlock; let perms: DecodedPermissionsBlock;
try { try {
perms = jwt.verify(permissionsToken, permissionsPrivateKey, { perms = jwt.verify(permissionsToken, permissionsPrivateKey, {
@@ -82,7 +93,12 @@ export class RoleController {
); );
} }
return perms as { permissions: string[] }; const result = perms as { permissions: string[] };
// Cache only if verifying the current token
if (permissionsToken === this._permissionsToken) {
this._cachedVerifiedPermissions = result;
}
return result;
} }
/** /**
+32 -25
View File
@@ -24,6 +24,13 @@ export default class UserController {
private _roles: Collection<string, Role>; private _roles: Collection<string, Role>;
private _permissions: string | null; private _permissions: string | null;
/** Cached result of fetchRoles() — populated on first hasPermission call. */
private _resolvedRoleControllers: Collection<string, RoleController> | null =
null;
/** Per-permission result cache — avoids repeated JWT verification + DB lookups. */
private _permissionCache: Map<string, boolean> = new Map();
public createdAt: Date; public createdAt: Date;
public updatedAt: Date; public updatedAt: Date;
constructor(userdata: User & { roles: Role[] }) { constructor(userdata: User & { roles: Role[] }) {
@@ -127,6 +134,7 @@ export default class UserController {
this._updateInternalValues(updatedUser); this._updateInternalValues(updatedUser);
this._roles = new Collection<string, Role>(); this._roles = new Collection<string, Role>();
updatedUser.roles.map((v: any) => this._roles.set(v.id, v)); updatedUser.roles.map((v: any) => this._roles.set(v.id, v));
this.clearPermissionCache();
for (const role of resolvedRoles) { for (const role of resolvedRoles) {
events.emit("user:role:assigned", { user: this, role }); events.emit("user:role:assigned", { user: this, role });
@@ -252,35 +260,34 @@ export default class UserController {
* @returns {boolean} Does this user have the specified permission * @returns {boolean} Does this user have the specified permission
*/ */
public async hasPermission(permission: string) { public async hasPermission(permission: string) {
let resources = await prisma.user.findFirst({ // Fast path: return cached result if we already resolved this permission
where: { id: this.id }, const cached = this._permissionCache.get(permission);
select: { if (cached !== undefined) return cached;
sessions: {
select: { id: true },
},
},
});
const resourceKeys: string[] = Object.keys(resources ?? {}) as string[]; // Resolve role controllers once and cache them for the lifetime of this
// controller instance (i.e. the current request).
if (!this._resolvedRoleControllers) {
this._resolvedRoleControllers = await this.fetchRoles();
}
const implicitPermissions = resources const result = this._resolvedRoleControllers
? resourceKeys .map((v) => v.checkPermission(permission))
// @ts-ignore .includes(true);
.filter((v) => resources[v].length > 0)
.map(
(v) =>
//@ts-ignore
`resource.${v}.[${(resources![v] as { id: string }[])
.map((o) => o.id)
.join(",")}].user.${this.id}.implicit`,
)
: [];
let checks = [ this._permissionCache.set(permission, result);
(await this.fetchRoles()).map((v) => v.checkPermission(permission)), return result;
].flatMap((v) => v); }
return checks.includes(true); /**
* Clear Permission Cache
*
* Invalidates the in-memory permission cache so the next
* `hasPermission` call re-fetches roles from the database.
* Call this after role or permission mutations on the user.
*/
public clearPermissionCache() {
this._resolvedRoleControllers = null;
this._permissionCache.clear();
} }
/** /**
+72 -22
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";
@@ -24,6 +27,16 @@ import cuid from "cuid";
// Setup global event debugger in non-production environments // Setup global event debugger in non-production environments
if (Bun.env.NODE_ENV == "development") setupEventDebugger(); if (Bun.env.NODE_ENV == "development") setupEventDebugger();
/** Concise error message for interval logs — avoids dumping full Axios error objects. */
const briefErr = (err: any): string => {
if (err?.isAxiosError) {
const method = (err.config?.method ?? "?").toUpperCase();
const url = err.config?.url ?? "?";
return `${method} ${url}${err.code ?? `HTTP ${err.response?.status}`}`;
}
return err?.message ?? String(err);
};
// Helper to run a startup sync safely — failures are logged but never crash the process. // Helper to run a startup sync safely — failures are logged but never crash the process.
const safeStartup = async (label: string, fn: () => Promise<void>) => { const safeStartup = async (label: string, fn: () => Promise<void>) => {
try { try {
@@ -57,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.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -91,44 +107,63 @@ await safeStartup("ensureAdminRole", async () => {
await safeStartup("refreshCompanies", refreshCompanies); await safeStartup("refreshCompanies", refreshCompanies);
setInterval(() => { setInterval(() => {
return refreshCompanies().catch((err) => return refreshCompanies().catch((err) =>
console.error("[interval] refreshCompanies failed", err), console.error(`[interval] refreshCompanies failed: ${briefErr(err)}`),
); );
}, 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", 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", 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(() => {
return refreshOpportunities().catch((err) => return refreshOpportunities().catch((err) =>
console.error("[interval] refreshOpportunities failed", err), console.error(`[interval] refreshOpportunities failed: ${briefErr(err)}`),
); );
}, 60 * 1000); }, 60 * 1000);
// Refresh opportunity CW cache every 30 seconds (activities + company hydration) // Refresh opportunity CW cache every 20 minutes (activities + company hydration)
await safeStartup("refreshOpportunityCache", refreshOpportunityCache); // NOTE: Do NOT await — register the interval immediately so the cache refresh
setInterval(() => { // is never blocked by a slow/stuck startup task above.
return refreshOpportunityCache().catch((err) => safeStartup("refreshOpportunityCache", refreshOpportunityCache);
console.error("[interval] refreshOpportunityCache failed", err), setInterval(
); () => {
}, 30 * 1000); return refreshOpportunityCache().catch((err) => {
console.error(
`[interval] refreshOpportunityCache failed: ${briefErr(err)}`,
);
});
},
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());
@@ -136,7 +171,9 @@ setInterval(
() => { () => {
return userDefinedFieldsCw return userDefinedFieldsCw
.refresh() .refresh()
.catch((err) => console.error("[interval] refreshUDFs failed", err)); .catch((err) =>
console.error(`[interval] refreshUDFs failed: ${briefErr(err)}`),
);
}, },
5 * 60 * 1000, 5 * 60 * 1000,
); );
@@ -146,15 +183,28 @@ await safeStartup("refreshCwIdentifiers", refreshCwIdentifiers);
setInterval( setInterval(
() => { () => {
return refreshCwIdentifiers().catch((err) => return refreshCwIdentifiers().catch((err) =>
console.error("[interval] refreshCwIdentifiers failed", err), console.error(`[interval] refreshCwIdentifiers failed: ${briefErr(err)}`),
); );
}, },
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
.syncSites() .syncSites()
.catch((err) => console.error("[interval] syncSites failed", err)); .catch((err) =>
console.error(`[interval] syncSites failed: ${briefErr(err)}`),
);
}, 60 * 1000); }, 60 * 1000);
+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);
},
};
+184 -69
View File
@@ -6,12 +6,15 @@ 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,
getCachedCompanyCwData, getCachedCompanyCwData,
getCachedOppCwData,
fetchAndCacheActivities, fetchAndCacheActivities,
fetchAndCacheCompanyCwData, fetchAndCacheCompanyCwData,
fetchAndCacheOppCwData,
} from "../modules/cache/opportunityCache"; } from "../modules/cache/opportunityCache";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -43,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;
} }
@@ -64,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;
} }
@@ -93,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));
} }
@@ -114,16 +120,95 @@ 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)
*
* Returns an OpportunityController backed only by the **database record**.
* No ConnectWise API calls, no Redis lookups, no activity/company hydration.
*
* Use this when you only need the controller instance to call a sub-resource
* method (e.g. `fetchNotes()`, `fetchContacts()`, `fetchProducts()`,
* `fetchSite()`).
*
* @param identifier - Internal ID (string) or CW opportunity ID (number)
* @returns {Promise<OpportunityController>}
*/
async fetchRecord(
identifier: string | number,
): Promise<OpportunityController> {
const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier));
const record = await prisma.opportunity.findFirst({
where: isNumeric
? { cwOpportunityId: Number(identifier) }
: { id: identifier as string },
include: { company: true },
});
if (!record) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
return new OpportunityController(record, {
company: record.company
? new CompanyController(record.company)
: undefined,
});
},
/** /**
* Fetch Opportunity * Fetch Opportunity
* *
@@ -132,11 +217,12 @@ export const opportunities = {
* *
* **Data-source strategy:** * **Data-source strategy:**
* - `fresh: true` `"cw-first"` always fetches from CW, updates DB, caches result. * - `fresh: true` `"cw-first"` always fetches from CW, updates DB, caches result.
* - `fresh: false` (default) `"cache-then-cw"` tries Redis first, falls back to CW on miss. * - `fresh: false` (default) `"cache-then-cw"` tries Redis cache for the
* CW opportunity response first, falls back to CW on miss.
* *
* Individual fetches always contact CW to update the DB record with * The CW opportunity response is cached in Redis with the same TTL as
* the latest data from ConnectWise, regardless of the cache strategy * activities/company. The background refresh keeps this warm so most
* for activities/company hydration. * detail-view loads skip the CW roundtrip entirely.
* *
* @param identifier - The internal ID (string) or CW opportunity ID (number) * @param identifier - The internal ID (string) or CW opportunity ID (number)
* @param opts - Optional flags * @param opts - Optional flags
@@ -153,12 +239,12 @@ export const opportunities = {
const isNumeric = const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier)); typeof identifier === "number" || /^\d+$/.test(String(identifier));
// Look up the existing DB record to get the cwOpportunityId // Look up the existing DB record (full, with company)
const existing = await prisma.opportunity.findFirst({ const existing = await prisma.opportunity.findFirst({
where: isNumeric where: isNumeric
? { cwOpportunityId: Number(identifier) } ? { cwOpportunityId: Number(identifier) }
: { id: identifier as string }, : { id: identifier as string },
select: { id: true, cwOpportunityId: true }, include: { company: true },
}); });
if (!existing) { if (!existing) {
@@ -170,46 +256,75 @@ export const opportunities = {
}); });
} }
// Fetch fresh data from ConnectWise // Compute TTL from the current DB state (used for cache and hydration)
const cwData = await opportunityCw.fetch(existing.cwOpportunityId);
// Map and update the DB record
const mapped = OpportunityController.mapCwToDb(cwData);
// Resolve internal company link
const companyId = cwData.company?.id
? ((
await prisma.company.findFirst({
where: { cw_CompanyId: cwData.company.id },
select: { id: true },
})
)?.id ?? null)
: null;
const updated = await prisma.opportunity.update({
where: { id: existing.id },
data: { ...mapped, companyId },
include: { company: true },
});
const ttlMs = const ttlMs =
computeCacheTTL({ computeCacheTTL({
closedFlag: updated.closedFlag, closedFlag: existing.closedFlag,
closedDate: updated.closedDate, closedDate: existing.closedDate,
expectedCloseDate: updated.expectedCloseDate, expectedCloseDate: existing.expectedCloseDate,
lastUpdated: updated.cwLastUpdated, lastUpdated: existing.cwLastUpdated,
}) ?? undefined; }) ?? undefined;
const activities = await buildActivities(updated.cwOpportunityId, { // ── Resolve CW opportunity data (cache-aware) ──────────────────────
strategy, let cwData: any;
ttlMs, let record = existing; // default: use the existing DB record as-is
});
return new OpportunityController(updated, { if (!opts?.fresh) {
company: updated.company // Try the Redis cache first
? await buildCompanyController(updated.company, { strategy, ttlMs }) cwData = await getCachedOppCwData(existing.cwOpportunityId);
: undefined, }
customFields: cwData.customFields ?? [],
// ── Parallel block: CW opp fetch + activities + company ────────────
// Activities and company hydration only need existing.cwOpportunityId
// and existing.company — both available from the initial DB lookup —
// so they can run concurrently with the CW opp fetch + DB update.
const cwOppPromise = (async () => {
if (cwData) return; // cache hit — nothing to do
cwData = ttlMs
? await fetchAndCacheOppCwData(existing.cwOpportunityId, ttlMs)
: await opportunityCw.fetch(existing.cwOpportunityId);
if (!cwData) {
throw new GenericError({
message: "Opportunity not found in ConnectWise",
name: "OpportunityNotFound",
cause: `CW returned 404 for opportunity ${existing.cwOpportunityId}`,
status: 404,
});
}
// Map and update the DB record (only on cache miss/fresh)
const mapped = OpportunityController.mapCwToDb(cwData);
const companyId = cwData.company?.id
? ((
await prisma.company.findFirst({
where: { cw_CompanyId: cwData.company.id },
select: { id: true },
})
)?.id ?? null)
: null;
record = await prisma.opportunity.update({
where: { id: existing.id },
data: { ...mapped, companyId },
include: { company: true },
});
})();
// Hydrate activities and company in parallel with CW opp fetch
const [, activities, company] = await Promise.all([
cwOppPromise,
buildActivities(existing.cwOpportunityId, { strategy, ttlMs }),
existing.company
? buildCompanyController(existing.company, { strategy, ttlMs })
: Promise.resolve(undefined),
]);
return new OpportunityController(record, {
company,
customFields: cwData?.customFields ?? [],
activities, activities,
}); });
}, },
@@ -302,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;
@@ -0,0 +1,116 @@
/**
* @module computeProductsCacheTTL
*
* Adaptive Cache TTL for Opportunity Products
* ============================================
*
* Determines how long products (forecast items) should be cached in
* Redis before being re-fetched from ConnectWise.
*
* Products have unique caching rules compared to notes or contacts
* because they are typically finalised before a deal closes and do not
* change once the opportunity reaches a terminal status.
*
* ## Spec
*
* | # | Condition | TTL (ms) | TTL (human) | Rationale |
* |---|------------------------------------------------------------------------------|------------|-------------|---------------------------------------------------------------------------------------|
* | 1 | Status is **Won**, **Lost**, **Pending Won**, or **Pending Lost** | `null` | No cache | Products on terminal / near-terminal opps are static; no need to keep them warm. |
* | 2 | Opportunity is **not cacheable** (main cache TTL is `null`) | `null` | No cache | If the opp itself is evicted, sub-resources follow suit. |
* | 3 | `lastUpdated` is within the last **3 days** | 15 000 | 15 seconds | Actively-worked deals products are being edited and need near-real-time freshness. |
* | 4 | Everything else | 1 200 000 | 20 minutes | Lazy on-demand cache: fetched when requested, expires after 20 min without refresh. |
*
* ## Evaluation order
*
* Rules are evaluated top-to-bottom; the first matching rule wins.
*
* ## Inputs
*
* Extends {@link CacheTTLInput} from `computeCacheTTL` with an
* additional `statusCwId` field used to identify terminal statuses.
*
* ## Output
*
* Returns `number | null`:
* - Positive integer = TTL in **milliseconds**.
* - `null` = do **not** cache.
*/
import type { CacheTTLInput } from "./computeCacheTTL";
import { computeCacheTTL } from "./computeCacheTTL";
import { QUOTE_STATUSES } from "../../types/QuoteStatuses";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** 45 seconds TTL for hot products (opportunity updated within 3 days).
* Must exceed the 30-second background refresh interval so the cache
* stays warm between cycles. */
export const PRODUCTS_TTL_HOT = 45_000;
/** 20 minutes — TTL for on-demand product cache (lazy fallback). */
export const PRODUCTS_TTL_LAZY = 1_200_000;
/** 3 days in milliseconds. */
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
/**
* Set of all CW status IDs that map to a Won or Lost canonical status.
*
* Built at module load from {@link QUOTE_STATUSES} so it stays in sync
* with any future status additions.
*/
export const WON_LOST_STATUS_IDS: ReadonlySet<number> = new Set(
QUOTE_STATUSES.filter((s) => s.wonFlag || s.lostFlag).flatMap((s) => [
s.id,
...s.optimaEquivalency,
]),
);
// ---------------------------------------------------------------------------
// Input type
// ---------------------------------------------------------------------------
export interface ProductsCacheTTLInput extends CacheTTLInput {
/** The CW status ID of the opportunity. */
statusCwId: number | null;
}
// ---------------------------------------------------------------------------
// Algorithm
// ---------------------------------------------------------------------------
/**
* Compute the cache TTL for an opportunity's products.
*
* @param input - The opportunity's activity signals plus status ID.
* @returns TTL in milliseconds, or `null` if products should not be cached.
*/
export function computeProductsCacheTTL(
input: ProductsCacheTTLInput,
): number | null {
const { statusCwId, lastUpdated, now = new Date() } = input;
// Rule 1 — Terminal statuses: Won / Lost / Pending Won / Pending Lost
if (statusCwId !== null && WON_LOST_STATUS_IDS.has(statusCwId)) {
return null;
}
// Rule 2 — If the opportunity itself is not cacheable, skip products too
const mainTTL = computeCacheTTL(input);
if (mainTTL === null) {
return null;
}
// Rule 3 — Hot: updated within the last 3 days
if (lastUpdated) {
const diff = Math.abs(now.getTime() - lastUpdated.getTime());
if (diff <= THREE_DAYS_MS) {
return PRODUCTS_TTL_HOT;
}
}
// Rule 4 — Lazy fallback
return PRODUCTS_TTL_LAZY;
}
@@ -0,0 +1,118 @@
/**
* @module computeSubResourceCacheTTL
*
* Adaptive Cache TTL for Opportunity Sub-Resources
* =================================================
*
* Determines how long cached sub-resource data (notes, contacts) should
* live before being re-fetched from ConnectWise.
*
* Sub-resources change less frequently than the opportunity record itself
* or its activity feed, so TTLs are longer than the primary cache. The
* same activity-signal heuristics are used (expected close date, last
* updated, closed status) but with relaxed durations.
*
* ## Spec
*
* | # | Condition | TTL (ms) | TTL (human) | Rationale |
* |---|-------------------------------------------------------------------|----------|-------------|--------------------------------------------------------------------|
* | 1 | `closedFlag` is `true` AND closed > 30 days ago | `null` | Do not cache| Old closed records are rarely accessed. |
* | 1b| `closedFlag` is `true` AND closed within 30 days | 300 000 | 5 minutes | Recently-closed records may still be viewed occasionally. |
* | 2 | `expectedCloseDate` OR `lastUpdated` within **5 days** | 60 000 | 60 seconds | Active deals contacts/notes may still change. |
* | 3 | `expectedCloseDate` OR `lastUpdated` within **14 days** | 120 000 | 2 minutes | Moderate activity less likely to change. |
* | 4 | Everything else (older than 14 days) | 300 000 | 5 minutes | Low activity safe to cache longer. |
*
* ## Evaluation order
*
* Rules are evaluated top-to-bottom; the first matching rule wins.
*
* ## Inputs
*
* Uses the same {@link CacheTTLInput} interface as `computeCacheTTL`.
*
* ## Output
*
* Returns `number | null`:
* - Positive integer = TTL in **milliseconds**.
* - `null` = do **not** cache.
*/
import type { CacheTTLInput } from "./computeCacheTTL";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** 60 seconds — TTL for high-activity sub-resources (within 5 days). */
export const SUB_TTL_HIGH_ACTIVITY = 60_000;
/** 2 minutes — TTL for moderate-activity sub-resources (within 14 days). */
export const SUB_TTL_MODERATE_ACTIVITY = 120_000;
/** 5 minutes — TTL for low-activity / stale sub-resources. */
export const SUB_TTL_LOW_ACTIVITY = 300_000;
/** 30 days in milliseconds. */
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/** 5 days in milliseconds. */
const FIVE_DAYS_MS = 5 * 24 * 60 * 60 * 1000;
/** 14 days in milliseconds. */
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
// ---------------------------------------------------------------------------
// Algorithm
// ---------------------------------------------------------------------------
/**
* Compute the cache TTL for an opportunity sub-resource (notes, contacts).
*
* @param input - The opportunity's activity signals. See {@link CacheTTLInput}.
* @returns The TTL in milliseconds, or `null` if the data should not be cached.
*/
export function computeSubResourceCacheTTL(
input: CacheTTLInput,
): number | null {
const {
closedFlag,
closedDate,
expectedCloseDate,
lastUpdated,
now = new Date(),
} = input;
const nowMs = now.getTime();
const isWithinWindow = (date: Date | null, windowMs: number): boolean => {
if (!date) return false;
return Math.abs(nowMs - date.getTime()) <= windowMs;
};
// Rule 1 — Closed records
if (closedFlag) {
if (isWithinWindow(closedDate, THIRTY_DAYS_MS)) {
return SUB_TTL_LOW_ACTIVITY;
}
return null;
}
// Rule 2 — High activity (5 days)
if (
isWithinWindow(expectedCloseDate, FIVE_DAYS_MS) ||
isWithinWindow(lastUpdated, FIVE_DAYS_MS)
) {
return SUB_TTL_HIGH_ACTIVITY;
}
// Rule 3 — Moderate activity (14 days)
if (
isWithinWindow(expectedCloseDate, FOURTEEN_DAYS_MS) ||
isWithinWindow(lastUpdated, FOURTEEN_DAYS_MS)
) {
return SUB_TTL_MODERATE_ACTIVITY;
}
// Rule 4 — Low activity / stale
return SUB_TTL_LOW_ACTIVITY;
}
+555 -49
View File
@@ -13,6 +13,10 @@
* `CWActivity[]` array fetched from `activityCw.fetchByOpportunity()`. * `CWActivity[]` array fetched from `activityCw.fetchByOpportunity()`.
* - **Company CW data** (`opp:company-cw:{cw_CompanyId}`) the hydrated * - **Company CW data** (`opp:company-cw:{cw_CompanyId}`) the hydrated
* company / contacts blob set by `CompanyController.hydrateCwData()`. * company / contacts blob set by `CompanyController.hydrateCwData()`.
* - **Notes** (`opp:notes:{cwOpportunityId}`) raw CW notes array.
* - **Contacts** (`opp:contacts:{cwOpportunityId}`) raw CW contacts array.
* - **Products** (`opp:products:{cwOpportunityId}`) raw CW forecast +
* procurement products blob.
* *
* TTLs are computed dynamically via {@link computeCacheTTL} so hot * TTLs are computed dynamically via {@link computeCacheTTL} so hot
* opportunities refresh every 30 s while stale ones live for 15 min. * opportunities refresh every 30 s while stale ones live for 15 min.
@@ -28,8 +32,16 @@
import { prisma, redis } from "../../constants"; import { prisma, redis } from "../../constants";
import { activityCw } from "../cw-utils/activities/activities"; import { activityCw } from "../cw-utils/activities/activities";
import { computeCacheTTL } from "../algorithms/computeCacheTTL"; import { computeCacheTTL } from "../algorithms/computeCacheTTL";
import { computeSubResourceCacheTTL } from "../algorithms/computeSubResourceCacheTTL";
import {
computeProductsCacheTTL,
PRODUCTS_TTL_HOT,
} from "../algorithms/computeProductsCacheTTL";
import { connectWiseApi } from "../../constants"; import { connectWiseApi } from "../../constants";
import { fetchCwCompanyById } from "../cw-utils/fetchCompany"; import { fetchCwCompanyById } from "../cw-utils/fetchCompany";
import { fetchCompanySite } from "../cw-utils/sites/companySites";
import { opportunityCw } from "../cw-utils/opportunities/opportunities";
import { withCwRetry } from "../cw-utils/withCwRetry";
import { events } from "../globalEvents"; import { events } from "../globalEvents";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -38,6 +50,11 @@ import { events } from "../globalEvents";
const ACTIVITY_PREFIX = "opp:activities:"; const ACTIVITY_PREFIX = "opp:activities:";
const COMPANY_CW_PREFIX = "opp:company-cw:"; const COMPANY_CW_PREFIX = "opp:company-cw:";
const NOTES_PREFIX = "opp:notes:";
const CONTACTS_PREFIX = "opp:contacts:";
const PRODUCTS_PREFIX = "opp:products:";
const SITE_PREFIX = "opp:site:";
const OPP_CW_PREFIX = "opp:cw-data:";
/** Redis key for cached activities by CW opportunity ID. */ /** Redis key for cached activities by CW opportunity ID. */
export const activityCacheKey = (cwOppId: number) => export const activityCacheKey = (cwOppId: number) =>
@@ -47,6 +64,25 @@ export const activityCacheKey = (cwOppId: number) =>
export const companyCwCacheKey = (cwCompanyId: number) => export const companyCwCacheKey = (cwCompanyId: number) =>
`${COMPANY_CW_PREFIX}${cwCompanyId}`; `${COMPANY_CW_PREFIX}${cwCompanyId}`;
/** Redis key for cached opportunity notes by CW opportunity ID. */
export const notesCacheKey = (cwOppId: number) => `${NOTES_PREFIX}${cwOppId}`;
/** Redis key for cached opportunity contacts by CW opportunity ID. */
export const contactsCacheKey = (cwOppId: number) =>
`${CONTACTS_PREFIX}${cwOppId}`;
/** Redis key for cached opportunity products by CW opportunity ID. */
export const productsCacheKey = (cwOppId: number) =>
`${PRODUCTS_PREFIX}${cwOppId}`;
/** Redis key for cached company site by CW company ID + site ID. */
export const siteCacheKey = (cwCompanyId: number, cwSiteId: number) =>
`${SITE_PREFIX}${cwCompanyId}:${cwSiteId}`;
/** Redis key for cached CW opportunity response by CW opportunity ID. */
export const oppCwDataCacheKey = (cwOppId: number) =>
`${OPP_CW_PREFIX}${cwOppId}`;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Read helpers // Read helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -85,6 +121,147 @@ export async function getCachedCompanyCwData(
} }
} }
/**
* Retrieve cached opportunity notes (raw CW data).
*
* @returns The parsed raw CW notes array or `null` on cache miss.
*/
export async function getCachedNotes(
cwOpportunityId: number,
): Promise<any[] | null> {
const raw = await redis.get(notesCacheKey(cwOpportunityId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Retrieve cached opportunity contacts (raw CW data).
*
* @returns The parsed raw CW contacts array or `null` on cache miss.
*/
export async function getCachedContacts(
cwOpportunityId: number,
): Promise<any[] | null> {
const raw = await redis.get(contactsCacheKey(cwOpportunityId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Retrieve cached opportunity products (raw CW forecast + procurement blob).
*
* @returns `{ forecast, procProducts }` or `null` on cache miss.
*/
export async function getCachedProducts(
cwOpportunityId: number,
): Promise<{ forecast: any; procProducts: any[] } | null> {
const raw = await redis.get(productsCacheKey(cwOpportunityId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Retrieve cached CW site data for a company/site pair.
*
* @returns Parsed site data or `null` on cache miss.
*/
export async function getCachedSite(
cwCompanyId: number,
cwSiteId: number,
): Promise<any | null> {
const raw = await redis.get(siteCacheKey(cwCompanyId, cwSiteId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Retrieve cached CW opportunity response data.
*
* @returns Parsed CW opportunity object or `null` on cache miss.
*/
export async function getCachedOppCwData(
cwOpportunityId: number,
): Promise<any | null> {
const raw = await redis.get(oppCwDataCacheKey(cwOpportunityId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Check whether an error is an Axios 404 (resource not found in CW). */
function isNotFoundError(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false;
const e = err as Record<string, any>;
return e.isAxiosError === true && e.response?.status === 404;
}
/**
* Check whether an error is a transient network / timeout error.
*
* These are safe to swallow in background refresh tasks CW will be
* retried on the next refresh cycle. Logs a concise one-line warning
* instead of dumping the full Axios error object.
*/
function isTransientError(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false;
const e = err as Record<string, any>;
if (!e.isAxiosError) return false;
const code = e.code as string | undefined;
return (
code === "ECONNABORTED" ||
code === "ECONNREFUSED" ||
code === "ECONNRESET" ||
code === "ETIMEDOUT" ||
code === "ERR_NETWORK" ||
code === "ENETUNREACH" ||
code === "ERR_BAD_RESPONSE"
);
}
/** Build a concise error description for logging (avoids dumping entire Axios objects). */
function describeError(err: unknown): string {
if (typeof err !== "object" || err === null) return String(err);
const e = err as Record<string, any>;
if (e.isAxiosError) {
const method = (e.config?.method ?? "?").toUpperCase();
const url = e.config?.url ?? "unknown";
const code = e.code ?? "";
const status = e.response?.status ?? "";
return `${method} ${url}${code || `HTTP ${status}`} (${e.message})`;
}
return e.message ?? String(err);
}
/**
* When true, transient-error warnings inside fetchAndCache* are suppressed.
* Used during background refresh to avoid flooding the terminal the
* refresh function prints a single summary line instead.
*/
let _suppressTransientWarnings = false;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Write helpers // Write helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -92,21 +269,35 @@ export async function getCachedCompanyCwData(
/** /**
* Fetch activities from CW and cache them with the appropriate TTL. * Fetch activities from CW and cache them with the appropriate TTL.
* *
* Returns an empty array if CW responds with 404 (opportunity doesn't
* exist or was deleted upstream).
*
* @returns The raw `CWActivity[]` collection (as plain array). * @returns The raw `CWActivity[]` collection (as plain array).
*/ */
export async function fetchAndCacheActivities( export async function fetchAndCacheActivities(
cwOpportunityId: number, cwOpportunityId: number,
ttlMs: number, ttlMs: number,
): Promise<any[]> { ): Promise<any[]> {
const collection = await activityCw.fetchByOpportunity(cwOpportunityId); try {
const arr = collection.map((item) => item); // Use the direct (single-call) variant to avoid the extra count request
await redis.set( const arr = await activityCw.fetchByOpportunityDirect(cwOpportunityId);
activityCacheKey(cwOpportunityId), await redis.set(
JSON.stringify(arr), activityCacheKey(cwOpportunityId),
"PX", JSON.stringify(arr),
ttlMs, "PX",
); ttlMs,
return arr; );
return arr;
} catch (err) {
if (isNotFoundError(err)) return [];
if (isTransientError(err)) {
console.warn(
`[cache] activities opp#${cwOpportunityId}: ${describeError(err)}`,
);
return [];
}
throw err;
}
} }
/** /**
@@ -118,32 +309,264 @@ export async function fetchAndCacheCompanyCwData(
cwCompanyId: number, cwCompanyId: number,
ttlMs: number, ttlMs: number,
): Promise<{ company: any; defaultContact: any; allContacts: any[] } | null> { ): Promise<{ company: any; defaultContact: any; allContacts: any[] } | null> {
const cwCompany = await fetchCwCompanyById(cwCompanyId); try {
if (!cwCompany) return null; // 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` },
),
]);
const contactHref = cwCompany.defaultContact?._info?.contact_href; if (!cwCompany) return null;
const defaultContactData = contactHref
? await connectWiseApi.get(contactHref)
: undefined;
const allContactsData = await connectWiseApi.get( // Default contact: derive from allContacts instead of making an
`${cwCompany._info.contacts_href}&pageSize=1000`, // extra serial CW call. The company object carries the default
); // contact's ID, so we can pull it from the list we already fetched.
const defaultContactId = cwCompany.defaultContact?.id;
const defaultContactData = defaultContactId
? ((allContactsData.data as any[]).find(
(c: any) => c.id === defaultContactId,
) ?? null)
: null;
const blob = { const blob = {
company: cwCompany, company: cwCompany,
defaultContact: defaultContactData?.data ?? null, defaultContact: defaultContactData,
allContacts: allContactsData.data, allContacts: allContactsData.data,
}; };
await redis.set( await redis.set(
companyCwCacheKey(cwCompanyId), companyCwCacheKey(cwCompanyId),
JSON.stringify(blob), JSON.stringify(blob),
"PX", "PX",
ttlMs, ttlMs,
); );
return blob; return blob;
} catch (err) {
if (isNotFoundError(err)) return null;
if (isTransientError(err)) {
console.warn(`[cache] company#${cwCompanyId}: ${describeError(err)}`);
return null;
}
throw err;
}
}
/**
* Fetch opportunity notes from CW and cache the raw response.
*
* Returns an empty array if CW responds with 404.
*
* @returns The raw CW notes array.
*/
export async function fetchAndCacheNotes(
cwOpportunityId: number,
ttlMs: number,
): Promise<any[]> {
try {
const notes = await opportunityCw.fetchNotes(cwOpportunityId);
await redis.set(
notesCacheKey(cwOpportunityId),
JSON.stringify(notes),
"PX",
ttlMs,
);
return notes;
} catch (err) {
if (isNotFoundError(err)) return [];
if (isTransientError(err)) {
console.warn(
`[cache] notes opp#${cwOpportunityId}: ${describeError(err)}`,
);
return [];
}
throw err;
}
}
/**
* Fetch opportunity contacts from CW and cache the raw response.
*
* Returns an empty array if CW responds with 404.
*
* @returns The raw CW contacts array.
*/
export async function fetchAndCacheContacts(
cwOpportunityId: number,
ttlMs: number,
): Promise<any[]> {
try {
const contacts = await opportunityCw.fetchContacts(cwOpportunityId);
await redis.set(
contactsCacheKey(cwOpportunityId),
JSON.stringify(contacts),
"PX",
ttlMs,
);
return contacts;
} catch (err) {
if (isNotFoundError(err)) return [];
if (isTransientError(err)) {
console.warn(
`[cache] contacts opp#${cwOpportunityId}: ${describeError(err)}`,
);
return [];
}
throw err;
}
}
/**
* Invalidate cached notes for an opportunity.
*
* Call this after any note mutation (create, update, delete) so the
* next read refreshes from ConnectWise.
*/
export async function invalidateNotesCache(
cwOpportunityId: number,
): Promise<void> {
await redis.del(notesCacheKey(cwOpportunityId));
}
/**
* Invalidate cached contacts for an opportunity.
*
* Call this after any contact mutation so the next read refreshes
* from ConnectWise.
*/
export async function invalidateContactsCache(
cwOpportunityId: number,
): Promise<void> {
await redis.del(contactsCacheKey(cwOpportunityId));
}
/**
* Fetch opportunity products (forecast + procurement) from CW and cache.
*
* Stores both the forecast response and procurement products together
* so that `fetchProducts()` can reconstruct ForecastProductControllers
* from a single cache hit.
*
* @returns `{ forecast, procProducts }` blob.
*/
export async function fetchAndCacheProducts(
cwOpportunityId: number,
ttlMs: number,
): Promise<{ forecast: any; procProducts: any[] }> {
try {
const [forecast, procProducts] = await Promise.all([
opportunityCw.fetchProducts(cwOpportunityId),
opportunityCw.fetchProcurementProducts(cwOpportunityId),
]);
const blob = { forecast, procProducts };
await redis.set(
productsCacheKey(cwOpportunityId),
JSON.stringify(blob),
"PX",
ttlMs,
);
return blob;
} catch (err) {
if (isNotFoundError(err))
return { forecast: { forecastItems: [] }, procProducts: [] };
if (isTransientError(err)) {
console.warn(
`[cache] products opp#${cwOpportunityId}: ${describeError(err)}`,
);
return { forecast: { forecastItems: [] }, procProducts: [] };
}
throw err;
}
}
/**
* Invalidate cached products for an opportunity.
*
* Call this after any product mutation (add, update, resequence) so the
* next read refreshes from ConnectWise.
*/
export async function invalidateProductsCache(
cwOpportunityId: number,
): Promise<void> {
await redis.del(productsCacheKey(cwOpportunityId));
}
/**
* Site TTL 20 minutes. Site/address data rarely changes so we cache
* aggressively. The background refresh does NOT proactively warm site keys;
* they are populated lazily on the first detail-view request.
*/
const SITE_TTL_MS = 1_200_000;
/**
* Fetch a CW company site from ConnectWise and cache the result.
*
* @returns The raw CW site object.
*/
export async function fetchAndCacheSite(
cwCompanyId: number,
cwSiteId: number,
): Promise<any> {
try {
const site = await fetchCompanySite(cwCompanyId, cwSiteId);
await redis.set(
siteCacheKey(cwCompanyId, cwSiteId),
JSON.stringify(site),
"PX",
SITE_TTL_MS,
);
return site;
} catch (err) {
if (isNotFoundError(err)) return null;
if (isTransientError(err)) {
console.warn(
`[cache] site company#${cwCompanyId}/site#${cwSiteId}: ${describeError(err)}`,
);
return null;
}
throw err;
}
}
/**
* Fetch the raw CW opportunity response from ConnectWise and cache it.
*
* Used by `fetchItem()` in the manager to avoid a CW roundtrip when
* the detail view is reloaded within the cache TTL window.
*
* @param cwOpportunityId - The CW opportunity ID
* @param ttlMs - Cache TTL in milliseconds
* @returns The raw CW opportunity response object.
*/
export async function fetchAndCacheOppCwData(
cwOpportunityId: number,
ttlMs: number,
): Promise<any> {
try {
const cwData = await opportunityCw.fetch(cwOpportunityId);
await redis.set(
oppCwDataCacheKey(cwOpportunityId),
JSON.stringify(cwData),
"PX",
ttlMs,
);
return cwData;
} catch (err) {
if (isNotFoundError(err)) return null;
if (isTransientError(err)) {
console.warn(`[cache] opp#${cwOpportunityId}: ${describeError(err)}`);
return null;
}
throw err;
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -176,8 +599,10 @@ export async function refreshOpportunityCache(): Promise<void> {
closedDate: true, closedDate: true,
expectedCloseDate: true, expectedCloseDate: true,
cwLastUpdated: true, cwLastUpdated: true,
statusCwId: true,
company: { select: { cw_CompanyId: true } }, company: { select: { cw_CompanyId: true } },
}, },
orderBy: { cwLastUpdated: "desc" },
}); });
events.emit("cache:opportunities:refresh:started", { events.emit("cache:opportunities:refresh:started", {
@@ -186,25 +611,41 @@ export async function refreshOpportunityCache(): Promise<void> {
let activitiesRefreshed = 0; let activitiesRefreshed = 0;
let companiesRefreshed = 0; let companiesRefreshed = 0;
let notesRefreshed = 0;
let contactsRefreshed = 0;
let productsRefreshed = 0;
let oppCwDataRefreshed = 0;
let skipped = 0; let skipped = 0;
// Batch-check which activity keys already exist via a pipeline // Batch-check which keys already exist via a single pipeline
// (5 EXISTS per opportunity: oppCwData, activities, notes, contacts, products).
const pipeline = redis.pipeline(); const pipeline = redis.pipeline();
for (const opp of opportunities) { for (const opp of opportunities) {
pipeline.exists(oppCwDataCacheKey(opp.cwOpportunityId));
pipeline.exists(activityCacheKey(opp.cwOpportunityId)); pipeline.exists(activityCacheKey(opp.cwOpportunityId));
pipeline.exists(notesCacheKey(opp.cwOpportunityId));
pipeline.exists(contactsCacheKey(opp.cwOpportunityId));
pipeline.exists(productsCacheKey(opp.cwOpportunityId));
} }
const existsResults = await pipeline.exec(); const existsResults = await pipeline.exec();
const refreshTasks: Promise<void>[] = []; const refreshTasks: (() => Promise<void>)[] = [];
for (let i = 0; i < opportunities.length; i++) { for (let i = 0; i < opportunities.length; i++) {
const opp = opportunities[i]!; const opp = opportunities[i]!;
const ttl = computeCacheTTL({ const cacheTTLInput = {
closedFlag: opp.closedFlag, closedFlag: opp.closedFlag,
closedDate: opp.closedDate, closedDate: opp.closedDate,
expectedCloseDate: opp.expectedCloseDate, expectedCloseDate: opp.expectedCloseDate,
lastUpdated: opp.cwLastUpdated, lastUpdated: opp.cwLastUpdated,
};
const ttl = computeCacheTTL(cacheTTLInput);
const subTTL = computeSubResourceCacheTTL(cacheTTLInput);
const productsTTL = computeProductsCacheTTL({
...cacheTTLInput,
statusCwId: opp.statusCwId,
}); });
// Skip closed (ttl === null) — should not happen because of the query filter, // Skip closed (ttl === null) — should not happen because of the query filter,
@@ -215,43 +656,108 @@ export async function refreshOpportunityCache(): Promise<void> {
} }
// existsResults entries are [error, result] tuples // existsResults entries are [error, result] tuples
const activityExists = existsResults?.[i]?.[1] === 1; // Pipeline order per opportunity: oppCwData, activities, notes, contacts, products
const baseIdx = i * 5;
const oppCwDataExists = existsResults?.[baseIdx]?.[1] === 1;
const activityExists = existsResults?.[baseIdx + 1]?.[1] === 1;
const notesExist = existsResults?.[baseIdx + 2]?.[1] === 1;
const contactsExist = existsResults?.[baseIdx + 3]?.[1] === 1;
const productsExist = existsResults?.[baseIdx + 4]?.[1] === 1;
// Proactively cache the CW opportunity response itself
if (!oppCwDataExists) {
refreshTasks.push(() =>
fetchAndCacheOppCwData(opp.cwOpportunityId, ttl).then(() => {
oppCwDataRefreshed++;
}),
);
}
if (!activityExists) { if (!activityExists) {
refreshTasks.push( refreshTasks.push(() =>
fetchAndCacheActivities(opp.cwOpportunityId, ttl).then(() => { fetchAndCacheActivities(opp.cwOpportunityId, ttl).then(() => {
activitiesRefreshed++; activitiesRefreshed++;
}), }),
); );
} }
// Refresh notes/contacts if sub-resource TTL applies and key is missing
if (subTTL !== null) {
if (!notesExist) {
refreshTasks.push(() =>
fetchAndCacheNotes(opp.cwOpportunityId, subTTL).then(() => {
notesRefreshed++;
}),
);
}
if (!contactsExist) {
refreshTasks.push(() =>
fetchAndCacheContacts(opp.cwOpportunityId, subTTL).then(() => {
contactsRefreshed++;
}),
);
}
}
// Proactively refresh products only for hot opps (updated within 3 days).
// 30-minute lazy-cached products are filled on-demand by the endpoint
// and do not need background refresh.
if (productsTTL === PRODUCTS_TTL_HOT && !productsExist) {
refreshTasks.push(() =>
fetchAndCacheProducts(opp.cwOpportunityId, productsTTL).then(() => {
productsRefreshed++;
}),
);
}
// Also refresh company CW data if the key is missing // Also refresh company CW data if the key is missing
if (opp.company?.cw_CompanyId) { if (opp.company?.cw_CompanyId) {
const cwCompanyId = opp.company.cw_CompanyId; const cwCompanyId = opp.company.cw_CompanyId;
refreshTasks.push( refreshTasks.push(async () => {
(async () => { const companyExists = await redis.exists(
const companyExists = await redis.exists( companyCwCacheKey(cwCompanyId),
companyCwCacheKey(cwCompanyId), );
); if (!companyExists) {
if (!companyExists) { await fetchAndCacheCompanyCwData(cwCompanyId, ttl);
await fetchAndCacheCompanyCwData(cwCompanyId, ttl); companiesRefreshed++;
companiesRefreshed++; }
} });
})(),
);
} }
} }
// Run all refresh tasks concurrently with bounded concurrency // Run refresh thunks with bounded concurrency and inter-batch delay.
const CONCURRENCY = 10; // Each thunk is only invoked here — no requests fire until we call them.
// CW rate-limits aggressively so we keep this conservative.
const CONCURRENCY = 6;
const BATCH_DELAY_MS = 250;
let timeoutCount = 0;
for (let i = 0; i < refreshTasks.length; i += CONCURRENCY) { for (let i = 0; i < refreshTasks.length; i += CONCURRENCY) {
await Promise.allSettled(refreshTasks.slice(i, i + CONCURRENCY)); const batch = refreshTasks.slice(i, i + CONCURRENCY);
const results = await Promise.allSettled(batch.map((fn) => fn()));
for (const r of results) {
if (r.status === "rejected") timeoutCount++;
}
// Small delay between batches to avoid overwhelming CW
if (i + CONCURRENCY < refreshTasks.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
}
}
if (timeoutCount > 0) {
console.warn(
`[cache] refresh: ${timeoutCount} task(s) failed (likely CW timeouts) — will retry next cycle`,
);
} }
events.emit("cache:opportunities:refresh:completed", { events.emit("cache:opportunities:refresh:completed", {
totalOpportunities: opportunities.length, totalOpportunities: opportunities.length,
activitiesRefreshed, activitiesRefreshed,
companiesRefreshed, companiesRefreshed,
notesRefreshed,
contactsRefreshed,
productsRefreshed,
oppCwDataRefreshed,
skipped, skipped,
}); });
} }
@@ -115,6 +115,24 @@ export const activityCw = {
return activityCw.fetchAll(`opportunity/id=${opportunityId}`); return activityCw.fetchAll(`opportunity/id=${opportunityId}`);
}, },
/**
* Fetch Activities by Opportunity (Direct)
*
* Lightweight single-call variant that skips the count request.
* Fetches up to 1000 activities in a single GET sufficient for
* virtually all opportunities. Used by the background cache refresh
* to avoid doubling CW API calls.
*/
fetchByOpportunityDirect: async (
opportunityId: number,
): Promise<CWActivity[]> => {
const conditions = encodeURIComponent(`opportunity/id=${opportunityId}`);
const response = await connectWiseApi.get(
`/sales/activities?pageSize=1000&conditions=${conditions}`,
);
return response.data;
},
/** /**
* Create Activity * Create Activity
* *
+142
View File
@@ -0,0 +1,142 @@
/**
* @module cwApiLogger
*
* Axios interceptor-based logger that records every ConnectWise API
* request to a JSONL (newline-delimited JSON) file for post-hoc analysis.
*
* Each line in the log file is a self-contained JSON object with:
* - timestamp (ISO-8601)
* - method, url, baseURL
* - status (HTTP status or null on network error)
* - durationMs (wall-clock time from request start response/error)
* - error (error code / message, if any)
* - timeout (configured timeout in ms)
*
* Logging is **opt-in** set the `LOG_CW_API` environment variable to
* any truthy value to enable it. When enabled, each process start creates
* a new timestamped file inside the `cw-api-logs/` directory:
*
* LOG_CW_API=1 bun run dev # uses cw-api-logs/<timestamp>.jsonl
* bun run dev:log # shorthand (sets LOG_CW_API=1)
*
* Appends are non-blocking (fire-and-forget) to avoid slowing down
* the actual API flow.
*
* Usage:
* import { attachCwApiLogger } from "./modules/cw-utils/cwApiLogger";
* attachCwApiLogger(connectWiseApi);
*/
import { appendFile, mkdir } from "fs/promises";
import path from "path";
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
const LOG_DIR = path.resolve(process.cwd(), "cw-api-logs");
/** Build a timestamped filename like `2026-03-02T14-30-05.123Z.jsonl` */
function buildLogPath(): string {
const ts = new Date().toISOString().replace(/:/g, "-");
return path.join(LOG_DIR, `${ts}.jsonl`);
}
let LOG_PATH: string | null = null;
// Symbol used to stash the start time on the request config
const START_TIME = Symbol("cwLogStartTime");
interface TimedConfig extends InternalAxiosRequestConfig {
[START_TIME]?: number;
}
export interface CwApiLogEntry {
timestamp: string;
method: string;
url: string;
baseURL: string;
status: number | null;
durationMs: number;
error: string | null;
timeout: number | undefined;
}
/** Write a single log entry (fire-and-forget). */
function writeEntry(entry: CwApiLogEntry): void {
if (!LOG_PATH) return;
appendFile(LOG_PATH, JSON.stringify(entry) + "\n").catch((err) => {
// Swallow write errors — logging should never crash the app
console.error("[cw-logger] failed to write log entry:", err.message);
});
}
/**
* Attach request/response interceptors to an Axios instance to log
* every CW API call with timing information.
*/
export function attachCwApiLogger(api: AxiosInstance): void {
if (!process.env.LOG_CW_API) {
return;
}
// Create the log directory and build a unique file path for this run
LOG_PATH = buildLogPath();
mkdir(LOG_DIR, { recursive: true }).catch((err) => {
console.error("[cw-logger] failed to create log directory:", err.message);
});
// ---- Request interceptor: record start time --------------------------
api.interceptors.request.use((config: TimedConfig) => {
config[START_TIME] = performance.now();
return config;
});
// ---- Response interceptor: log successful calls ----------------------
api.interceptors.response.use(
(response) => {
const config = response.config as TimedConfig;
const start = config[START_TIME] ?? performance.now();
const durationMs = Math.round(performance.now() - start);
writeEntry({
timestamp: new Date().toISOString(),
method: (config.method ?? "GET").toUpperCase(),
url: config.url ?? "",
baseURL: config.baseURL ?? "",
status: response.status,
durationMs,
error: null,
timeout: config.timeout,
});
return response;
},
// ---- Error interceptor: log failed calls -----------------------------
(err) => {
const config = (err.config ?? {}) as TimedConfig;
const start = config[START_TIME] ?? performance.now();
const durationMs = Math.round(performance.now() - start);
writeEntry({
timestamp: new Date().toISOString(),
method: (config.method ?? "GET").toUpperCase(),
url: config.url ?? "",
baseURL: config.baseURL ?? "",
status: err.response?.status ?? null,
durationMs,
error: err.code
? `${err.code}: ${err.message}`
: (err.message ?? "unknown"),
timeout: config.timeout,
});
return Promise.reject(err);
},
);
console.log(`[cw-logger] logging CW API calls to ${LOG_PATH}`);
}
/** Returns the current log file path (or null if logging is disabled). */
export function getCwLogPath(): string | null {
return LOG_PATH;
}
@@ -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);
},
);
}
+8 -2
View File
@@ -1,12 +1,18 @@
import { connectWiseApi } from "../../constants"; import { connectWiseApi } from "../../constants";
import { Company } from "../../types/ConnectWiseTypes"; import { Company } from "../../types/ConnectWiseTypes";
import { withCwRetry } from "./withCwRetry";
export const fetchCwCompanyById = async ( export const fetchCwCompanyById = async (
companyId: number, companyId: number,
): Promise<Company | null> => { ): Promise<Company | null> => {
try { try {
const response = await connectWiseApi.get( const response = await withCwRetry(
`/company/companies/${companyId}`, () => connectWiseApi.get(`/company/companies/${companyId}`),
{
label: `fetchCompany#${companyId}`,
maxAttempts: 3,
baseDelayMs: 1_500,
},
); );
return response.data; return response.data;
} catch (error) { } catch (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`,
);
}
}; };
+90
View File
@@ -0,0 +1,90 @@
/**
* Generic retry wrapper for ConnectWise API calls.
*
* Retries on transient errors (ECONNABORTED, ECONNRESET, ETIMEDOUT,
* ECONNREFUSED, ERR_NETWORK) with exponential backoff. Non-transient
* errors (e.g. 404, 400) are re-thrown immediately.
*/
const TRANSIENT_CODES = new Set([
"ECONNABORTED",
"ECONNRESET",
"ETIMEDOUT",
"ECONNREFUSED",
"ERR_NETWORK",
"ENETUNREACH",
]);
export interface CwRetryOptions {
/** Maximum number of attempts (including the first). Default: 3 */
maxAttempts?: number;
/** Base delay in ms before the first retry. Doubles each retry. Default: 1000 */
baseDelayMs?: number;
/** Optional label for log messages. */
label?: string;
}
/**
* Execute `fn` and retry up to `maxAttempts - 1` times on transient
* Axios / network errors.
*/
export async function withCwRetry<T>(
fn: () => Promise<T>,
opts: CwRetryOptions = {},
): Promise<T> {
const { maxAttempts = 3, baseDelayMs = 1_000, label } = opts;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (!isRetryable(err) || attempt === maxAttempts) throw err;
const delay = baseDelayMs * 2 ** (attempt - 1); // 1s, 2s, 4s …
const tag = label ? `[${label}] ` : "";
console.warn(
`${tag}CW transient error (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms — ${describeErr(err)}`,
);
await sleep(delay);
}
}
// Should never reach here, but satisfy TS:
throw lastError;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isRetryable(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false;
const e = err as Record<string, any>;
if (!e.isAxiosError) return false;
// Retry on known transient codes
if (TRANSIENT_CODES.has(e.code)) return true;
// Also retry on 5xx server errors from CW
const status = e.response?.status;
if (typeof status === "number" && status >= 500) return true;
return false;
}
function describeErr(err: unknown): string {
if (typeof err !== "object" || err === null) return String(err);
const e = err as Record<string, any>;
if (e.isAxiosError) {
const method = (e.config?.method ?? "?").toUpperCase();
const url = e.config?.url ?? "unknown";
return `${method} ${url}${e.code ?? `HTTP ${e.response?.status}`}`;
}
return (e as Error).message ?? String(err);
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+23
View File
@@ -186,6 +186,10 @@ interface EventTypes {
totalOpportunities: number; totalOpportunities: number;
activitiesRefreshed: number; activitiesRefreshed: number;
companiesRefreshed: number; companiesRefreshed: number;
notesRefreshed: number;
contactsRefreshed: number;
productsRefreshed: number;
oppCwDataRefreshed: number;
skipped: number; skipped: number;
}) => void; }) => void;
"cache:opportunities:refresh:error": (data: { error: unknown }) => void; "cache:opportunities:refresh:error": (data: { error: unknown }) => void;
@@ -201,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();
}

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