Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d935e7180 | |||
| fe71248e88 | |||
| 7411310083 | |||
| 30b408e0db | |||
| d7b374f8ab | |||
| 883b648d5e | |||
| b787120461 | |||
| 1326725995 |
@@ -24,9 +24,10 @@ Keep each layer focused:
|
||||
|
||||
## Runtime / tooling
|
||||
|
||||
The project runs on **Bun**. DB tooling uses **Prisma**; the generated client lives under `generated/prisma` (do NOT edit generated files). Key scripts in `package.json`:
|
||||
The project runs on **Bun** exclusively — **always use `bun` commands, never `npm`, `npx`, or `yarn`**. DB tooling uses **Prisma**; the generated client lives under `generated/prisma` (do NOT edit generated files). Test preloads are configured in `bunfig.toml` so bare `bun test` works. Key scripts in `package.json`:
|
||||
|
||||
- `dev` — `NODE_ENV=development bun --watch src/index.ts` (start dev server with hot reload)
|
||||
- `test` — `bun test` (runs all tests with preload from `bunfig.toml`)
|
||||
- `db:gen` — `prisma generate`
|
||||
- `db:push` — `prisma migrate dev --skip-generate`
|
||||
- `utils:dev` — `docker compose -f .docker/docker-compose.yml up --build`
|
||||
@@ -36,7 +37,7 @@ The project runs on **Bun**. DB tooling uses **Prisma**; the generated client li
|
||||
|
||||
## Data layer
|
||||
|
||||
Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` for browser type contexts). The shared `prisma` instance is exported from `src/constants.ts`. Always run `npm run db:gen` after updating `schema.prisma`.
|
||||
Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` for browser type contexts). The shared `prisma` instance is exported from `src/constants.ts`. Always run `bun run db:gen` after updating `schema.prisma`.
|
||||
|
||||
## Shared constants (`src/constants.ts`)
|
||||
|
||||
@@ -174,13 +175,14 @@ The `UnifiClient` class in `src/modules/unifi-api/UnifiClient.ts` wraps all UniF
|
||||
|
||||
## Local dev / quick checks
|
||||
|
||||
- Start dev server: `npm run dev`
|
||||
- Regenerate Prisma client: `npm run db:gen`
|
||||
- Apply DB migrations locally: `npm run db:push`
|
||||
- Docker dev utilities: `npm run utils:dev`
|
||||
- Generate private keys: `npm run utils:gen_private_keys`
|
||||
- Create admin role: `npm run utils:create_admin_role`
|
||||
- Assign user role: `npm run utils:assign_user_role`
|
||||
- Start dev server: `bun run dev`
|
||||
- Run tests: `bun test`
|
||||
- Regenerate Prisma client: `bun run db:gen`
|
||||
- Apply DB migrations locally: `bun run db:push`
|
||||
- Docker dev utilities: `bun run utils:dev`
|
||||
- Generate private keys: `bun run utils:gen_private_keys`
|
||||
- Create admin role: `bun run utils:create_admin_role`
|
||||
- Assign user role: `bun run utils:assign_user_role`
|
||||
|
||||
## When editing generated or infra files
|
||||
|
||||
@@ -196,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`.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
*.jsonl
|
||||
cw-api-logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
+848
-28
File diff suppressed because it is too large
Load Diff
+297
@@ -0,0 +1,297 @@
|
||||
# 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 30-second 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` |
|
||||
|
||||
---
|
||||
|
||||
## 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,800,000 ms | 30 minutes |
|
||||
|
||||
Products on terminal-status opportunities are never proactively cached. Non-hot products use a **lazy on-demand** cache — they're fetched when requested and cached for 30 minutes.
|
||||
|
||||
### Site TTL
|
||||
|
||||
Sites use a fixed TTL of **30 minutes** (1,800,000 ms). Site/address data rarely changes. Sites are **not** proactively warmed by the background refresh — they are populated lazily on the first detail-view request.
|
||||
|
||||
---
|
||||
|
||||
## Background Refresh
|
||||
|
||||
**Function:** `refreshOpportunityCache()` in `src/modules/cache/opportunityCache.ts`
|
||||
|
||||
**Interval:** Every 30 seconds, 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.
|
||||
|
||||
### 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 | 30 seconds | 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, 30s)
|
||||
│
|
||||
└─► 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/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 |
|
||||
@@ -0,0 +1,148 @@
|
||||
setInternalReview - The quote is ready to be review before it is ready to be sent.
|
||||
setInternalApproved - The quote has been approved and is ready to be sent out.
|
||||
setQuoteSent - The Quote has been sent to the customer.
|
||||
setQuoteConfirmed - The quote has been recieved by the customer.
|
||||
setRevisionNeeded - The quote needs to be revised and is set to stage revision
|
||||
setFinalized - This locks any non-admins from modifying the quote saying that is the final iteration of the quote.
|
||||
convert - This converts the quote to a ticket. It will also update all the necessary fields.
|
||||
|
||||
addTime(activityId, user: string)
|
||||
|
||||
fetchProducts
|
||||
updateProduct
|
||||
addProduct
|
||||
|
||||
fetchNotes
|
||||
addNotes(note: string, user: string)
|
||||
|
||||
# Cat/SubCat/Bucket
|
||||
|
||||
## Ecosystems vs Categories
|
||||
|
||||
## Ecosystem Tree
|
||||
|
||||
- Networking
|
||||
- Manufacturer: Ubiquiti
|
||||
- Category: Technology
|
||||
- Subcategory: Network-\*
|
||||
- Manufacturer: TP-Link
|
||||
- Category: Technology
|
||||
- Subcategory: Network-\*
|
||||
- Video Surveillance
|
||||
- Manufacturer: Uniview
|
||||
- Category: Field
|
||||
- Subcategory: Surveillance-\*
|
||||
- Manufacturer: Hikvision
|
||||
- Category: Field
|
||||
- Subcategory: Surveillance-\*
|
||||
- Manufacturer: Alarm.com
|
||||
- Category: Field
|
||||
- Subcategory: Surveillance-\*
|
||||
- Burg/Alarm
|
||||
- Manufacturer: Qolsys
|
||||
- Category: Field
|
||||
- Subcategory: AlarmBurg-\*
|
||||
- DSC
|
||||
- Category: Field
|
||||
- Subcategory: AlarmBurg-\*
|
||||
|
||||
## Category Tree
|
||||
|
||||
- Technology
|
||||
- GeneralEquip
|
||||
- Home Entertainment
|
||||
- Monitor
|
||||
- Printers
|
||||
- Storage
|
||||
- Network
|
||||
- Network-Other
|
||||
- Network-Router
|
||||
- Network-Switch
|
||||
- Network-Wireless
|
||||
- Computer
|
||||
- Computer-Components
|
||||
- Computer-Desktop
|
||||
- Computer-Laptop
|
||||
- Recurring
|
||||
- Recurring - Online
|
||||
- Recurring - Other
|
||||
- Recurring - Protection
|
||||
- Recurring - Telephone
|
||||
- Telephone
|
||||
- Tele-HSet-Digital
|
||||
- Tele-HSet-IP
|
||||
- Tele-HSet-SLT
|
||||
- Tele-Misc
|
||||
- Tele-Paging
|
||||
- Tele-SystemCards
|
||||
- Tele-Systems
|
||||
- General
|
||||
- Batteries
|
||||
- Battery Backups
|
||||
- BulkWire
|
||||
- Cables
|
||||
- Cables-Adapters
|
||||
- Cables-HDMI
|
||||
- Cables-Network
|
||||
- Cables-Other
|
||||
- Cables-USB
|
||||
- Cables-VGA
|
||||
- Elec Cords & Adapters
|
||||
- Enclosures
|
||||
- PowerSupply
|
||||
- RackEquip
|
||||
- RackEquip-Rack
|
||||
- RackEquip-Shelves
|
||||
- Field
|
||||
- Conduit
|
||||
- Electric
|
||||
- GateControl
|
||||
- Locksets
|
||||
- Other
|
||||
- Relays
|
||||
- AccessControl
|
||||
- AccessControl-Controllers
|
||||
- AccessControl-Credential
|
||||
- AccessControl-LockDevices
|
||||
- AccessControl-Other
|
||||
- AccessControl-Readers
|
||||
- AccessControl-VideoEntry
|
||||
- AlarmBurg
|
||||
- AlarmBurg-Communicators
|
||||
- AlarmBurg-Keypads
|
||||
- AlarmBurg-Modules
|
||||
- AlarmBurg-Other
|
||||
- AlarmBurg-Panels
|
||||
- AlarmBurg-Sensors
|
||||
- AlarmBurg-Sensors-Wireless
|
||||
- AlarmBurg-Sensors-Wired
|
||||
- AlarmBurg-Siren
|
||||
- AlarmFire
|
||||
- AlarmFire-Communicators
|
||||
- AlarmFire-Devices
|
||||
- AlarmFire-Modules
|
||||
- AlarmFire-Other
|
||||
- AlarmFire-Panels
|
||||
- AlarmFire-Sensors
|
||||
- Automation
|
||||
- Automation-General
|
||||
- Automation-HVAC
|
||||
- Automation-Lights
|
||||
- Automation-Locks
|
||||
- Automation-Thermostat
|
||||
- AV
|
||||
- AV-Adapters&Cables
|
||||
- AV-Components
|
||||
- AV-Mounts
|
||||
- AV-Other
|
||||
- AV-Speakers
|
||||
- AV-Television
|
||||
- StrCbl?
|
||||
- StrCbl-Jacks
|
||||
- StrCbl-PatchPanel
|
||||
- StrCbl-Plates
|
||||
- Surveillance
|
||||
- Surveillance-Accs
|
||||
- Surveillance-CamerasAnalog
|
||||
- Surveillance-CamerasIP
|
||||
- Surveillance-NVR
|
||||
+209
-6
@@ -118,21 +118,53 @@ Admin-specific UI permissions that control visibility and data loading for admin
|
||||
### Procurement Permissions
|
||||
|
||||
| Permission Node | Description | Used In | Dependencies |
|
||||
| --------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
|
||||
| `procurement.catalog.fetch` | Fetch a single catalog item | [src/api/procurement/[id]/fetch.ts](src/api/procurement/[id]/fetch.ts) | |
|
||||
| `procurement.catalog.fetch.many` | Fetch multiple catalog items or count | [src/api/procurement/fetchAll.ts](src/api/procurement/fetchAll.ts), [src/api/procurement/count.ts](src/api/procurement/count.ts) | |
|
||||
| `procurement.catalog.fetch.many` | Fetch multiple catalog items, count, categories/ecosystems, or filter values | [src/api/procurement/fetchAll.ts](src/api/procurement/fetchAll.ts), [src/api/procurement/count.ts](src/api/procurement/count.ts), [src/api/procurement/categories.ts](src/api/procurement/categories.ts), [src/api/procurement/filters.ts](src/api/procurement/filters.ts) | |
|
||||
| `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` |
|
||||
|
||||
### Sales Permissions
|
||||
|
||||
Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (forecasts, 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 |
|
||||
| ------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
|
||||
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (forecasts, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/forecasts.ts](src/api/sales/[id]/forecasts.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | |
|
||||
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable) or get opportunity count | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts) | |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
|
||||
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/products.ts](src/api/sales/[id]/products.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/fetchNote.ts](src/api/sales/[id]/fetchNote.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | |
|
||||
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.ts) | |
|
||||
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/[id]/createNote.ts](src/api/sales/[id]/createNote.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/[id]/updateNote.ts](src/api/sales/[id]/updateNote.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<field>` permissions. | [src/api/sales/[id]/addProduct.ts](src/api/sales/[id]/addProduct.ts) | `sales.opportunity.fetch` |
|
||||
|
||||
<details>
|
||||
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
|
||||
|
||||
Each submitted field is gated by a `sales.opportunity.product.field.<field>` permission node. Only fields the user has permission for are forwarded to ConnectWise.
|
||||
|
||||
| Field Permission Node | Description |
|
||||
| ----------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `sales.opportunity.product.field.catalogItem` | Set the catalog item reference |
|
||||
| `sales.opportunity.product.field.forecastDescription` | Set the forecast description |
|
||||
| `sales.opportunity.product.field.productDescription` | Set the product description |
|
||||
| `sales.opportunity.product.field.quantity` | Set the quantity |
|
||||
| `sales.opportunity.product.field.status` | Set the status reference |
|
||||
| `sales.opportunity.product.field.productClass` | Set the product class (e.g. Product, Service, Agreement) |
|
||||
| `sales.opportunity.product.field.forecastType` | Set the forecast type |
|
||||
| `sales.opportunity.product.field.revenue` | Set the revenue amount |
|
||||
| `sales.opportunity.product.field.cost` | Set the cost amount |
|
||||
| `sales.opportunity.product.field.includeFlag` | Set the include flag |
|
||||
| `sales.opportunity.product.field.linkFlag` | Set the link flag |
|
||||
| `sales.opportunity.product.field.recurringFlag` | Set the recurring flag |
|
||||
| `sales.opportunity.product.field.taxableFlag` | Set the taxable flag |
|
||||
| `sales.opportunity.product.field.recurringRevenue` | Set the recurring revenue amount |
|
||||
| `sales.opportunity.product.field.recurringCost` | Set the recurring cost amount |
|
||||
| `sales.opportunity.product.field.cycles` | Set the number of recurring cycles |
|
||||
| `sales.opportunity.product.field.sequenceNumber` | Set the sequence number (display order) |
|
||||
|
||||
</details>
|
||||
|
||||
### UniFi Permissions
|
||||
|
||||
@@ -171,6 +203,177 @@ The WiFi fetch route uses `processObjectValuePerms` to filter each WLAN object o
|
||||
| `unifi.site.wifi.ppsk` | View private pre-shared keys (PPSKs) for a specific WiFi network | [src/api/unifi/site/wifi/ppskFetchAll.ts](src/api/unifi/site/wifi/ppskFetchAll.ts), [src/api/unifi/site/wifi/ppskCreate.ts](src/api/unifi/site/wifi/ppskCreate.ts) | `unifi.access`, `unifi.site.wifi` |
|
||||
| `unifi.site.wifi.ppsk.create` | Create a private pre-shared key on a specific WiFi network | [src/api/unifi/site/wifi/ppskCreate.ts](src/api/unifi/site/wifi/ppskCreate.ts) | `unifi.access`, `unifi.site.wifi`, `unifi.site.wifi.ppsk` |
|
||||
|
||||
---
|
||||
|
||||
## Object Type Permissions (Field-Level Gating)
|
||||
|
||||
All fetch and fetchAll routes gate response object keys using `processObjectValuePerms`. For each object type, only fields whose corresponding `<scope>.<field>` permission the user holds are included in the response. Grant `<scope>.*` to allow all fields on that object type.
|
||||
|
||||
### Company (`obj.company`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| --------------------------- | ----------------------------------------- |
|
||||
| `obj.company.id` | View company ID |
|
||||
| `obj.company.name` | View company name |
|
||||
| `obj.company.cw_Identifier` | View ConnectWise identifier |
|
||||
| `obj.company.cw_CompanyId` | View ConnectWise company ID |
|
||||
| `obj.company.cw_Data` | View ConnectWise data (address, contacts) |
|
||||
| `obj.company.createdAt` | View creation timestamp |
|
||||
| `obj.company.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts), [src/api/companies/fetchAll.ts](src/api/companies/fetchAll.ts)
|
||||
|
||||
### Credential (`obj.credential`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ---------------------------------- | ----------------------------- |
|
||||
| `obj.credential.id` | View credential ID |
|
||||
| `obj.credential.name` | View credential name |
|
||||
| `obj.credential.notes` | View credential notes |
|
||||
| `obj.credential.typeId` | View credential type ID |
|
||||
| `obj.credential.companyId` | View linked company ID |
|
||||
| `obj.credential.subCredentialOfId` | View parent credential ID |
|
||||
| `obj.credential.fields` | View credential field values |
|
||||
| `obj.credential.type` | View credential type object |
|
||||
| `obj.credential.company` | View linked company object |
|
||||
| `obj.credential.subCredentials` | View sub-credentials array |
|
||||
| `obj.credential.secureFieldIds` | View secure field identifiers |
|
||||
| `obj.credential.createdAt` | View creation timestamp |
|
||||
| `obj.credential.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/credentials/fetch.ts](src/api/credentials/fetch.ts), [src/api/credentials/fetchByCompany.ts](src/api/credentials/fetchByCompany.ts), [src/api/credentials/fetchSubCredentials.ts](src/api/credentials/fetchSubCredentials.ts), [src/api/credential-types/fetchCredentials.ts](src/api/credential-types/fetchCredentials.ts)
|
||||
|
||||
### Credential Type (`obj.credentialType`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ------------------------------------ | ----------------------------------------- |
|
||||
| `obj.credentialType.id` | View credential type ID |
|
||||
| `obj.credentialType.name` | View credential type name |
|
||||
| `obj.credentialType.permissionScope` | View permission scope |
|
||||
| `obj.credentialType.icon` | View icon |
|
||||
| `obj.credentialType.fields` | View field definitions |
|
||||
| `obj.credentialType.credentialCount` | View count of credentials using this type |
|
||||
| `obj.credentialType.createdAt` | View creation timestamp |
|
||||
| `obj.credentialType.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/credential-types/fetch.ts](src/api/credential-types/fetch.ts), [src/api/credential-types/fetchAll.ts](src/api/credential-types/fetchAll.ts)
|
||||
|
||||
### User (`obj.user`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ---------------------- | -------------------------------- |
|
||||
| `obj.user.id` | View user ID |
|
||||
| `obj.user.name` | View user display name |
|
||||
| `obj.user.roles` | View assigned role monikers |
|
||||
| `obj.user.permissions` | View aggregated permission nodes |
|
||||
| `obj.user.login` | View login identifier |
|
||||
| `obj.user.email` | View email address |
|
||||
| `obj.user.image` | View profile image URL |
|
||||
| `obj.user.createdAt` | View creation timestamp |
|
||||
| `obj.user.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/user/@me/fetch.ts](src/api/user/@me/fetch.ts), [src/api/user/fetch.ts](src/api/user/fetch.ts), [src/api/user/fetchAll.ts](src/api/user/fetchAll.ts), [src/api/roles/getUsers.ts](src/api/roles/getUsers.ts)
|
||||
|
||||
### Role (`obj.role`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ---------------------- | -------------------------------- |
|
||||
| `obj.role.id` | View role ID |
|
||||
| `obj.role.title` | View role title |
|
||||
| `obj.role.moniker` | View role moniker |
|
||||
| `obj.role.permissions` | View role permission nodes |
|
||||
| `obj.role.users` | View users assigned to this role |
|
||||
| `obj.role.createdAt` | View creation timestamp |
|
||||
| `obj.role.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/roles/fetch.ts](src/api/roles/fetch.ts), [src/api/roles/fetchAll.ts](src/api/roles/fetchAll.ts), [src/api/user/fetchRoles.ts](src/api/user/fetchRoles.ts)
|
||||
|
||||
### Catalog Item (`obj.catalogItem`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ------------------------------------- | -------------------------------- |
|
||||
| `obj.catalogItem.id` | View catalog item ID |
|
||||
| `obj.catalogItem.cwCatalogId` | View ConnectWise catalog ID |
|
||||
| `obj.catalogItem.identifier` | View item identifier |
|
||||
| `obj.catalogItem.name` | View item name |
|
||||
| `obj.catalogItem.description` | View description |
|
||||
| `obj.catalogItem.customerDescription` | View customer-facing description |
|
||||
| `obj.catalogItem.internalNotes` | View internal notes |
|
||||
| `obj.catalogItem.manufacturer` | View manufacturer name |
|
||||
| `obj.catalogItem.manufactureCwId` | View manufacturer ConnectWise ID |
|
||||
| `obj.catalogItem.partNumber` | View part number |
|
||||
| `obj.catalogItem.vendorName` | View vendor name |
|
||||
| `obj.catalogItem.vendorSku` | View vendor SKU |
|
||||
| `obj.catalogItem.vendorCwId` | View vendor ConnectWise ID |
|
||||
| `obj.catalogItem.price` | View price |
|
||||
| `obj.catalogItem.cost` | View cost |
|
||||
| `obj.catalogItem.inactive` | View inactive flag |
|
||||
| `obj.catalogItem.salesTaxable` | View sales-taxable flag |
|
||||
| `obj.catalogItem.onHand` | View on-hand inventory count |
|
||||
| `obj.catalogItem.cwLastUpdated` | View CW last-updated timestamp |
|
||||
| `obj.catalogItem.linkedItems` | View linked catalog items |
|
||||
| `obj.catalogItem.createdAt` | View creation timestamp |
|
||||
| `obj.catalogItem.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/procurement/fetchAll.ts](src/api/procurement/fetchAll.ts), [src/api/procurement/[id]/fetch.ts](src/api/procurement/[id]/fetch.ts), [src/api/procurement/[id]/fetchLinked.ts](src/api/procurement/[id]/fetchLinked.ts)
|
||||
|
||||
### Opportunity (`obj.opportunity`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ------------------------------------ | ------------------------------- |
|
||||
| `obj.opportunity.id` | View opportunity ID |
|
||||
| `obj.opportunity.cwOpportunityId` | View ConnectWise opportunity ID |
|
||||
| `obj.opportunity.name` | View opportunity name |
|
||||
| `obj.opportunity.notes` | View notes |
|
||||
| `obj.opportunity.type` | View opportunity type |
|
||||
| `obj.opportunity.stage` | View stage |
|
||||
| `obj.opportunity.status` | View status |
|
||||
| `obj.opportunity.priority` | View priority |
|
||||
| `obj.opportunity.rating` | View rating |
|
||||
| `obj.opportunity.source` | View source |
|
||||
| `obj.opportunity.campaign` | View campaign |
|
||||
| `obj.opportunity.primarySalesRep` | View primary sales rep |
|
||||
| `obj.opportunity.secondarySalesRep` | View secondary sales rep |
|
||||
| `obj.opportunity.company` | View company |
|
||||
| `obj.opportunity.contact` | View contact |
|
||||
| `obj.opportunity.site` | View site |
|
||||
| `obj.opportunity.customerPO` | View customer PO |
|
||||
| `obj.opportunity.totalSalesTax` | View total sales tax |
|
||||
| `obj.opportunity.location` | View location |
|
||||
| `obj.opportunity.department` | View department |
|
||||
| `obj.opportunity.expectedCloseDate` | View expected close date |
|
||||
| `obj.opportunity.pipelineChangeDate` | View pipeline change date |
|
||||
| `obj.opportunity.dateBecameLead` | View date became lead |
|
||||
| `obj.opportunity.closedDate` | View closed date |
|
||||
| `obj.opportunity.closedFlag` | View closed flag |
|
||||
| `obj.opportunity.closedBy` | View closed-by member |
|
||||
| `obj.opportunity.companyId` | View linked company ID |
|
||||
| `obj.opportunity.cwLastUpdated` | View CW last-updated timestamp |
|
||||
| `obj.opportunity.createdAt` | View creation timestamp |
|
||||
| `obj.opportunity.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts)
|
||||
|
||||
### UniFi Site (`obj.unifiSite`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ------------------------- | ----------------------------- |
|
||||
| `obj.unifiSite.id` | View site internal ID |
|
||||
| `obj.unifiSite.name` | View site name |
|
||||
| `obj.unifiSite.siteId` | View UniFi controller site ID |
|
||||
| `obj.unifiSite.companyId` | View linked company ID |
|
||||
| `obj.unifiSite.company` | View linked company object |
|
||||
| `obj.unifiSite.createdAt` | View creation timestamp |
|
||||
| `obj.unifiSite.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/unifi/sites/fetchAll.ts](src/api/unifi/sites/fetchAll.ts), [src/api/unifi/site/fetch.ts](src/api/unifi/site/fetch.ts), [src/api/companies/[id]/unifiSites.ts](src/api/companies/[id]/unifiSites.ts)
|
||||
|
||||
### WiFi Network (`unifi.site.wifi.read`)
|
||||
|
||||
See **UniFi Permissions > Field-Level Permission Gating** above for the full list of `unifi.site.wifi.read.<field>` nodes.
|
||||
|
||||
---
|
||||
|
||||
## Permission Issuers
|
||||
|
||||
Permissions can be issued by different sources:
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"cuid": "^3.0.0",
|
||||
"hono": "^4.11.5",
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"keypair": "^1.0.4",
|
||||
"prisma": "^7.3.0",
|
||||
@@ -57,6 +58,8 @@
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
|
||||
|
||||
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
|
||||
|
||||
"@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -131,6 +134,8 @@
|
||||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
|
||||
@@ -223,6 +228,8 @@
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="],
|
||||
|
||||
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
@@ -241,8 +248,12 @@
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
|
||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
||||
|
||||
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||
|
||||
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
|
||||
@@ -331,6 +342,10 @@
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||
|
||||
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||
|
||||
"regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="],
|
||||
|
||||
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="],
|
||||
@@ -363,6 +378,8 @@
|
||||
|
||||
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./tests/setup.ts"]
|
||||
@@ -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()
|
||||
File diff suppressed because one or more lines are too long
@@ -1213,6 +1213,7 @@ export const UserScalarFieldEnum = {
|
||||
email: 'email',
|
||||
emailVerified: 'emailVerified',
|
||||
image: 'image',
|
||||
cwIdentifier: 'cwIdentifier',
|
||||
userId: 'userId',
|
||||
token: 'token',
|
||||
createdAt: 'createdAt',
|
||||
@@ -1266,6 +1267,10 @@ export const CatalogItemScalarFieldEnum = {
|
||||
description: 'description',
|
||||
customerDescription: 'customerDescription',
|
||||
internalNotes: 'internalNotes',
|
||||
category: 'category',
|
||||
categoryCwId: 'categoryCwId',
|
||||
subcategory: 'subcategory',
|
||||
subcategoryCwId: 'subcategoryCwId',
|
||||
manufacturer: 'manufacturer',
|
||||
manufactureCwId: 'manufactureCwId',
|
||||
partNumber: 'partNumber',
|
||||
@@ -1329,6 +1334,7 @@ export const OpportunityScalarFieldEnum = {
|
||||
closedByName: 'closedByName',
|
||||
closedByCwId: 'closedByCwId',
|
||||
companyId: 'companyId',
|
||||
productSequence: 'productSequence',
|
||||
cwLastUpdated: 'cwLastUpdated',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
|
||||
@@ -100,6 +100,7 @@ export const UserScalarFieldEnum = {
|
||||
email: 'email',
|
||||
emailVerified: 'emailVerified',
|
||||
image: 'image',
|
||||
cwIdentifier: 'cwIdentifier',
|
||||
userId: 'userId',
|
||||
token: 'token',
|
||||
createdAt: 'createdAt',
|
||||
@@ -153,6 +154,10 @@ export const CatalogItemScalarFieldEnum = {
|
||||
description: 'description',
|
||||
customerDescription: 'customerDescription',
|
||||
internalNotes: 'internalNotes',
|
||||
category: 'category',
|
||||
categoryCwId: 'categoryCwId',
|
||||
subcategory: 'subcategory',
|
||||
subcategoryCwId: 'subcategoryCwId',
|
||||
manufacturer: 'manufacturer',
|
||||
manufactureCwId: 'manufactureCwId',
|
||||
partNumber: 'partNumber',
|
||||
@@ -216,6 +221,7 @@ export const OpportunityScalarFieldEnum = {
|
||||
closedByName: 'closedByName',
|
||||
closedByCwId: 'closedByCwId',
|
||||
companyId: 'companyId',
|
||||
productSequence: 'productSequence',
|
||||
cwLastUpdated: 'cwLastUpdated',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
|
||||
@@ -28,6 +28,8 @@ export type AggregateCatalogItem = {
|
||||
|
||||
export type CatalogItemAvgAggregateOutputType = {
|
||||
cwCatalogId: number | null
|
||||
categoryCwId: number | null
|
||||
subcategoryCwId: number | null
|
||||
manufactureCwId: number | null
|
||||
vendorCwId: number | null
|
||||
price: number | null
|
||||
@@ -37,6 +39,8 @@ export type CatalogItemAvgAggregateOutputType = {
|
||||
|
||||
export type CatalogItemSumAggregateOutputType = {
|
||||
cwCatalogId: number | null
|
||||
categoryCwId: number | null
|
||||
subcategoryCwId: number | null
|
||||
manufactureCwId: number | null
|
||||
vendorCwId: number | null
|
||||
price: number | null
|
||||
@@ -52,6 +56,10 @@ export type CatalogItemMinAggregateOutputType = {
|
||||
description: string | null
|
||||
customerDescription: string | null
|
||||
internalNotes: string | null
|
||||
category: string | null
|
||||
categoryCwId: number | null
|
||||
subcategory: string | null
|
||||
subcategoryCwId: number | null
|
||||
manufacturer: string | null
|
||||
manufactureCwId: number | null
|
||||
partNumber: string | null
|
||||
@@ -76,6 +84,10 @@ export type CatalogItemMaxAggregateOutputType = {
|
||||
description: string | null
|
||||
customerDescription: string | null
|
||||
internalNotes: string | null
|
||||
category: string | null
|
||||
categoryCwId: number | null
|
||||
subcategory: string | null
|
||||
subcategoryCwId: number | null
|
||||
manufacturer: string | null
|
||||
manufactureCwId: number | null
|
||||
partNumber: string | null
|
||||
@@ -100,6 +112,10 @@ export type CatalogItemCountAggregateOutputType = {
|
||||
description: number
|
||||
customerDescription: number
|
||||
internalNotes: number
|
||||
category: number
|
||||
categoryCwId: number
|
||||
subcategory: number
|
||||
subcategoryCwId: number
|
||||
manufacturer: number
|
||||
manufactureCwId: number
|
||||
partNumber: number
|
||||
@@ -120,6 +136,8 @@ export type CatalogItemCountAggregateOutputType = {
|
||||
|
||||
export type CatalogItemAvgAggregateInputType = {
|
||||
cwCatalogId?: true
|
||||
categoryCwId?: true
|
||||
subcategoryCwId?: true
|
||||
manufactureCwId?: true
|
||||
vendorCwId?: true
|
||||
price?: true
|
||||
@@ -129,6 +147,8 @@ export type CatalogItemAvgAggregateInputType = {
|
||||
|
||||
export type CatalogItemSumAggregateInputType = {
|
||||
cwCatalogId?: true
|
||||
categoryCwId?: true
|
||||
subcategoryCwId?: true
|
||||
manufactureCwId?: true
|
||||
vendorCwId?: true
|
||||
price?: true
|
||||
@@ -144,6 +164,10 @@ export type CatalogItemMinAggregateInputType = {
|
||||
description?: true
|
||||
customerDescription?: true
|
||||
internalNotes?: true
|
||||
category?: true
|
||||
categoryCwId?: true
|
||||
subcategory?: true
|
||||
subcategoryCwId?: true
|
||||
manufacturer?: true
|
||||
manufactureCwId?: true
|
||||
partNumber?: true
|
||||
@@ -168,6 +192,10 @@ export type CatalogItemMaxAggregateInputType = {
|
||||
description?: true
|
||||
customerDescription?: true
|
||||
internalNotes?: true
|
||||
category?: true
|
||||
categoryCwId?: true
|
||||
subcategory?: true
|
||||
subcategoryCwId?: true
|
||||
manufacturer?: true
|
||||
manufactureCwId?: true
|
||||
partNumber?: true
|
||||
@@ -192,6 +220,10 @@ export type CatalogItemCountAggregateInputType = {
|
||||
description?: true
|
||||
customerDescription?: true
|
||||
internalNotes?: true
|
||||
category?: true
|
||||
categoryCwId?: true
|
||||
subcategory?: true
|
||||
subcategoryCwId?: true
|
||||
manufacturer?: true
|
||||
manufactureCwId?: true
|
||||
partNumber?: true
|
||||
@@ -303,6 +335,10 @@ export type CatalogItemGroupByOutputType = {
|
||||
description: string | null
|
||||
customerDescription: string | null
|
||||
internalNotes: string | null
|
||||
category: string | null
|
||||
categoryCwId: number | null
|
||||
subcategory: string | null
|
||||
subcategoryCwId: number | null
|
||||
manufacturer: string | null
|
||||
manufactureCwId: number | null
|
||||
partNumber: string | null
|
||||
@@ -350,6 +386,10 @@ export type CatalogItemWhereInput = {
|
||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
internalNotes?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
category?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
categoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
subcategory?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
subcategoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
manufacturer?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
@@ -376,6 +416,10 @@ export type CatalogItemOrderByWithRelationInput = {
|
||||
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
category?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
@@ -405,6 +449,10 @@ export type CatalogItemWhereUniqueInput = Prisma.AtLeast<{
|
||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
internalNotes?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
category?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
categoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
subcategory?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
subcategoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
manufacturer?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
@@ -431,6 +479,10 @@ export type CatalogItemOrderByWithAggregationInput = {
|
||||
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
category?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
@@ -463,6 +515,10 @@ export type CatalogItemScalarWhereWithAggregatesInput = {
|
||||
description?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
customerDescription?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
internalNotes?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
category?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
categoryCwId?: Prisma.IntNullableWithAggregatesFilter<"CatalogItem"> | number | null
|
||||
subcategory?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
subcategoryCwId?: Prisma.IntNullableWithAggregatesFilter<"CatalogItem"> | number | null
|
||||
manufacturer?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
manufactureCwId?: Prisma.IntNullableWithAggregatesFilter<"CatalogItem"> | number | null
|
||||
partNumber?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
@@ -487,6 +543,10 @@ export type CatalogItemCreateInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -513,6 +573,10 @@ export type CatalogItemUncheckedCreateInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -539,6 +603,10 @@ export type CatalogItemUpdateInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -565,6 +633,10 @@ export type CatalogItemUncheckedUpdateInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -591,6 +663,10 @@ export type CatalogItemCreateManyInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -615,6 +691,10 @@ export type CatalogItemUpdateManyMutationInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -639,6 +719,10 @@ export type CatalogItemUncheckedUpdateManyInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -673,6 +757,10 @@ export type CatalogItemCountOrderByAggregateInput = {
|
||||
description?: Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrder
|
||||
category?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrder
|
||||
@@ -691,6 +779,8 @@ export type CatalogItemCountOrderByAggregateInput = {
|
||||
|
||||
export type CatalogItemAvgOrderByAggregateInput = {
|
||||
cwCatalogId?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
vendorCwId?: Prisma.SortOrder
|
||||
price?: Prisma.SortOrder
|
||||
@@ -706,6 +796,10 @@ export type CatalogItemMaxOrderByAggregateInput = {
|
||||
description?: Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrder
|
||||
category?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrder
|
||||
@@ -730,6 +824,10 @@ export type CatalogItemMinOrderByAggregateInput = {
|
||||
description?: Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrder
|
||||
category?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrder
|
||||
@@ -748,6 +846,8 @@ export type CatalogItemMinOrderByAggregateInput = {
|
||||
|
||||
export type CatalogItemSumOrderByAggregateInput = {
|
||||
cwCatalogId?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
vendorCwId?: Prisma.SortOrder
|
||||
price?: Prisma.SortOrder
|
||||
@@ -855,6 +955,10 @@ export type CatalogItemCreateWithoutLinkedToInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -880,6 +984,10 @@ export type CatalogItemUncheckedCreateWithoutLinkedToInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -910,6 +1018,10 @@ export type CatalogItemCreateWithoutLinkedItemsInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -935,6 +1047,10 @@ export type CatalogItemUncheckedCreateWithoutLinkedItemsInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -984,6 +1100,10 @@ export type CatalogItemScalarWhereInput = {
|
||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
internalNotes?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
category?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
categoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
subcategory?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
subcategoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
manufacturer?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
@@ -1024,6 +1144,10 @@ export type CatalogItemUpdateWithoutLinkedToInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1049,6 +1173,10 @@ export type CatalogItemUncheckedUpdateWithoutLinkedToInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1074,6 +1202,10 @@ export type CatalogItemUncheckedUpdateManyWithoutLinkedToInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1098,6 +1230,10 @@ export type CatalogItemUpdateWithoutLinkedItemsInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1123,6 +1259,10 @@ export type CatalogItemUncheckedUpdateWithoutLinkedItemsInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1148,6 +1288,10 @@ export type CatalogItemUncheckedUpdateManyWithoutLinkedItemsInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1212,6 +1356,10 @@ export type CatalogItemSelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
||||
description?: boolean
|
||||
customerDescription?: boolean
|
||||
internalNotes?: boolean
|
||||
category?: boolean
|
||||
categoryCwId?: boolean
|
||||
subcategory?: boolean
|
||||
subcategoryCwId?: boolean
|
||||
manufacturer?: boolean
|
||||
manufactureCwId?: boolean
|
||||
partNumber?: boolean
|
||||
@@ -1239,6 +1387,10 @@ export type CatalogItemSelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
description?: boolean
|
||||
customerDescription?: boolean
|
||||
internalNotes?: boolean
|
||||
category?: boolean
|
||||
categoryCwId?: boolean
|
||||
subcategory?: boolean
|
||||
subcategoryCwId?: boolean
|
||||
manufacturer?: boolean
|
||||
manufactureCwId?: boolean
|
||||
partNumber?: boolean
|
||||
@@ -1263,6 +1415,10 @@ export type CatalogItemSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
description?: boolean
|
||||
customerDescription?: boolean
|
||||
internalNotes?: boolean
|
||||
category?: boolean
|
||||
categoryCwId?: boolean
|
||||
subcategory?: boolean
|
||||
subcategoryCwId?: boolean
|
||||
manufacturer?: boolean
|
||||
manufactureCwId?: boolean
|
||||
partNumber?: boolean
|
||||
@@ -1287,6 +1443,10 @@ export type CatalogItemSelectScalar = {
|
||||
description?: boolean
|
||||
customerDescription?: boolean
|
||||
internalNotes?: boolean
|
||||
category?: boolean
|
||||
categoryCwId?: boolean
|
||||
subcategory?: boolean
|
||||
subcategoryCwId?: boolean
|
||||
manufacturer?: boolean
|
||||
manufactureCwId?: boolean
|
||||
partNumber?: boolean
|
||||
@@ -1303,7 +1463,7 @@ export type CatalogItemSelectScalar = {
|
||||
updatedAt?: boolean
|
||||
}
|
||||
|
||||
export type CatalogItemOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwCatalogId" | "identifier" | "name" | "description" | "customerDescription" | "internalNotes" | "manufacturer" | "manufactureCwId" | "partNumber" | "vendorName" | "vendorSku" | "vendorCwId" | "price" | "cost" | "inactive" | "salesTaxable" | "onHand" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["catalogItem"]>
|
||||
export type CatalogItemOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwCatalogId" | "identifier" | "name" | "description" | "customerDescription" | "internalNotes" | "category" | "categoryCwId" | "subcategory" | "subcategoryCwId" | "manufacturer" | "manufactureCwId" | "partNumber" | "vendorName" | "vendorSku" | "vendorCwId" | "price" | "cost" | "inactive" | "salesTaxable" | "onHand" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["catalogItem"]>
|
||||
export type CatalogItemInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
linkedItems?: boolean | Prisma.CatalogItem$linkedItemsArgs<ExtArgs>
|
||||
linkedTo?: boolean | Prisma.CatalogItem$linkedToArgs<ExtArgs>
|
||||
@@ -1326,6 +1486,10 @@ export type $CatalogItemPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
||||
description: string | null
|
||||
customerDescription: string | null
|
||||
internalNotes: string | null
|
||||
category: string | null
|
||||
categoryCwId: number | null
|
||||
subcategory: string | null
|
||||
subcategoryCwId: number | null
|
||||
manufacturer: string | null
|
||||
manufactureCwId: number | null
|
||||
partNumber: string | null
|
||||
@@ -1772,6 +1936,10 @@ export interface CatalogItemFieldRefs {
|
||||
readonly description: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly customerDescription: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly internalNotes: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly category: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly categoryCwId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
||||
readonly subcategory: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly subcategoryCwId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
||||
readonly manufacturer: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly manufactureCwId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
||||
readonly partNumber: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
|
||||
@@ -43,6 +43,7 @@ export type OpportunityAvgAggregateOutputType = {
|
||||
locationCwId: number | null
|
||||
departmentCwId: number | null
|
||||
closedByCwId: number | null
|
||||
productSequence: number | null
|
||||
}
|
||||
|
||||
export type OpportunitySumAggregateOutputType = {
|
||||
@@ -62,6 +63,7 @@ export type OpportunitySumAggregateOutputType = {
|
||||
locationCwId: number | null
|
||||
departmentCwId: number | null
|
||||
closedByCwId: number | null
|
||||
productSequence: number[]
|
||||
}
|
||||
|
||||
export type OpportunityMinAggregateOutputType = {
|
||||
@@ -206,6 +208,7 @@ export type OpportunityCountAggregateOutputType = {
|
||||
closedByName: number
|
||||
closedByCwId: number
|
||||
companyId: number
|
||||
productSequence: number
|
||||
cwLastUpdated: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
@@ -230,6 +233,7 @@ export type OpportunityAvgAggregateInputType = {
|
||||
locationCwId?: true
|
||||
departmentCwId?: true
|
||||
closedByCwId?: true
|
||||
productSequence?: true
|
||||
}
|
||||
|
||||
export type OpportunitySumAggregateInputType = {
|
||||
@@ -249,6 +253,7 @@ export type OpportunitySumAggregateInputType = {
|
||||
locationCwId?: true
|
||||
departmentCwId?: true
|
||||
closedByCwId?: true
|
||||
productSequence?: true
|
||||
}
|
||||
|
||||
export type OpportunityMinAggregateInputType = {
|
||||
@@ -393,6 +398,7 @@ export type OpportunityCountAggregateInputType = {
|
||||
closedByName?: true
|
||||
closedByCwId?: true
|
||||
companyId?: true
|
||||
productSequence?: true
|
||||
cwLastUpdated?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
@@ -529,6 +535,7 @@ export type OpportunityGroupByOutputType = {
|
||||
closedByName: string | null
|
||||
closedByCwId: number | null
|
||||
companyId: string | null
|
||||
productSequence: number[]
|
||||
cwLastUpdated: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
@@ -601,6 +608,7 @@ export type OpportunityWhereInput = {
|
||||
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
@@ -651,6 +659,7 @@ export type OpportunityOrderByWithRelationInput = {
|
||||
closedByName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
@@ -704,6 +713,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
|
||||
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
@@ -754,6 +764,7 @@ export type OpportunityOrderByWithAggregationInput = {
|
||||
closedByName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
@@ -811,6 +822,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
|
||||
closedByName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
closedByCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null
|
||||
companyId?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||
cwLastUpdated?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
||||
@@ -859,6 +871,7 @@ export type OpportunityCreateInput = {
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -909,6 +922,7 @@ export type OpportunityUncheckedCreateInput = {
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
companyId?: string | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -957,6 +971,7 @@ export type OpportunityUpdateInput = {
|
||||
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
|
||||
@@ -1007,6 +1022,7 @@ export type OpportunityUncheckedUpdateInput = {
|
||||
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
|
||||
@@ -1056,6 +1072,7 @@ export type OpportunityCreateManyInput = {
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
companyId?: string | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -1104,6 +1121,7 @@ export type OpportunityUpdateManyMutationInput = {
|
||||
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
|
||||
@@ -1153,6 +1171,7 @@ export type OpportunityUncheckedUpdateManyInput = {
|
||||
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
|
||||
@@ -1168,6 +1187,14 @@ export type OpportunityOrderByRelationAggregateInput = {
|
||||
_count?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type IntNullableListFilter<$PrismaModel = never> = {
|
||||
equals?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
has?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
hasEvery?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
hasSome?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
isEmpty?: boolean
|
||||
}
|
||||
|
||||
export type OpportunityCountOrderByAggregateInput = {
|
||||
id?: Prisma.SortOrder
|
||||
cwOpportunityId?: Prisma.SortOrder
|
||||
@@ -1212,6 +1239,7 @@ export type OpportunityCountOrderByAggregateInput = {
|
||||
closedByName?: Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrder
|
||||
companyId?: Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
cwLastUpdated?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
@@ -1234,6 +1262,7 @@ export type OpportunityAvgOrderByAggregateInput = {
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentCwId?: Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type OpportunityMaxOrderByAggregateInput = {
|
||||
@@ -1351,6 +1380,7 @@ export type OpportunitySumOrderByAggregateInput = {
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentCwId?: Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type OpportunityCreateNestedManyWithoutCompanyInput = {
|
||||
@@ -1395,6 +1425,15 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyNestedInput = {
|
||||
deleteMany?: Prisma.OpportunityScalarWhereInput | Prisma.OpportunityScalarWhereInput[]
|
||||
}
|
||||
|
||||
export type OpportunityCreateproductSequenceInput = {
|
||||
set: number[]
|
||||
}
|
||||
|
||||
export type OpportunityUpdateproductSequenceInput = {
|
||||
set?: number[]
|
||||
push?: number | number[]
|
||||
}
|
||||
|
||||
export type OpportunityCreateWithoutCompanyInput = {
|
||||
id?: string
|
||||
cwOpportunityId: number
|
||||
@@ -1438,6 +1477,7 @@ export type OpportunityCreateWithoutCompanyInput = {
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -1486,6 +1526,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -1564,6 +1605,7 @@ export type OpportunityScalarWhereInput = {
|
||||
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
@@ -1612,6 +1654,7 @@ export type OpportunityCreateManyCompanyInput = {
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -1660,6 +1703,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
|
||||
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
|
||||
@@ -1708,6 +1752,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
|
||||
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
|
||||
@@ -1756,6 +1801,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
|
||||
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
|
||||
@@ -1807,6 +1853,7 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
||||
closedByName?: boolean
|
||||
closedByCwId?: boolean
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
@@ -1857,6 +1904,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
closedByName?: boolean
|
||||
closedByCwId?: boolean
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
@@ -1907,6 +1955,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
closedByName?: boolean
|
||||
closedByCwId?: boolean
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
@@ -1957,12 +2006,13 @@ export type OpportunitySelectScalar = {
|
||||
closedByName?: boolean
|
||||
closedByCwId?: boolean
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
}
|
||||
|
||||
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "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" | "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> = {
|
||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||
}
|
||||
@@ -2022,6 +2072,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
||||
closedByName: string | null
|
||||
closedByCwId: number | null
|
||||
companyId: string | null
|
||||
productSequence: number[]
|
||||
cwLastUpdated: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
@@ -2492,6 +2543,7 @@ export interface OpportunityFieldRefs {
|
||||
readonly closedByName: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
readonly closedByCwId: Prisma.FieldRef<"Opportunity", 'Int'>
|
||||
readonly companyId: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
readonly productSequence: Prisma.FieldRef<"Opportunity", 'Int[]'>
|
||||
readonly cwLastUpdated: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||
readonly createdAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||
readonly updatedAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||
|
||||
@@ -32,6 +32,7 @@ export type UserMinAggregateOutputType = {
|
||||
email: string | null
|
||||
emailVerified: Date | null
|
||||
image: string | null
|
||||
cwIdentifier: string | null
|
||||
userId: string | null
|
||||
token: string | null
|
||||
createdAt: Date | null
|
||||
@@ -46,6 +47,7 @@ export type UserMaxAggregateOutputType = {
|
||||
email: string | null
|
||||
emailVerified: Date | null
|
||||
image: string | null
|
||||
cwIdentifier: string | null
|
||||
userId: string | null
|
||||
token: string | null
|
||||
createdAt: Date | null
|
||||
@@ -60,6 +62,7 @@ export type UserCountAggregateOutputType = {
|
||||
email: number
|
||||
emailVerified: number
|
||||
image: number
|
||||
cwIdentifier: number
|
||||
userId: number
|
||||
token: number
|
||||
createdAt: number
|
||||
@@ -76,6 +79,7 @@ export type UserMinAggregateInputType = {
|
||||
email?: true
|
||||
emailVerified?: true
|
||||
image?: true
|
||||
cwIdentifier?: true
|
||||
userId?: true
|
||||
token?: true
|
||||
createdAt?: true
|
||||
@@ -90,6 +94,7 @@ export type UserMaxAggregateInputType = {
|
||||
email?: true
|
||||
emailVerified?: true
|
||||
image?: true
|
||||
cwIdentifier?: true
|
||||
userId?: true
|
||||
token?: true
|
||||
createdAt?: true
|
||||
@@ -104,6 +109,7 @@ export type UserCountAggregateInputType = {
|
||||
email?: true
|
||||
emailVerified?: true
|
||||
image?: true
|
||||
cwIdentifier?: true
|
||||
userId?: true
|
||||
token?: true
|
||||
createdAt?: true
|
||||
@@ -191,6 +197,7 @@ export type UserGroupByOutputType = {
|
||||
email: string
|
||||
emailVerified: Date | null
|
||||
image: string | null
|
||||
cwIdentifier: string | null
|
||||
userId: string
|
||||
token: string | null
|
||||
createdAt: Date
|
||||
@@ -226,6 +233,7 @@ export type UserWhereInput = {
|
||||
email?: Prisma.StringFilter<"User"> | string
|
||||
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
cwIdentifier?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
userId?: Prisma.StringFilter<"User"> | string
|
||||
token?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
@@ -242,6 +250,7 @@ export type UserOrderByWithRelationInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -262,6 +271,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||
name?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
cwIdentifier?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
token?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
@@ -277,6 +287,7 @@ export type UserOrderByWithAggregationInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -297,6 +308,7 @@ export type UserScalarWhereWithAggregatesInput = {
|
||||
email?: Prisma.StringWithAggregatesFilter<"User"> | string
|
||||
emailVerified?: Prisma.DateTimeNullableWithAggregatesFilter<"User"> | Date | string | null
|
||||
image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
cwIdentifier?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
userId?: Prisma.StringWithAggregatesFilter<"User"> | string
|
||||
token?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
||||
@@ -311,6 +323,7 @@ export type UserCreateInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -327,6 +340,7 @@ export type UserUncheckedCreateInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -343,6 +357,7 @@ export type UserUpdateInput = {
|
||||
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
|
||||
@@ -359,6 +374,7 @@ export type UserUncheckedUpdateInput = {
|
||||
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
|
||||
@@ -375,6 +391,7 @@ export type UserCreateManyInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -389,6 +406,7 @@ export type UserUpdateManyMutationInput = {
|
||||
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
|
||||
@@ -403,6 +421,7 @@ export type UserUncheckedUpdateManyInput = {
|
||||
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
|
||||
@@ -422,6 +441,7 @@ export type UserCountOrderByAggregateInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -436,6 +456,7 @@ export type UserMaxOrderByAggregateInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -450,6 +471,7 @@ export type UserMinOrderByAggregateInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -530,6 +552,7 @@ export type UserCreateWithoutSessionsInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -545,6 +568,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -576,6 +600,7 @@ export type UserUpdateWithoutSessionsInput = {
|
||||
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
|
||||
@@ -591,6 +616,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
|
||||
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
|
||||
@@ -606,6 +632,7 @@ export type UserCreateWithoutRolesInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -621,6 +648,7 @@ export type UserUncheckedCreateWithoutRolesInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -660,6 +688,7 @@ export type UserScalarWhereInput = {
|
||||
email?: Prisma.StringFilter<"User"> | string
|
||||
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
cwIdentifier?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
userId?: Prisma.StringFilter<"User"> | string
|
||||
token?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
@@ -674,6 +703,7 @@ export type UserUpdateWithoutRolesInput = {
|
||||
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
|
||||
@@ -689,6 +719,7 @@ export type UserUncheckedUpdateWithoutRolesInput = {
|
||||
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
|
||||
@@ -704,6 +735,7 @@ export type UserUncheckedUpdateManyWithoutRolesInput = {
|
||||
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
|
||||
@@ -758,6 +790,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
||||
email?: boolean
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
cwIdentifier?: boolean
|
||||
userId?: boolean
|
||||
token?: boolean
|
||||
createdAt?: boolean
|
||||
@@ -775,6 +808,7 @@ export type UserSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
||||
email?: boolean
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
cwIdentifier?: boolean
|
||||
userId?: boolean
|
||||
token?: boolean
|
||||
createdAt?: boolean
|
||||
@@ -789,6 +823,7 @@ export type UserSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
||||
email?: boolean
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
cwIdentifier?: boolean
|
||||
userId?: boolean
|
||||
token?: boolean
|
||||
createdAt?: boolean
|
||||
@@ -803,13 +838,14 @@ export type UserSelectScalar = {
|
||||
email?: boolean
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
cwIdentifier?: boolean
|
||||
userId?: boolean
|
||||
token?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
}
|
||||
|
||||
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "permissions" | "login" | "name" | "email" | "emailVerified" | "image" | "userId" | "token" | "createdAt" | "updatedAt", ExtArgs["result"]["user"]>
|
||||
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "permissions" | "login" | "name" | "email" | "emailVerified" | "image" | "cwIdentifier" | "userId" | "token" | "createdAt" | "updatedAt", ExtArgs["result"]["user"]>
|
||||
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
|
||||
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
|
||||
@@ -832,6 +868,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||
email: string
|
||||
emailVerified: Date | null
|
||||
image: string | null
|
||||
cwIdentifier: string | null
|
||||
userId: string
|
||||
token: string | null
|
||||
createdAt: Date
|
||||
@@ -1268,6 +1305,7 @@ export interface UserFieldRefs {
|
||||
readonly email: Prisma.FieldRef<"User", 'String'>
|
||||
readonly emailVerified: Prisma.FieldRef<"User", 'DateTime'>
|
||||
readonly image: Prisma.FieldRef<"User", 'String'>
|
||||
readonly cwIdentifier: Prisma.FieldRef<"User", 'String'>
|
||||
readonly userId: Prisma.FieldRef<"User", 'String'>
|
||||
readonly token: Prisma.FieldRef<"User", 'String'>
|
||||
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"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",
|
||||
"db:gen": "prisma generate",
|
||||
"db:push": "prisma migrate dev --skip-generate",
|
||||
@@ -27,6 +28,7 @@
|
||||
"utils:gen_private_keys": "bun ./utils/genPrivateKeys",
|
||||
"utils:create_admin_role": "bun ./utils/createAdminRole",
|
||||
"utils:assign_user_role": "bun ./utils/assignUserRole",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -41,6 +43,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"cuid": "^3.0.0",
|
||||
"hono": "^4.11.5",
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"keypair": "^1.0.4",
|
||||
"prisma": "^7.3.0",
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Generate diff SQL between current migrations and the Prisma schema
|
||||
DIFF_SQL=$(bunx prisma migrate diff \
|
||||
--from-migrations prisma/migrations \
|
||||
--to-schema-datamodel prisma/schema.prisma \
|
||||
--script 2>/dev/null || true)
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Resolve any previously failed migrations so deploy can proceed.
|
||||
# Only migrations explicitly marked as "Failed" in the status output are
|
||||
# resolved. We grep for lines containing "Failed" and extract the name.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "[migrate] Checking for failed migrations..."
|
||||
STATUS_OUTPUT=$(bunx prisma migrate status 2>&1 || true)
|
||||
echo "$STATUS_OUTPUT"
|
||||
|
||||
# If there's a meaningful diff (not just empty/comments), create a migration
|
||||
if [ -n "$DIFF_SQL" ] && echo "$DIFF_SQL" | grep -qvE '^\s*$|^--'; then
|
||||
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
|
||||
MIGRATION_DIR="prisma/migrations/${TIMESTAMP}_auto_generated"
|
||||
mkdir -p "$MIGRATION_DIR"
|
||||
echo "$DIFF_SQL" > "$MIGRATION_DIR/migration.sql"
|
||||
echo "[migrate] Created migration: $MIGRATION_DIR"
|
||||
else
|
||||
echo "[migrate] Schema and migrations are in sync — no migration needed."
|
||||
fi
|
||||
# Only resolve migrations whose status line explicitly says "Failed"
|
||||
echo "$STATUS_OUTPUT" | grep -i "failed" | grep -oE '[0-9]{14}_[a-zA-Z_]+' | while read -r MIGRATION; do
|
||||
echo "[migrate] Resolving failed migration: $MIGRATION"
|
||||
bunx prisma migrate resolve --rolled-back "$MIGRATION" || true
|
||||
done
|
||||
|
||||
# Deploy all pending migrations
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Deploy all pending migrations from the migrations directory.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "[migrate] Running prisma migrate deploy..."
|
||||
bunx prisma migrate deploy
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "identifier" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CatalogItem_identifier_key" ON "CatalogItem"("identifier");
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterTable: User
|
||||
ALTER TABLE "User" ADD COLUMN "cwIdentifier" TEXT;
|
||||
|
||||
-- AlterTable: CatalogItem
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "category" TEXT;
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "categoryCwId" INTEGER;
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "subcategory" TEXT;
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "subcategoryCwId" INTEGER;
|
||||
|
||||
-- AlterTable: Opportunity
|
||||
ALTER TABLE "Opportunity" ADD COLUMN "productSequence" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
|
||||
@@ -34,6 +34,8 @@ model User {
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
|
||||
cwIdentifier String?
|
||||
|
||||
userId String @unique
|
||||
token String?
|
||||
|
||||
@@ -95,6 +97,11 @@ model CatalogItem {
|
||||
linkedItems CatalogItem[] @relation("LinkedItems")
|
||||
linkedTo CatalogItem[] @relation("LinkedItems")
|
||||
|
||||
category String?
|
||||
categoryCwId Int?
|
||||
subcategory String?
|
||||
subcategoryCwId Int?
|
||||
|
||||
manufacturer String?
|
||||
manufactureCwId Int?
|
||||
|
||||
@@ -178,6 +185,10 @@ model Opportunity {
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id])
|
||||
|
||||
// Local product sequence — array of CW forecast item IDs in display order.
|
||||
// When present, fetchProducts() uses this order instead of CW sequenceNumber.
|
||||
productSequence Int[] @default([])
|
||||
|
||||
cwLastUpdated DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/company/companies/[id] */
|
||||
export default createRoute(
|
||||
@@ -42,13 +43,20 @@ export default createRoute(
|
||||
}
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company Fetched Successfully!",
|
||||
company.toJson({
|
||||
const companyData = company.toJson({
|
||||
includeAddress,
|
||||
includePrimaryContact,
|
||||
includeAllContacts,
|
||||
}),
|
||||
});
|
||||
const gatedData = await processObjectValuePerms(
|
||||
companyData,
|
||||
"obj.company",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company Fetched Successfully!",
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { companies } from "../../../managers/companies";
|
||||
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";
|
||||
|
||||
/* GET /v1/company/companies/:identifier/unifi/sites */
|
||||
export default createRoute(
|
||||
@@ -12,9 +13,16 @@ export default createRoute(
|
||||
async (c) => {
|
||||
const company = await companies.fetch(c.req.param("identifier"));
|
||||
const sites = await unifiSites.fetchByCompany(company.id);
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
sites.map((site) =>
|
||||
processObjectValuePerms(site, "obj.unifiSite", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company UniFi Sites Fetched Successfully!",
|
||||
sites,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { companies } from "../../managers/companies";
|
||||
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";
|
||||
|
||||
/* /v1/company/companies */
|
||||
export default createRoute(
|
||||
@@ -22,9 +23,15 @@ export default createRoute(
|
||||
? (await companies.search(search, 1, 999999)).length
|
||||
: await companies.count();
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
data.map((item) =>
|
||||
processObjectValuePerms(item, "obj.company", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
let response = apiResponse.successful(
|
||||
"Companies Fetched Successfully!",
|
||||
data,
|
||||
gatedData,
|
||||
{
|
||||
pagination: {
|
||||
previousPage: page == 1 ? null : page - 1, // Previous Page
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentialTypes } from "../../managers/credentialTypes";
|
||||
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";
|
||||
|
||||
/* /v1/credential-type/:identifier */
|
||||
export default createRoute(
|
||||
@@ -15,9 +16,15 @@ export default createRoute(
|
||||
c.req.param("identifier"),
|
||||
);
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
credentialType.toJson({ includeCredentialCount: true }),
|
||||
"obj.credentialType",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Credential Type Fetched Successfully!",
|
||||
credentialType.toJson({ includeCredentialCount: true }),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentialTypes } from "../../managers/credentialTypes";
|
||||
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";
|
||||
|
||||
/* /v1/credential-type */
|
||||
export default createRoute(
|
||||
@@ -13,11 +14,19 @@ export default createRoute(
|
||||
async (c) => {
|
||||
const allCredentialTypes = await credentialTypes.fetchAll();
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
allCredentialTypes.map((ct) =>
|
||||
processObjectValuePerms(
|
||||
ct.toJson({ includeCredentialCount: true }),
|
||||
"obj.credentialType",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Credential Types Fetched Successfully!",
|
||||
allCredentialTypes.map((ct) =>
|
||||
ct.toJson({ includeCredentialCount: true }),
|
||||
),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentialTypes } from "../../managers/credentialTypes";
|
||||
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";
|
||||
|
||||
/* /v1/credential-type/:id/credentials */
|
||||
export default createRoute(
|
||||
@@ -14,9 +15,15 @@ export default createRoute(
|
||||
const credentialType = await credentialTypes.fetch(c.req.param("id"));
|
||||
const credentials = await credentialType.fetchCredentials();
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
credentials.map((cred) =>
|
||||
processObjectValuePerms(cred.toJson(), "obj.credential", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Credentials Fetched Successfully!",
|
||||
credentials.map((cred) => cred.toJson()),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentials } from "../../managers/credentials";
|
||||
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";
|
||||
|
||||
/* /v1/credential/:id */
|
||||
export default createRoute(
|
||||
@@ -12,10 +13,15 @@ export default createRoute(
|
||||
|
||||
async (c) => {
|
||||
const credential = await credentials.fetch(c.req.param("id"));
|
||||
const gatedData = await processObjectValuePerms(
|
||||
credential.toJson(),
|
||||
"obj.credential",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Credential Fetched Successfully!",
|
||||
credential.toJson(),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentials } from "../../managers/credentials";
|
||||
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";
|
||||
|
||||
/* /v1/credential/company/:companyId */
|
||||
export default createRoute(
|
||||
@@ -15,9 +16,15 @@ export default createRoute(
|
||||
c.req.param("companyId"),
|
||||
);
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
companyCredentials.map((cred) =>
|
||||
processObjectValuePerms(cred.toJson(), "obj.credential", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company Credentials Fetched Successfully!",
|
||||
companyCredentials.map((cred) => cred.toJson()),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { credentials } from "../../managers/credentials";
|
||||
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";
|
||||
|
||||
/* GET /v1/credential/credentials/:id/sub-credentials */
|
||||
export default createRoute(
|
||||
@@ -17,9 +18,15 @@ export default createRoute(
|
||||
|
||||
const subCredentials = await credentials.fetchSubCredentials(parentId);
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
subCredentials.map((sc) =>
|
||||
processObjectValuePerms(sc.toJson(), "obj.credential", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Sub-Credentials Fetched Successfully!",
|
||||
subCredentials.map((sc) => sc.toJson()),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/procurement/items/:identifier */
|
||||
export default createRoute(
|
||||
@@ -14,9 +15,15 @@ export default createRoute(
|
||||
|
||||
const item = await procurement.fetchItem(identifier);
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
item.toJson({ includeLinkedItems }),
|
||||
"obj.catalogItem",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Catalog item fetched successfully!",
|
||||
item.toJson({ includeLinkedItems }),
|
||||
gatedData,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/procurement/items/:identifier/linked */
|
||||
export default createRoute(
|
||||
@@ -14,9 +15,15 @@ export default createRoute(
|
||||
|
||||
const linkedItems = item.getLinkedItems().map((linked) => linked.toJson());
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
linkedItems.map((linked) =>
|
||||
processObjectValuePerms(linked, "obj.catalogItem", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Linked catalog items fetched successfully!",
|
||||
linkedItems,
|
||||
gatedData,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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 {
|
||||
serializeCategoryTree,
|
||||
serializeEcosystemTree,
|
||||
} from "../../modules/catalog-categories/catalogCategories";
|
||||
|
||||
/* /v1/procurement/categories */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/categories"],
|
||||
async (c) => {
|
||||
const categories = serializeCategoryTree();
|
||||
const ecosystems = serializeEcosystemTree();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Category and ecosystem data fetched successfully!",
|
||||
{ categories, ecosystems },
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
|
||||
);
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { procurement } from "../../managers/procurement";
|
||||
import { procurement, CatalogFilterOpts } from "../../managers/procurement";
|
||||
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";
|
||||
|
||||
/* /v1/procurement/items */
|
||||
export default createRoute(
|
||||
@@ -14,17 +15,53 @@ export default createRoute(
|
||||
const search = c.req.query("search") as string;
|
||||
const includeInactive = c.req.query("includeInactive") === "true";
|
||||
|
||||
const data = search
|
||||
? await procurement.search(search, page, rpp, { includeInactive })
|
||||
: await procurement.fetchPages(page, rpp, { includeInactive });
|
||||
// Category / filter params
|
||||
const category = c.req.query("category") as string | undefined;
|
||||
const subcategory = c.req.query("subcategory") as string | undefined;
|
||||
const group = c.req.query("group") as string | undefined;
|
||||
const manufacturer = c.req.query("manufacturer") as string | undefined;
|
||||
const ecosystem = c.req.query("ecosystem") as string | undefined;
|
||||
const inStock = c.req.query("inStock") === "true" ? true : undefined;
|
||||
const minPrice = c.req.query("minPrice")
|
||||
? Number(c.req.query("minPrice"))
|
||||
: undefined;
|
||||
const maxPrice = c.req.query("maxPrice")
|
||||
? Number(c.req.query("maxPrice"))
|
||||
: undefined;
|
||||
|
||||
const totalRecords = await procurement.count({
|
||||
activeOnly: !includeInactive,
|
||||
});
|
||||
const filterOpts: CatalogFilterOpts = {
|
||||
includeInactive,
|
||||
category,
|
||||
subcategory,
|
||||
group,
|
||||
manufacturer,
|
||||
ecosystem,
|
||||
inStock,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
};
|
||||
|
||||
const data = search
|
||||
? await procurement.search(search, page, rpp, filterOpts)
|
||||
: await procurement.fetchPages(page, rpp, filterOpts);
|
||||
|
||||
const totalRecords = search
|
||||
? await procurement.countSearch(search, filterOpts)
|
||||
: await procurement.count(filterOpts);
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
data.map((item) =>
|
||||
processObjectValuePerms(
|
||||
item.toJson(),
|
||||
"obj.catalogItem",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Catalog items fetched successfully!",
|
||||
data.map((item) => item.toJson()),
|
||||
gatedData,
|
||||
{
|
||||
pagination: {
|
||||
previousPage: page <= 1 ? null : page - 1,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { procurement } from "../../managers/procurement";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
|
||||
/* /v1/procurement/filters */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/filters"],
|
||||
async (c) => {
|
||||
const category = c.req.query("category") as string | undefined;
|
||||
const subcategory = c.req.query("subcategory") as string | undefined;
|
||||
const includeInactive = c.req.query("includeInactive") === "true";
|
||||
|
||||
const filterOpts = { category, subcategory, includeInactive };
|
||||
|
||||
const [categories, subcategories, manufacturers] = await Promise.all([
|
||||
procurement.fetchDistinctValues("category", filterOpts),
|
||||
procurement.fetchDistinctValues("subcategory", filterOpts),
|
||||
procurement.fetchDistinctValues("manufacturer", filterOpts),
|
||||
]);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Available filter values fetched successfully!",
|
||||
{ categories, subcategories, manufacturers },
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
|
||||
);
|
||||
@@ -5,5 +5,17 @@ import { default as link } from "./[id]/link";
|
||||
import { default as unlink } from "./[id]/unlink";
|
||||
import { default as fetchLinked } from "./[id]/fetchLinked";
|
||||
import { default as count } from "./count";
|
||||
import { default as categories } from "./categories";
|
||||
import { default as filters } from "./filters";
|
||||
|
||||
export { count, fetch, fetchAll, fetchLinked, link, refreshInventory, unlink };
|
||||
export {
|
||||
categories,
|
||||
count,
|
||||
fetch,
|
||||
fetchAll,
|
||||
fetchLinked,
|
||||
filters,
|
||||
link,
|
||||
refreshInventory,
|
||||
unlink,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { roles } from "../../managers/roles";
|
||||
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";
|
||||
|
||||
/* GET /v1/role/:identifier */
|
||||
export default createRoute(
|
||||
@@ -15,9 +16,15 @@ export default createRoute(
|
||||
|
||||
const role = await roles.fetch(identifier);
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
role.toJson({ viewPermissions: true }),
|
||||
"obj.role",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Role Fetched Successfully!",
|
||||
role.toJson({ viewPermissions: true }),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { roles } from "../../managers/roles";
|
||||
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";
|
||||
|
||||
/* GET /v1/role */
|
||||
export default createRoute(
|
||||
@@ -13,13 +14,19 @@ export default createRoute(
|
||||
async (c) => {
|
||||
const allRoles = await roles.fetchAllRoles();
|
||||
|
||||
const rolesArray = allRoles.map((role) =>
|
||||
const gatedData = await Promise.all(
|
||||
allRoles.map((role) =>
|
||||
processObjectValuePerms(
|
||||
role.toJson({ viewPermissions: true }),
|
||||
"obj.role",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Roles Fetched Successfully!",
|
||||
rolesArray,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { roles } from "../../managers/roles";
|
||||
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";
|
||||
|
||||
/* GET /v1/role/:identifier/users */
|
||||
export default createRoute(
|
||||
@@ -16,11 +17,15 @@ export default createRoute(
|
||||
const role = await roles.fetch(identifier);
|
||||
const users = role.getUsers();
|
||||
|
||||
const usersArray = users.map((user) => user.toJson());
|
||||
const gatedData = await Promise.all(
|
||||
users.map((user) =>
|
||||
processObjectValuePerms(user.toJson(), "obj.user", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Users Fetched Successfully!",
|
||||
usersArray,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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 { z } from "zod";
|
||||
|
||||
const productItemSchema = z
|
||||
.object({
|
||||
catalogItem: z.object({ id: z.number().int().positive() }).optional(),
|
||||
forecastDescription: z.string().optional(),
|
||||
productDescription: z.string().optional(),
|
||||
quantity: z.number().positive().optional(),
|
||||
status: z.object({ id: z.number().int().positive() }).optional(),
|
||||
productClass: z.string().optional(),
|
||||
forecastType: z.string().optional(),
|
||||
revenue: z.number().optional(),
|
||||
cost: z.number().optional(),
|
||||
includeFlag: z.boolean().optional(),
|
||||
linkFlag: z.boolean().optional(),
|
||||
recurringFlag: z.boolean().optional(),
|
||||
taxableFlag: z.boolean().optional(),
|
||||
recurringRevenue: z.number().optional(),
|
||||
recurringCost: z.number().optional(),
|
||||
cycles: z.number().int().min(0).optional(),
|
||||
sequenceNumber: z.number().int().min(0).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const addProductSchema = z.union([
|
||||
productItemSchema,
|
||||
z.array(productItemSchema).min(1, "At least one product is required"),
|
||||
]);
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/products */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/products"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const validated = addProductSchema.parse(body);
|
||||
const inputItems = Array.isArray(validated) ? validated : [validated];
|
||||
|
||||
// Gate each submitted field against user permissions.
|
||||
// Only fields the user has permission for are forwarded to ConnectWise.
|
||||
const user = c.get("user");
|
||||
const gatedItems = await Promise.all(
|
||||
inputItems.map((item) =>
|
||||
processObjectValuePerms(item, "sales.opportunity.product.field", user),
|
||||
),
|
||||
);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const created = await item.addProducts(gatedItems);
|
||||
|
||||
const isBatch = Array.isArray(body);
|
||||
const response = apiResponse.created(
|
||||
isBatch
|
||||
? `${created.length} product(s) added to opportunity successfully!`
|
||||
: "Product added to opportunity successfully!",
|
||||
isBatch ? created.map((p) => p.toJson()) : created[0]!.toJson(),
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.add"] }),
|
||||
);
|
||||
@@ -3,7 +3,6 @@ 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 { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/contacts */
|
||||
export default createRoute(
|
||||
@@ -11,24 +10,9 @@ export default createRoute(
|
||||
["/opportunities/:identifier/contacts"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
|
||||
const contacts = await opportunityCw.fetchContacts(item.cwOpportunityId);
|
||||
|
||||
const data = contacts.map((ct) => ({
|
||||
id: ct.id,
|
||||
contact: ct.contact ? { id: ct.contact.id, name: ct.contact.name } : null,
|
||||
company: ct.company
|
||||
? {
|
||||
id: ct.company.id,
|
||||
identifier: ct.company.identifier,
|
||||
name: ct.company.name,
|
||||
}
|
||||
: null,
|
||||
role: ct.role ? { id: ct.role.id, name: ct.role.name } : null,
|
||||
notes: ct.notes,
|
||||
referralFlag: ct.referralFlag,
|
||||
}));
|
||||
const data = await item.fetchContacts();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity contacts fetched successfully!",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { resolveMember } from "../../../modules/cw-utils/members/memberCache";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/notes */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/notes"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const schema = z.object({
|
||||
text: z.string().min(1, "Note text is required"),
|
||||
flagged: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const data = schema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const user = c.get("user");
|
||||
|
||||
const created = await item.addNote(data.text, user.login, {
|
||||
flagged: data.flagged,
|
||||
});
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Opportunity note created successfully!",
|
||||
{
|
||||
id: created.id,
|
||||
text: created.text,
|
||||
type: created.type
|
||||
? { id: created.type.id, name: created.type.name }
|
||||
: null,
|
||||
flagged: created.flagged,
|
||||
enteredBy: await resolveMember(created.enteredBy),
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.note.create"] }),
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
|
||||
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
export default createRoute(
|
||||
"delete",
|
||||
["/opportunities/:identifier/notes/:noteId"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const noteId = Number(c.req.param("noteId"));
|
||||
|
||||
if (isNaN(noteId))
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidNoteId",
|
||||
message: "Note ID must be a number",
|
||||
});
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
await item.deleteNote(noteId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity note deleted successfully!",
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.note.delete"] }),
|
||||
);
|
||||
@@ -3,19 +3,60 @@ 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";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier */
|
||||
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products */
|
||||
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),
|
||||
);
|
||||
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
|
||||
// Eagerly load site data so toJson() includes full site info
|
||||
await item.fetchSite();
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
item.toJson(),
|
||||
"obj.opportunity",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
// Fetch requested sub-resources in parallel
|
||||
const subResourcePromises: Record<string, Promise<any>> = {};
|
||||
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()));
|
||||
}
|
||||
|
||||
const keys = Object.keys(subResourcePromises);
|
||||
if (keys.length > 0) {
|
||||
const results = await Promise.all(
|
||||
keys.map((k) => subResourcePromises[k]),
|
||||
);
|
||||
keys.forEach((k, i) => {
|
||||
(gatedData as any)[k] = results[i];
|
||||
});
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity fetched successfully!",
|
||||
item.toJson(),
|
||||
gatedData,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/notes/:noteId"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const noteId = Number(c.req.param("noteId"));
|
||||
|
||||
if (isNaN(noteId))
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidNoteId",
|
||||
message: "Note ID must be a number",
|
||||
});
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const data = await item.fetchNote(noteId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity note fetched successfully!",
|
||||
data,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -1,39 +0,0 @@
|
||||
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 { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/forecasts */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/forecasts"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
|
||||
const forecasts = await opportunityCw.fetchForecasts(item.cwOpportunityId);
|
||||
|
||||
const data = forecasts.map((f) => ({
|
||||
id: f.id,
|
||||
forecastType: f.forecastType,
|
||||
forecastMonth: f.forecastMonth,
|
||||
revenue: f.revenue,
|
||||
cost: f.cost,
|
||||
forecastPercentage: f.forecastPercentage,
|
||||
status: f.status ? { id: f.status.id, name: f.status.name } : null,
|
||||
includedFlag: f.includedFlag,
|
||||
linkedFlag: f.linkedFlag,
|
||||
recurringFlag: f.recurringFlag,
|
||||
}));
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity forecasts fetched successfully!",
|
||||
data,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -3,7 +3,6 @@ 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 { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/notes */
|
||||
export default createRoute(
|
||||
@@ -11,17 +10,9 @@ export default createRoute(
|
||||
["/opportunities/:identifier/notes"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
|
||||
const notes = await opportunityCw.fetchNotes(item.cwOpportunityId);
|
||||
|
||||
const data = notes.map((n) => ({
|
||||
id: n.id,
|
||||
text: n.text,
|
||||
type: n.type ? { id: n.type.id, name: n.type.name } : null,
|
||||
flagged: n.flagged,
|
||||
enteredBy: n.enteredBy,
|
||||
}));
|
||||
const data = await item.fetchNotes();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity notes fetched successfully!",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/products */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/products"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
|
||||
const data = await item.fetchProducts();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity products fetched successfully!",
|
||||
data.map((p) => p.toJson()),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
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";
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/:identifier/products/sequence"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const schema = z.object({
|
||||
orderedIds: z
|
||||
.array(z.number().int().positive())
|
||||
.min(1, "At least one forecast item ID is required"),
|
||||
});
|
||||
|
||||
const { orderedIds } = schema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const updated = await item.resequenceProducts(orderedIds);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Product sequence updated successfully!",
|
||||
{
|
||||
products: updated.map((p) => p.toJson()),
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { resolveMember } from "../../../modules/cw-utils/members/memberCache";
|
||||
import { z } from "zod";
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/:identifier/notes/:noteId"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const noteId = Number(c.req.param("noteId"));
|
||||
|
||||
if (isNaN(noteId))
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidNoteId",
|
||||
message: "Note ID must be a number",
|
||||
});
|
||||
|
||||
const body = await c.req.json();
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
text: z.string().min(1).optional(),
|
||||
flagged: z.boolean().optional(),
|
||||
})
|
||||
.refine((d) => d.text !== undefined || d.flagged !== undefined, {
|
||||
message: "At least one of 'text' or 'flagged' must be provided",
|
||||
});
|
||||
|
||||
const data = schema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const updated = await item.updateNote(noteId, data);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity note updated successfully!",
|
||||
{
|
||||
id: updated.id,
|
||||
text: updated.text,
|
||||
type: updated.type
|
||||
? { id: updated.type.id, name: updated.type.name }
|
||||
: null,
|
||||
flagged: updated.flagged,
|
||||
enteredBy: await resolveMember(updated.enteredBy),
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.note.update"] }),
|
||||
);
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
|
||||
/* GET /v1/sales/opportunities */
|
||||
export default createRoute(
|
||||
@@ -18,13 +19,23 @@ export default createRoute(
|
||||
? await opportunities.search(search, page, rpp, { includeClosed })
|
||||
: await opportunities.fetchPages(page, rpp, { includeClosed });
|
||||
|
||||
const totalRecords = await opportunities.count({
|
||||
openOnly: !includeClosed,
|
||||
});
|
||||
const totalRecords = search
|
||||
? await opportunities.searchCount(search, { includeClosed })
|
||||
: await opportunities.count({ openOnly: !includeClosed });
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
data.map((item) =>
|
||||
processObjectValuePerms(
|
||||
item.toJson(),
|
||||
"obj.opportunity",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunities fetched successfully!",
|
||||
data.map((item) => item.toJson()),
|
||||
gatedData,
|
||||
{
|
||||
pagination: {
|
||||
previousPage: page <= 1 ? null : page - 1,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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 { QUOTE_STATUSES } from "../../types/QuoteStatuses";
|
||||
|
||||
/* GET /v1/sales/opportunity-types */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunity-types"],
|
||||
|
||||
async (c) => {
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity Types Fetched Successfully!",
|
||||
QUOTE_STATUSES,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
|
||||
);
|
||||
+24
-2
@@ -1,9 +1,31 @@
|
||||
import { default as fetchAll } from "./fetchAll";
|
||||
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
|
||||
import { default as count } from "./count";
|
||||
import { default as fetch } from "./[id]/fetch";
|
||||
import { default as refresh } from "./[id]/refresh";
|
||||
import { default as forecasts } from "./[id]/forecasts";
|
||||
import { default as products } from "./[id]/products";
|
||||
import { default as addProduct } from "./[id]/addProduct";
|
||||
import { default as resequenceProducts } from "./[id]/resequenceProducts";
|
||||
import { default as notes } from "./[id]/notes";
|
||||
import { default as fetchNote } from "./[id]/fetchNote";
|
||||
import { default as createNote } from "./[id]/createNote";
|
||||
import { default as updateNote } from "./[id]/updateNote";
|
||||
import { default as deleteNote } from "./[id]/deleteNote";
|
||||
import { default as contacts } from "./[id]/contacts";
|
||||
|
||||
export { count, fetch, fetchAll, forecasts, notes, contacts, refresh };
|
||||
export {
|
||||
addProduct,
|
||||
count,
|
||||
fetch,
|
||||
fetchAll,
|
||||
fetchOpportunityTypes,
|
||||
products,
|
||||
resequenceProducts,
|
||||
notes,
|
||||
fetchNote,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
contacts,
|
||||
refresh,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { unifiSites } from "../../../managers/unifiSites";
|
||||
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";
|
||||
|
||||
/* GET /v1/unifi/site/:id */
|
||||
export default createRoute(
|
||||
@@ -10,9 +11,16 @@ export default createRoute(
|
||||
["/site/:id"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
site,
|
||||
"obj.unifiSite",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Site Fetched Successfully!",
|
||||
site,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { unifiSites } from "../../../managers/unifiSites";
|
||||
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";
|
||||
|
||||
/* GET /v1/unifi/sites */
|
||||
export default createRoute(
|
||||
@@ -10,9 +11,16 @@ export default createRoute(
|
||||
["/sites"],
|
||||
async (c) => {
|
||||
const sites = await unifiSites.fetchAll();
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
sites.map((site) =>
|
||||
processObjectValuePerms(site, "obj.unifiSite", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Sites Fetched Successfully!",
|
||||
sites,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -2,16 +2,20 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
// /v1/user/@me
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/@me"],
|
||||
(c) => {
|
||||
const response = apiResponse.successful(
|
||||
"Fetched user.",
|
||||
async (c) => {
|
||||
const gatedData = await processObjectValuePerms(
|
||||
c.get("user")?.toJson(),
|
||||
"obj.user",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful("Fetched user.", gatedData);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ scopes: ["user.read"] }),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { users } from "../../managers/users";
|
||||
import GenericError from "../../Errors/GenericError";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/user/users/:identifier */
|
||||
export default createRoute(
|
||||
@@ -21,9 +22,15 @@ export default createRoute(
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
user.toJson(),
|
||||
"obj.user",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"User Fetched Successfully!",
|
||||
user.toJson(),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { users } from "../../managers/users";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/user/users */
|
||||
export default createRoute(
|
||||
@@ -11,11 +12,16 @@ export default createRoute(
|
||||
|
||||
async (c) => {
|
||||
const allUsers = await users.fetchAllUsers();
|
||||
const usersArray = allUsers.map((u) => u.toJson());
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
allUsers.map((u) =>
|
||||
processObjectValuePerms(u.toJson(), "obj.user", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Users Fetched Successfully!",
|
||||
usersArray,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { users } from "../../managers/users";
|
||||
import GenericError from "../../Errors/GenericError";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/user/users/:identifier/roles */
|
||||
export default createRoute(
|
||||
@@ -22,11 +23,20 @@ export default createRoute(
|
||||
});
|
||||
|
||||
const roles = await user.fetchRoles();
|
||||
const rolesArray = roles.map((r) => r.toJson({ viewPermissions: true }));
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
roles.map((r) =>
|
||||
processObjectValuePerms(
|
||||
r.toJson({ viewPermissions: true }),
|
||||
"obj.role",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"User Roles Fetched Successfully!",
|
||||
rolesArray,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Server } from "socket.io";
|
||||
import { Server as Engine } from "@socket.io/bun-engine";
|
||||
import axios from "axios";
|
||||
import { UnifiClient } from "./modules/unifi-api/UnifiClient";
|
||||
import { attachCwApiLogger } from "./modules/cw-utils/cwApiLogger";
|
||||
import Redis from "ioredis";
|
||||
|
||||
const connectionString = `${process.env.DATABASE_URL}`;
|
||||
const adapter = new PrismaPg({ connectionString });
|
||||
@@ -22,6 +24,10 @@ export const API_BASE_URL =
|
||||
|
||||
export const prisma = new PrismaClient({ adapter });
|
||||
|
||||
// Redis Client
|
||||
|
||||
export const redis = new Redis(process.env.REDIS_URL!);
|
||||
|
||||
export const sessionDuration = 30 * 24 * 60 * 60000;
|
||||
export const accessTokenDuration = "10min";
|
||||
export const refreshTokenDuration = "30d";
|
||||
@@ -76,8 +82,11 @@ const connectWiseApi = axios.create({
|
||||
clientId: `${process.env.CW_CLIENT_ID}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 30_000, // 30 s — prevents indefinite hangs on CW API
|
||||
});
|
||||
|
||||
attachCwApiLogger(connectWiseApi);
|
||||
|
||||
export { connectWiseApi };
|
||||
|
||||
// Unifi API Constants
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
CWActivity,
|
||||
CWActivityCustomField,
|
||||
CWPatchOperation,
|
||||
CWCreateActivity,
|
||||
} from "../modules/cw-utils/activities/activity.types";
|
||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import { fetchActivity } from "../modules/cw-utils/activities/fetchActivity";
|
||||
|
||||
/**
|
||||
* Activity Controller
|
||||
*
|
||||
* Domain model class that encapsulates a ConnectWise Activity entity.
|
||||
* Activities are not persisted locally — all data is sourced directly
|
||||
* from the ConnectWise API.
|
||||
*/
|
||||
export class ActivityController {
|
||||
public readonly cwActivityId: number;
|
||||
public name: string;
|
||||
public notes: string | null;
|
||||
|
||||
public typeName: string | null;
|
||||
public typeCwId: number | null;
|
||||
public statusName: string | null;
|
||||
public statusCwId: number | null;
|
||||
|
||||
public companyCwId: number | null;
|
||||
public companyName: string | null;
|
||||
public companyIdentifier: string | null;
|
||||
public contactCwId: number | null;
|
||||
public contactName: string | null;
|
||||
|
||||
public phoneNumber: string | null;
|
||||
public email: string | null;
|
||||
|
||||
public opportunityCwId: number | null;
|
||||
public opportunityName: string | null;
|
||||
public ticketCwId: number | null;
|
||||
public ticketName: string | null;
|
||||
public agreementCwId: number | null;
|
||||
public agreementName: string | null;
|
||||
public campaignCwId: number | null;
|
||||
public campaignName: string | null;
|
||||
|
||||
public assignToCwId: number | null;
|
||||
public assignToName: string | null;
|
||||
public assignToIdentifier: string | null;
|
||||
|
||||
public scheduleStatusCwId: number | null;
|
||||
public scheduleStatusName: string | null;
|
||||
public reminderCwId: number | null;
|
||||
public reminderName: string | null;
|
||||
public whereCwId: number | null;
|
||||
public whereName: string | null;
|
||||
|
||||
public dateStart: Date | null;
|
||||
public dateEnd: Date | null;
|
||||
public notifyFlag: boolean;
|
||||
|
||||
public currencyCwId: number | null;
|
||||
public currencyName: string | null;
|
||||
|
||||
public mobileGuid: string | null;
|
||||
public customFields: CWActivityCustomField[];
|
||||
|
||||
public cwLastUpdated: Date | null;
|
||||
public cwDateEntered: Date | null;
|
||||
public cwEnteredBy: string | null;
|
||||
public cwUpdatedBy: string | null;
|
||||
|
||||
constructor(data: CWActivity) {
|
||||
this.cwActivityId = data.id;
|
||||
this.name = data.name;
|
||||
this.notes = data.notes ?? null;
|
||||
|
||||
this.typeName = data.type?.name ?? null;
|
||||
this.typeCwId = data.type?.id ?? null;
|
||||
this.statusName = data.status?.name ?? null;
|
||||
this.statusCwId = data.status?.id ?? null;
|
||||
|
||||
this.companyCwId = data.company?.id ?? null;
|
||||
this.companyName = data.company?.name ?? null;
|
||||
this.companyIdentifier = data.company?.identifier ?? null;
|
||||
this.contactCwId = data.contact?.id ?? null;
|
||||
this.contactName = data.contact?.name ?? null;
|
||||
|
||||
this.phoneNumber = data.phoneNumber ?? null;
|
||||
this.email = data.email ?? null;
|
||||
|
||||
this.opportunityCwId = data.opportunity?.id ?? null;
|
||||
this.opportunityName = data.opportunity?.name ?? null;
|
||||
this.ticketCwId = data.ticket?.id ?? null;
|
||||
this.ticketName = data.ticket?.name ?? null;
|
||||
this.agreementCwId = data.agreement?.id ?? null;
|
||||
this.agreementName = data.agreement?.name ?? null;
|
||||
this.campaignCwId = data.campaign?.id ?? null;
|
||||
this.campaignName = data.campaign?.name ?? null;
|
||||
|
||||
this.assignToCwId = data.assignTo?.id ?? null;
|
||||
this.assignToName = data.assignTo?.name ?? null;
|
||||
this.assignToIdentifier = data.assignTo?.identifier ?? null;
|
||||
|
||||
this.scheduleStatusCwId = data.scheduleStatus?.id ?? null;
|
||||
this.scheduleStatusName = data.scheduleStatus?.name ?? null;
|
||||
this.reminderCwId = data.reminder?.id ?? null;
|
||||
this.reminderName = data.reminder?.name ?? null;
|
||||
this.whereCwId = data.where?.id ?? null;
|
||||
this.whereName = data.where?.name ?? null;
|
||||
|
||||
this.dateStart = data.dateStart ? new Date(data.dateStart) : null;
|
||||
this.dateEnd = data.dateEnd ? new Date(data.dateEnd) : null;
|
||||
this.notifyFlag = data.notifyFlag ?? false;
|
||||
|
||||
this.currencyCwId = data.currency?.id ?? null;
|
||||
this.currencyName = data.currency?.name ?? null;
|
||||
|
||||
this.mobileGuid = data.mobileGuid ?? null;
|
||||
this.customFields = data.customFields ?? [];
|
||||
|
||||
this.cwLastUpdated = data._info?.lastUpdated
|
||||
? new Date(data._info.lastUpdated)
|
||||
: null;
|
||||
this.cwDateEntered = data._info?.dateEntered
|
||||
? new Date(data._info.dateEntered)
|
||||
: null;
|
||||
this.cwEnteredBy = data._info?.enteredBy ?? null;
|
||||
this.cwUpdatedBy = data._info?.updatedBy ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh from ConnectWise
|
||||
*
|
||||
* Fetches the latest activity data from CW and returns
|
||||
* a new ActivityController instance with updated state.
|
||||
*/
|
||||
public async refreshFromCW(): Promise<ActivityController> {
|
||||
const cwData = await fetchActivity(this.cwActivityId);
|
||||
return new ActivityController(cwData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw CW data
|
||||
*
|
||||
* Returns the raw ConnectWise activity object.
|
||||
*/
|
||||
public async fetchCwData(): Promise<CWActivity> {
|
||||
return fetchActivity(this.cwActivityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update in ConnectWise
|
||||
*
|
||||
* Applies JSON Patch operations to this activity in ConnectWise
|
||||
* and returns a new controller with the updated data.
|
||||
*/
|
||||
public async update(
|
||||
operations: CWPatchOperation[],
|
||||
): Promise<ActivityController> {
|
||||
const updated = await activityCw.update(this.cwActivityId, operations);
|
||||
return new ActivityController(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete from ConnectWise
|
||||
*
|
||||
* Deletes this activity in ConnectWise.
|
||||
*/
|
||||
public async delete(): Promise<void> {
|
||||
await activityCw.delete(this.cwActivityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Activity (static factory)
|
||||
*
|
||||
* Creates a new activity in ConnectWise and returns a controller instance.
|
||||
*/
|
||||
public static async create(
|
||||
data: CWCreateActivity,
|
||||
): Promise<ActivityController> {
|
||||
const created = await activityCw.create(data);
|
||||
return new ActivityController(created);
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Serializes the activity into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
return {
|
||||
cwActivityId: this.cwActivityId,
|
||||
name: this.name,
|
||||
notes: this.notes,
|
||||
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
|
||||
status: this.statusCwId
|
||||
? { id: this.statusCwId, name: this.statusName }
|
||||
: null,
|
||||
company: this.companyCwId
|
||||
? {
|
||||
id: this.companyCwId,
|
||||
identifier: this.companyIdentifier,
|
||||
name: this.companyName,
|
||||
}
|
||||
: null,
|
||||
contact: this.contactCwId
|
||||
? { id: this.contactCwId, name: this.contactName }
|
||||
: null,
|
||||
phoneNumber: this.phoneNumber,
|
||||
email: this.email,
|
||||
opportunity: this.opportunityCwId
|
||||
? { id: this.opportunityCwId, name: this.opportunityName }
|
||||
: null,
|
||||
ticket: this.ticketCwId
|
||||
? { id: this.ticketCwId, name: this.ticketName }
|
||||
: null,
|
||||
agreement: this.agreementCwId
|
||||
? { id: this.agreementCwId, name: this.agreementName }
|
||||
: null,
|
||||
campaign: this.campaignCwId
|
||||
? { id: this.campaignCwId, name: this.campaignName }
|
||||
: null,
|
||||
assignTo: this.assignToCwId
|
||||
? {
|
||||
id: this.assignToCwId,
|
||||
identifier: this.assignToIdentifier,
|
||||
name: this.assignToName,
|
||||
}
|
||||
: null,
|
||||
scheduleStatus: this.scheduleStatusCwId
|
||||
? { id: this.scheduleStatusCwId, name: this.scheduleStatusName }
|
||||
: null,
|
||||
reminder: this.reminderCwId
|
||||
? { id: this.reminderCwId, name: this.reminderName }
|
||||
: null,
|
||||
where: this.whereCwId
|
||||
? { id: this.whereCwId, name: this.whereName }
|
||||
: null,
|
||||
dateStart: this.dateStart,
|
||||
dateEnd: this.dateEnd,
|
||||
notifyFlag: this.notifyFlag,
|
||||
currency: this.currencyCwId
|
||||
? { id: this.currencyCwId, name: this.currencyName }
|
||||
: null,
|
||||
mobileGuid: this.mobileGuid,
|
||||
customFields: this.customFields,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
cwDateEntered: this.cwDateEntered,
|
||||
cwEnteredBy: this.cwEnteredBy,
|
||||
cwUpdatedBy: this.cwUpdatedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ export class CatalogItemController {
|
||||
public readonly cwCatalogId: number;
|
||||
public readonly identifier: string | null;
|
||||
|
||||
public category: string | null;
|
||||
public categoryCwId: number | null;
|
||||
public subcategory: string | null;
|
||||
public subcategoryCwId: number | null;
|
||||
|
||||
public manufacturer: string | null;
|
||||
public manufactureCwId: number | null;
|
||||
public partNumber: string | null;
|
||||
@@ -55,6 +60,10 @@ export class CatalogItemController {
|
||||
this.internalNotes = itemData.internalNotes;
|
||||
this.cwCatalogId = itemData.cwCatalogId;
|
||||
this.identifier = itemData.identifier;
|
||||
this.category = itemData.category;
|
||||
this.categoryCwId = itemData.categoryCwId;
|
||||
this.subcategory = itemData.subcategory;
|
||||
this.subcategoryCwId = itemData.subcategoryCwId;
|
||||
this.manufacturer = itemData.manufacturer;
|
||||
this.manufactureCwId = itemData.manufactureCwId;
|
||||
this.partNumber = itemData.partNumber;
|
||||
@@ -196,6 +205,10 @@ export class CatalogItemController {
|
||||
description: this.description,
|
||||
customerDescription: this.customerDescription,
|
||||
internalNotes: this.internalNotes,
|
||||
category: this.category,
|
||||
categoryCwId: this.categoryCwId,
|
||||
subcategory: this.subcategory,
|
||||
subcategoryCwId: this.subcategoryCwId,
|
||||
manufacturer: this.manufacturer,
|
||||
manufactureCwId: this.manufactureCwId,
|
||||
partNumber: this.partNumber,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Company } from "../../generated/prisma/client";
|
||||
import { connectWiseApi } from "../constants";
|
||||
import { fetchCwCompanyById } from "../modules/cw-utils/fetchCompany";
|
||||
import { fetchCompanyConfigurations } from "../modules/cw-utils/configurations/fetchCompanyConfigurations";
|
||||
import { updateCwInternalCompany } from "../modules/cw-utils/updateCompany";
|
||||
import {
|
||||
fetchCompanySites,
|
||||
fetchCompanySite,
|
||||
serializeCwSite,
|
||||
} from "../modules/cw-utils/sites/companySites";
|
||||
import { Company as CWCompany, Contact } from "../types/ConnectWiseTypes";
|
||||
|
||||
/**
|
||||
@@ -16,7 +22,7 @@ export class CompanyController {
|
||||
public name: string;
|
||||
public readonly cw_Identifier: string;
|
||||
public readonly cw_CompanyId: number;
|
||||
public readonly cw_Data?: {
|
||||
public cw_Data?: {
|
||||
company: CWCompany;
|
||||
defaultContact: Contact | null;
|
||||
allContacts: Contact[];
|
||||
@@ -30,6 +36,38 @@ export class CompanyController {
|
||||
this.cw_Data = cwData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate CW Data
|
||||
*
|
||||
* Fetches and populates the full ConnectWise company data
|
||||
* (company, default contact, all contacts) if not already loaded.
|
||||
*
|
||||
* @returns {ThisType}
|
||||
*/
|
||||
public async hydrateCwData() {
|
||||
if (this.cw_Data) return this;
|
||||
|
||||
const cwCompany = await fetchCwCompanyById(this.cw_CompanyId);
|
||||
if (!cwCompany) return this;
|
||||
|
||||
const contactHref = cwCompany.defaultContact?._info?.contact_href;
|
||||
const defaultContactData = contactHref
|
||||
? await connectWiseApi.get(contactHref)
|
||||
: undefined;
|
||||
|
||||
const allContactsData = await connectWiseApi.get(
|
||||
`${cwCompany._info.contacts_href}&pageSize=1000`,
|
||||
);
|
||||
|
||||
this.cw_Data = {
|
||||
company: cwCompany,
|
||||
defaultContact: defaultContactData?.data ?? null,
|
||||
allContacts: allContactsData.data,
|
||||
};
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Internal Company Data from ConnectWise
|
||||
*
|
||||
@@ -71,6 +109,30 @@ export class CompanyController {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company Sites
|
||||
*
|
||||
* Retrieves all sites for this company from ConnectWise
|
||||
* and returns them as serialized site objects.
|
||||
*/
|
||||
public async fetchSites() {
|
||||
const sites = await fetchCompanySites(this.cw_CompanyId);
|
||||
return sites.map(serializeCwSite);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company Site by ID
|
||||
*
|
||||
* Retrieves a single site by its ConnectWise site ID
|
||||
* and returns a serialized site object.
|
||||
*
|
||||
* @param cwSiteId - The ConnectWise site ID
|
||||
*/
|
||||
public async fetchSite(cwSiteId: number) {
|
||||
const site = await fetchCompanySite(this.cw_CompanyId, cwSiteId);
|
||||
return serializeCwSite(site);
|
||||
}
|
||||
|
||||
public toJson(opts?: {
|
||||
includeAddress: boolean;
|
||||
includePrimaryContact: boolean;
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { CWForecastItem } from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
|
||||
/**
|
||||
* Forecast Product Controller
|
||||
*
|
||||
* Domain model class that encapsulates a ConnectWise Forecast Item (product/
|
||||
* revenue line item on an opportunity). Forecast products are not persisted
|
||||
* locally — all data is sourced directly from the ConnectWise API.
|
||||
*/
|
||||
export class ForecastProductController {
|
||||
public readonly cwForecastId: number;
|
||||
public forecastDescription: string;
|
||||
|
||||
public opportunityCwId: number | null;
|
||||
public opportunityName: string | null;
|
||||
|
||||
public quantity: number;
|
||||
|
||||
public statusCwId: number | null;
|
||||
public statusName: string | null;
|
||||
|
||||
public catalogItemCwId: number | null;
|
||||
public catalogItemIdentifier: string | null;
|
||||
|
||||
public productDescription: string;
|
||||
public productClass: string;
|
||||
public forecastType: string;
|
||||
|
||||
public revenue: number;
|
||||
public cost: number;
|
||||
public margin: number;
|
||||
public percentage: number;
|
||||
|
||||
public includeFlag: boolean;
|
||||
public linkFlag: boolean;
|
||||
public recurringFlag: boolean;
|
||||
public taxableFlag: boolean;
|
||||
|
||||
public recurringRevenue: number;
|
||||
public recurringCost: number;
|
||||
public cycles: number;
|
||||
|
||||
public sequenceNumber: number;
|
||||
public subNumber: number;
|
||||
public quoteWerksQuantity: number;
|
||||
|
||||
public cwLastUpdated: Date | null;
|
||||
public cwUpdatedBy: string | null;
|
||||
|
||||
// Cancellation data (from procurement products endpoint)
|
||||
public cancelledFlag: boolean;
|
||||
public quantityCancelled: number;
|
||||
public cancelledReason: string | null;
|
||||
public cancelledBy: number | null;
|
||||
public cancelledDate: Date | null;
|
||||
|
||||
// Internal inventory data (from local CatalogItem database)
|
||||
public onHand: number | null;
|
||||
public inStock: boolean | null;
|
||||
|
||||
constructor(data: CWForecastItem) {
|
||||
this.cwForecastId = data.id;
|
||||
this.forecastDescription = data.forecastDescription;
|
||||
|
||||
this.opportunityCwId = data.opportunity?.id ?? null;
|
||||
this.opportunityName = data.opportunity?.name ?? null;
|
||||
|
||||
this.quantity = data.quantity;
|
||||
|
||||
this.statusCwId = data.status?.id ?? null;
|
||||
this.statusName = data.status?.name ?? null;
|
||||
|
||||
this.catalogItemCwId = data.catalogItem?.id ?? null;
|
||||
this.catalogItemIdentifier = data.catalogItem?.identifier ?? null;
|
||||
|
||||
this.productDescription = data.productDescription;
|
||||
this.productClass = data.productClass;
|
||||
this.forecastType = data.forecastType;
|
||||
|
||||
this.revenue = data.revenue;
|
||||
this.cost = data.cost;
|
||||
this.margin = data.margin;
|
||||
this.percentage = data.percentage;
|
||||
|
||||
this.includeFlag = data.includeFlag ?? false;
|
||||
this.linkFlag = data.linkFlag ?? false;
|
||||
this.recurringFlag = data.recurringFlag ?? false;
|
||||
this.taxableFlag = data.taxableFlag ?? false;
|
||||
|
||||
this.recurringRevenue = data.recurringRevenue ?? 0;
|
||||
this.recurringCost = data.recurringCost ?? 0;
|
||||
this.cycles = data.cycles ?? 0;
|
||||
|
||||
this.sequenceNumber = data.sequenceNumber ?? 0;
|
||||
this.subNumber = data.subNumber ?? 0;
|
||||
this.quoteWerksQuantity = data.quoteWerksQuantity ?? 0;
|
||||
|
||||
this.cwLastUpdated = data._info?.lastUpdated
|
||||
? new Date(data._info.lastUpdated)
|
||||
: null;
|
||||
this.cwUpdatedBy = data._info?.updatedBy ?? null;
|
||||
|
||||
// Cancellation defaults — enriched later via applyCancellationData()
|
||||
this.cancelledFlag = false;
|
||||
this.quantityCancelled = 0;
|
||||
this.cancelledReason = null;
|
||||
this.cancelledBy = null;
|
||||
this.cancelledDate = null;
|
||||
|
||||
// Inventory defaults — enriched later via applyInventoryData()
|
||||
this.onHand = null;
|
||||
this.inStock = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Cancellation Data
|
||||
*
|
||||
* Enriches this forecast product with cancellation data from the
|
||||
* procurement products endpoint.
|
||||
*/
|
||||
public applyCancellationData(data: {
|
||||
cancelledFlag?: boolean;
|
||||
quantityCancelled?: number;
|
||||
cancelledReason?: string;
|
||||
cancelledBy?: number;
|
||||
cancelledDate?: string;
|
||||
}): void {
|
||||
this.cancelledFlag = data.cancelledFlag ?? false;
|
||||
this.quantityCancelled = data.quantityCancelled ?? 0;
|
||||
this.cancelledReason = data.cancelledReason ?? null;
|
||||
this.cancelledBy = data.cancelledBy ?? null;
|
||||
this.cancelledDate = data.cancelledDate
|
||||
? new Date(data.cancelledDate)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Inventory Data
|
||||
*
|
||||
* Enriches this forecast product with internal inventory data from
|
||||
* the local CatalogItem database.
|
||||
*/
|
||||
public applyInventoryData(data: { onHand: number }): void {
|
||||
this.onHand = data.onHand;
|
||||
this.inStock = data.onHand > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profit
|
||||
*
|
||||
* Returns the calculated profit (revenue - cost).
|
||||
*/
|
||||
public get profit(): number {
|
||||
return this.revenue - this.cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelled
|
||||
*
|
||||
* Returns true if the forecast item has been cancelled (fully or partially).
|
||||
*/
|
||||
public get cancelled(): boolean {
|
||||
return this.cancelledFlag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellation Type
|
||||
*
|
||||
* Returns the type of cancellation:
|
||||
* - `"full"` — all units have been cancelled (`quantityCancelled >= quantity`)
|
||||
* - `"partial"` — some units cancelled but not all
|
||||
* - `null` — not cancelled
|
||||
*/
|
||||
public get cancellationType(): "full" | "partial" | null {
|
||||
if (!this.cancelledFlag || this.quantityCancelled <= 0) return null;
|
||||
return this.quantityCancelled >= this.quantity ? "full" : "partial";
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Serializes the forecast product into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
return {
|
||||
id: this.cwForecastId,
|
||||
forecastDescription: this.forecastDescription,
|
||||
opportunity: this.opportunityCwId
|
||||
? { id: this.opportunityCwId, name: this.opportunityName }
|
||||
: null,
|
||||
quantity: this.quantity,
|
||||
status: this.statusCwId
|
||||
? { id: this.statusCwId, name: this.statusName }
|
||||
: null,
|
||||
cancelled: this.cancelled,
|
||||
cancellationType: this.cancellationType,
|
||||
quantityCancelled: this.quantityCancelled,
|
||||
cancelledReason: this.cancelledReason,
|
||||
cancelledDate: this.cancelledDate,
|
||||
catalogItem: this.catalogItemCwId
|
||||
? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier }
|
||||
: null,
|
||||
productDescription: this.productDescription,
|
||||
productClass: this.productClass,
|
||||
forecastType: this.forecastType,
|
||||
revenue: this.revenue,
|
||||
cost: this.cost,
|
||||
margin: this.margin,
|
||||
profit: this.profit,
|
||||
percentage: this.percentage,
|
||||
includeFlag: this.includeFlag,
|
||||
linkFlag: this.linkFlag,
|
||||
recurringFlag: this.recurringFlag,
|
||||
taxableFlag: this.taxableFlag,
|
||||
recurringRevenue: this.recurringRevenue,
|
||||
recurringCost: this.recurringCost,
|
||||
cycles: this.cycles,
|
||||
sequenceNumber: this.sequenceNumber,
|
||||
subNumber: this.subNumber,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
cwUpdatedBy: this.cwUpdatedBy,
|
||||
onHand: this.onHand,
|
||||
inStock: this.inStock,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,37 @@
|
||||
import { Opportunity } from "../../generated/prisma/client";
|
||||
import { Company, Opportunity } from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { CompanyController } from "./CompanyController";
|
||||
import { ActivityController } from "./ActivityController";
|
||||
import { fetchOpportunity } from "../modules/cw-utils/opportunities/fetchOpportunity";
|
||||
import { CWOpportunity } from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import {
|
||||
fetchCompanySite,
|
||||
serializeCwSite,
|
||||
} from "../modules/cw-utils/sites/companySites";
|
||||
import {
|
||||
CWCustomField,
|
||||
CWForecastItemCreate,
|
||||
CWOpportunity,
|
||||
CWOpportunityNote,
|
||||
} from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
import { resolveMember } from "../modules/cw-utils/members/memberCache";
|
||||
import { ForecastProductController } from "./ForecastProductController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { computeSubResourceCacheTTL } from "../modules/algorithms/computeSubResourceCacheTTL";
|
||||
import { computeProductsCacheTTL } from "../modules/algorithms/computeProductsCacheTTL";
|
||||
import {
|
||||
getCachedNotes,
|
||||
getCachedContacts,
|
||||
getCachedProducts,
|
||||
getCachedSite,
|
||||
fetchAndCacheNotes,
|
||||
fetchAndCacheContacts,
|
||||
fetchAndCacheProducts,
|
||||
fetchAndCacheSite,
|
||||
invalidateNotesCache,
|
||||
invalidateProductsCache,
|
||||
} from "../modules/cache/opportunityCache";
|
||||
|
||||
/**
|
||||
* Opportunity Controller
|
||||
@@ -63,10 +93,47 @@ export class OpportunityController {
|
||||
public companyId: string | null;
|
||||
public cwLastUpdated: Date | null;
|
||||
|
||||
// Local product display order — array of CW forecast item IDs.
|
||||
// When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
|
||||
public productSequence: number[];
|
||||
|
||||
public readonly createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(data: Opportunity) {
|
||||
private _company: CompanyController | null = null;
|
||||
private _siteData: ReturnType<typeof serializeCwSite> | null = null;
|
||||
private _customFields: CWCustomField[] | null = null;
|
||||
private _activities: ActivityController[] | null = null;
|
||||
|
||||
/** Compute the sub-resource cache TTL from this opportunity's fields. */
|
||||
private _subResourceTTL(): number | null {
|
||||
return computeSubResourceCacheTTL({
|
||||
closedFlag: this.closedFlag,
|
||||
closedDate: this.closedDate,
|
||||
expectedCloseDate: this.expectedCloseDate,
|
||||
lastUpdated: this.cwLastUpdated,
|
||||
});
|
||||
}
|
||||
|
||||
/** Compute the products-specific cache TTL from this opportunity's fields. */
|
||||
private _productsTTL(): number | null {
|
||||
return computeProductsCacheTTL({
|
||||
closedFlag: this.closedFlag,
|
||||
closedDate: this.closedDate,
|
||||
expectedCloseDate: this.expectedCloseDate,
|
||||
lastUpdated: this.cwLastUpdated,
|
||||
statusCwId: this.statusCwId,
|
||||
});
|
||||
}
|
||||
|
||||
constructor(
|
||||
data: Opportunity & { company?: Company | null },
|
||||
opts?: {
|
||||
company?: CompanyController;
|
||||
customFields?: CWCustomField[];
|
||||
activities?: ActivityController[];
|
||||
},
|
||||
) {
|
||||
this.id = data.id;
|
||||
this.cwOpportunityId = data.cwOpportunityId;
|
||||
this.name = data.name;
|
||||
@@ -118,9 +185,43 @@ export class OpportunityController {
|
||||
|
||||
this.companyId = data.companyId;
|
||||
this.cwLastUpdated = data.cwLastUpdated;
|
||||
this.productSequence = data.productSequence;
|
||||
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
|
||||
this._company =
|
||||
opts?.company ??
|
||||
(data.company ? new CompanyController(data.company) : null);
|
||||
|
||||
this._customFields = opts?.customFields ?? null;
|
||||
this._activities = opts?.activities ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company
|
||||
*
|
||||
* Lazily loads the associated CompanyController from the database
|
||||
* if not already loaded via the Prisma include.
|
||||
*
|
||||
* @returns {Promise<CompanyController | null>}
|
||||
*/
|
||||
public async fetchCompany(): Promise<CompanyController | null> {
|
||||
if (this._company) {
|
||||
await this._company.hydrateCwData();
|
||||
return this._company;
|
||||
}
|
||||
if (!this.companyId) return null;
|
||||
|
||||
const companyData = await prisma.company.findUnique({
|
||||
where: { id: this.companyId },
|
||||
});
|
||||
|
||||
if (!companyData) return null;
|
||||
|
||||
this._company = new CompanyController(companyData);
|
||||
await this._company.hydrateCwData();
|
||||
return this._company;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +237,7 @@ export class OpportunityController {
|
||||
const updated = await prisma.opportunity.update({
|
||||
where: { id: this.id },
|
||||
data: mapped,
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
return new OpportunityController(updated);
|
||||
@@ -216,6 +318,549 @@ export class OpportunityController {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Site
|
||||
*
|
||||
* Fetches the full site details (address, phone, flags) from ConnectWise
|
||||
* for the site associated with this opportunity.
|
||||
* Checks the Redis cache first (30-min TTL); on miss, calls CW and caches.
|
||||
* Requires both companyCwId and siteCwId to be set.
|
||||
*
|
||||
* @returns Serialized site object or null
|
||||
*/
|
||||
public async fetchSite() {
|
||||
if (this._siteData) return this._siteData;
|
||||
if (!this.companyCwId || !this.siteCwId) return null;
|
||||
|
||||
// Try cache first
|
||||
const cached = await getCachedSite(this.companyCwId, this.siteCwId);
|
||||
if (cached) {
|
||||
this._siteData = serializeCwSite(cached);
|
||||
return this._siteData;
|
||||
}
|
||||
|
||||
// Cache miss — fetch from CW and cache
|
||||
const cwSite = await fetchAndCacheSite(this.companyCwId, this.siteCwId);
|
||||
if (!cwSite) return null;
|
||||
|
||||
this._siteData = serializeCwSite(cwSite);
|
||||
return this._siteData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Contacts
|
||||
*
|
||||
* Fetches contacts associated with this opportunity. Checks the Redis
|
||||
* cache first; on miss, calls ConnectWise and caches the raw response.
|
||||
*
|
||||
* @param opts.fresh - Bypass cache and fetch directly from CW.
|
||||
*/
|
||||
public async fetchContacts(opts?: { fresh?: boolean }) {
|
||||
const ttl = this._subResourceTTL();
|
||||
|
||||
// Try cache first (unless forced fresh)
|
||||
if (!opts?.fresh && ttl !== null) {
|
||||
const cached = await getCachedContacts(this.cwOpportunityId);
|
||||
if (cached) return this._serializeContacts(cached);
|
||||
}
|
||||
|
||||
// Fetch from CW (fetchAndCache* handles 404 internally)
|
||||
try {
|
||||
const contacts =
|
||||
ttl !== null
|
||||
? await fetchAndCacheContacts(this.cwOpportunityId, ttl)
|
||||
: await opportunityCw.fetchContacts(this.cwOpportunityId);
|
||||
|
||||
return this._serializeContacts(contacts);
|
||||
} catch (err: any) {
|
||||
if (err?.isAxiosError && err?.response?.status === 404) return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize raw CW contact data into the API response shape. */
|
||||
private _serializeContacts(contacts: any[]) {
|
||||
return contacts.map((ct: any) => ({
|
||||
id: ct.id,
|
||||
contact: ct.contact ? { id: ct.contact.id, name: ct.contact.name } : null,
|
||||
company: ct.company
|
||||
? {
|
||||
id: ct.company.id,
|
||||
identifier: ct.company.identifier,
|
||||
name: ct.company.name,
|
||||
}
|
||||
: null,
|
||||
role: ct.role ? { id: ct.role.id, name: ct.role.name } : null,
|
||||
notes: ct.notes,
|
||||
referralFlag: ct.referralFlag,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Notes
|
||||
*
|
||||
* Fetches notes associated with this opportunity. Checks the Redis
|
||||
* cache first; on miss, calls ConnectWise and caches the raw response.
|
||||
*
|
||||
* @param opts.fresh - Bypass cache and fetch directly from CW.
|
||||
*/
|
||||
public async fetchNotes(opts?: { fresh?: boolean }) {
|
||||
const ttl = this._subResourceTTL();
|
||||
|
||||
// Try cache first (unless forced fresh)
|
||||
if (!opts?.fresh && ttl !== null) {
|
||||
const cached = await getCachedNotes(this.cwOpportunityId);
|
||||
if (cached) return this._serializeNotes(cached);
|
||||
}
|
||||
|
||||
// Fetch from CW (fetchAndCache* handles 404 internally)
|
||||
try {
|
||||
const notes =
|
||||
ttl !== null
|
||||
? await fetchAndCacheNotes(this.cwOpportunityId, ttl)
|
||||
: await opportunityCw.fetchNotes(this.cwOpportunityId);
|
||||
|
||||
return this._serializeNotes(notes);
|
||||
} catch (err: any) {
|
||||
if (err?.isAxiosError && err?.response?.status === 404) return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize raw CW note data into the API response shape. */
|
||||
private async _serializeNotes(notes: any[]) {
|
||||
return Promise.all(
|
||||
notes.map(async (n: any) => ({
|
||||
id: n.id,
|
||||
text: n.text,
|
||||
type: n.type ? { id: n.type.id, name: n.type.name } : null,
|
||||
flagged: n.flagged,
|
||||
dateEntered: n._info?.lastUpdated
|
||||
? new Date(n._info.lastUpdated)
|
||||
: null,
|
||||
enteredBy: await resolveMember(n.enteredBy),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Single Note
|
||||
*
|
||||
* Fetches a single note by its ID from ConnectWise.
|
||||
*
|
||||
* @param noteId - The CW note ID
|
||||
*/
|
||||
public async fetchNote(noteId: number) {
|
||||
const note = await opportunityCw.fetchNote(this.cwOpportunityId, noteId);
|
||||
|
||||
return {
|
||||
id: note.id,
|
||||
text: note.text,
|
||||
type: note.type ? { id: note.type.id, name: note.type.name } : null,
|
||||
flagged: note.flagged,
|
||||
enteredBy: await resolveMember(note.enteredBy),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Activities
|
||||
*
|
||||
* Fetches activities associated with this opportunity from ConnectWise
|
||||
* and returns an array of ActivityController instances.
|
||||
* Results are cached after the first call.
|
||||
*/
|
||||
public async fetchActivities(): Promise<ActivityController[]> {
|
||||
if (this._activities) return this._activities;
|
||||
|
||||
const collection = await activityCw.fetchByOpportunity(
|
||||
this.cwOpportunityId,
|
||||
);
|
||||
this._activities = collection.map((item) => new ActivityController(item));
|
||||
return this._activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Products
|
||||
*
|
||||
* Fetches products (forecast/revenue items) for this opportunity.
|
||||
* Checks the Redis cache first; on miss, calls ConnectWise and
|
||||
* caches the raw response using the products-specific TTL algorithm.
|
||||
*
|
||||
* @param opts.fresh - Bypass cache and fetch directly from CW.
|
||||
*/
|
||||
public async fetchProducts(opts?: {
|
||||
fresh?: boolean;
|
||||
}): Promise<ForecastProductController[]> {
|
||||
const ttl = this._productsTTL();
|
||||
|
||||
let forecast: any;
|
||||
let procProducts: any[];
|
||||
|
||||
// Try cache first (unless forced fresh)
|
||||
if (!opts?.fresh && ttl !== null) {
|
||||
const cached = await getCachedProducts(this.cwOpportunityId);
|
||||
if (cached) {
|
||||
forecast = cached.forecast;
|
||||
procProducts = cached.procProducts;
|
||||
} else {
|
||||
// Cache miss — fetch from CW and cache
|
||||
const blob = await fetchAndCacheProducts(this.cwOpportunityId, ttl);
|
||||
forecast = blob.forecast;
|
||||
procProducts = blob.procProducts;
|
||||
}
|
||||
} else {
|
||||
// No caching (won/lost/pending or forced fresh) — fetch directly
|
||||
try {
|
||||
[forecast, procProducts] = await Promise.all([
|
||||
opportunityCw.fetchProducts(this.cwOpportunityId),
|
||||
opportunityCw.fetchProcurementProducts(this.cwOpportunityId),
|
||||
]);
|
||||
} catch (err: any) {
|
||||
if (err?.isAxiosError && err?.response?.status === 404) return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return this._buildProductControllers(forecast, procProducts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ForecastProductController[] from raw CW data.
|
||||
*
|
||||
* Extracted from fetchProducts() so both cached and fresh paths
|
||||
* share the same ordering + enrichment logic.
|
||||
*/
|
||||
private async _buildProductControllers(
|
||||
forecast: any,
|
||||
procProducts: any[],
|
||||
): Promise<ForecastProductController[]> {
|
||||
// Build a map of forecastDetailId → procurement product cancellation data
|
||||
const cancellationMap = new Map<number, Record<string, unknown>>();
|
||||
for (const pp of procProducts) {
|
||||
const forecastDetailId = pp.forecastDetailId as number | undefined;
|
||||
if (forecastDetailId) {
|
||||
cancellationMap.set(forecastDetailId, pp);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply local ordering if productSequence is set, otherwise fall back
|
||||
// to CW sequenceNumber.
|
||||
const forecastItems = forecast.forecastItems ?? [];
|
||||
let ordered: typeof forecastItems;
|
||||
|
||||
if (this.productSequence.length > 0) {
|
||||
const itemById = new Map(forecastItems.map((fi: any) => [fi.id, fi]));
|
||||
// Items in the specified order first, then any new items not yet sequenced
|
||||
const sequenced = this.productSequence
|
||||
.map((id) => itemById.get(id))
|
||||
.filter((fi: any): fi is NonNullable<typeof fi> => fi !== undefined);
|
||||
const sequencedIds = new Set(this.productSequence);
|
||||
const unsequenced = forecastItems
|
||||
.filter((fi: any) => !sequencedIds.has(fi.id))
|
||||
.sort((a: any, b: any) => a.sequenceNumber - b.sequenceNumber);
|
||||
ordered = [...sequenced, ...unsequenced];
|
||||
} else {
|
||||
ordered = [...forecastItems].sort(
|
||||
(a: any, b: any) => a.sequenceNumber - b.sequenceNumber,
|
||||
);
|
||||
}
|
||||
|
||||
const controllers: ForecastProductController[] = ordered.map(
|
||||
(item: any) => {
|
||||
const ctrl = new ForecastProductController(item);
|
||||
const procData = cancellationMap.get(item.id);
|
||||
if (procData) {
|
||||
ctrl.applyCancellationData(procData as any);
|
||||
}
|
||||
return ctrl;
|
||||
},
|
||||
);
|
||||
|
||||
// Enrich with internal inventory data from local CatalogItem DB
|
||||
const catalogCwIds = controllers
|
||||
.map((c) => c.catalogItemCwId)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
if (catalogCwIds.length > 0) {
|
||||
const catalogItems = await prisma.catalogItem.findMany({
|
||||
where: { cwCatalogId: { in: catalogCwIds } },
|
||||
select: { cwCatalogId: true, onHand: true },
|
||||
});
|
||||
const inventoryMap = new Map(
|
||||
catalogItems.map((ci) => [ci.cwCatalogId, ci]),
|
||||
);
|
||||
for (const ctrl of controllers) {
|
||||
const inv = ctrl.catalogItemCwId
|
||||
? inventoryMap.get(ctrl.catalogItemCwId)
|
||||
: undefined;
|
||||
if (inv) ctrl.applyInventoryData(inv);
|
||||
}
|
||||
}
|
||||
|
||||
return controllers;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Opportunity Activity / Workflow Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set Internal Review
|
||||
*
|
||||
* The quote is ready to be reviewed before it is ready to be sent.
|
||||
*/
|
||||
public async setInternalReview(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Internal Approved
|
||||
*
|
||||
* The quote has been approved and is ready to be sent out.
|
||||
*/
|
||||
public async setInternalApproved(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Quote Sent
|
||||
*
|
||||
* The quote has been sent to the customer.
|
||||
*/
|
||||
public async setQuoteSent(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Quote Confirmed
|
||||
*
|
||||
* The quote has been received by the customer.
|
||||
*/
|
||||
public async setQuoteConfirmed(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Revision Needed
|
||||
*
|
||||
* The quote needs to be revised and is set to stage revision.
|
||||
*/
|
||||
public async setRevisionNeeded(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Finalized
|
||||
*
|
||||
* Locks any non-admins from modifying the quote, indicating
|
||||
* this is the final iteration of the quote.
|
||||
*/
|
||||
public async setFinalized(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert
|
||||
*
|
||||
* Converts the quote to a ticket and updates all necessary fields.
|
||||
*/
|
||||
public async convert(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Time
|
||||
*
|
||||
* Adds time to an activity on this opportunity.
|
||||
*
|
||||
* @param activityId - The CW activity ID to add time to
|
||||
* @param user - The user identifier adding time
|
||||
*/
|
||||
public async addTime(activityId: number, user: string): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Product
|
||||
*
|
||||
* Updates an existing product/line item on this opportunity via PATCH.
|
||||
*
|
||||
* @param forecastItemId - The CW forecast item ID to update
|
||||
* @param data - Key/value pairs to patch
|
||||
*/
|
||||
public async updateProduct(
|
||||
forecastItemId: number,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<ForecastProductController> {
|
||||
try {
|
||||
const updated = await opportunityCw.updateProduct(
|
||||
this.cwOpportunityId,
|
||||
forecastItemId,
|
||||
data,
|
||||
);
|
||||
await invalidateProductsCache(this.cwOpportunityId);
|
||||
return new ForecastProductController(updated);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`[updateProduct] Failed to patch forecast item ${forecastItemId} on opportunity ${this.cwOpportunityId}`,
|
||||
JSON.stringify(
|
||||
{
|
||||
data,
|
||||
status: err?.response?.status,
|
||||
statusText: err?.response?.statusText,
|
||||
responseData: err?.response?.data,
|
||||
message: err?.message,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resequence Products
|
||||
*
|
||||
* Stores the desired display order of forecast item IDs locally in
|
||||
* the database. No CW API calls are made — CW item IDs are stable
|
||||
* and ordering is applied when `fetchProducts()` is called.
|
||||
*
|
||||
* @param orderedIds - Forecast item IDs in the desired display order
|
||||
*/
|
||||
public async resequenceProducts(
|
||||
orderedIds: number[],
|
||||
): Promise<ForecastProductController[]> {
|
||||
// Validate all IDs exist in CW
|
||||
const forecast = await opportunityCw.fetchProducts(this.cwOpportunityId);
|
||||
const existingIds = new Set(
|
||||
(forecast.forecastItems ?? []).map((fi) => fi.id),
|
||||
);
|
||||
for (const id of orderedIds) {
|
||||
if (!existingIds.has(id)) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${id} not found on opportunity ${this.cwOpportunityId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the sequence locally
|
||||
await prisma.opportunity.update({
|
||||
where: { id: this.id },
|
||||
data: { productSequence: orderedIds },
|
||||
});
|
||||
this.productSequence = orderedIds;
|
||||
|
||||
// Invalidate cached products since ordering changed
|
||||
await invalidateProductsCache(this.cwOpportunityId);
|
||||
|
||||
// Return items in the new order
|
||||
return this.fetchProducts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Products
|
||||
*
|
||||
* Adds one or more products/line items to this opportunity via the
|
||||
* ConnectWise forecast endpoint. The caller passes only the fields
|
||||
* the user is permitted to set (already filtered by field-level
|
||||
* permission gating in the route handler).
|
||||
*
|
||||
* Accepts a single item or an array of items.
|
||||
*/
|
||||
public async addProducts(
|
||||
data: CWForecastItemCreate | CWForecastItemCreate[],
|
||||
): Promise<ForecastProductController[]> {
|
||||
try {
|
||||
const created = await opportunityCw.createProducts(
|
||||
this.cwOpportunityId,
|
||||
data,
|
||||
);
|
||||
await invalidateProductsCache(this.cwOpportunityId);
|
||||
return created.map((item) => new ForecastProductController(item));
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`[addProducts] Failed to create forecast item(s) on opportunity ${this.cwOpportunityId}`,
|
||||
JSON.stringify(
|
||||
{
|
||||
data,
|
||||
status: err?.response?.status,
|
||||
statusText: err?.response?.statusText,
|
||||
responseData: err?.response?.data,
|
||||
message: err?.message,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
throw new GenericError({
|
||||
status: err?.response?.status ?? 500,
|
||||
name: "AddProductFailed",
|
||||
message:
|
||||
err?.response?.data?.message ??
|
||||
"Failed to add product(s) to opportunity",
|
||||
cause: err?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Note
|
||||
*
|
||||
* Creates a new note on this opportunity in ConnectWise.
|
||||
*
|
||||
* @param note - The note text to add
|
||||
* @param user - The user identifier adding the note
|
||||
* @param opts - Optional flags
|
||||
*/
|
||||
public async addNote(
|
||||
note: string,
|
||||
user: string,
|
||||
opts?: { flagged?: boolean },
|
||||
): Promise<CWOpportunityNote> {
|
||||
const created = await opportunityCw.createNote(this.cwOpportunityId, {
|
||||
text: note,
|
||||
flagged: opts?.flagged ?? false,
|
||||
});
|
||||
await invalidateNotesCache(this.cwOpportunityId);
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Note
|
||||
*
|
||||
* Updates an existing note on this opportunity in ConnectWise.
|
||||
*
|
||||
* @param noteId - The CW note ID to update
|
||||
* @param data - The fields to update
|
||||
*/
|
||||
public async updateNote(
|
||||
noteId: number,
|
||||
data: { text?: string; flagged?: boolean },
|
||||
): Promise<CWOpportunityNote> {
|
||||
const updated = await opportunityCw.updateNote(
|
||||
this.cwOpportunityId,
|
||||
noteId,
|
||||
data,
|
||||
);
|
||||
await invalidateNotesCache(this.cwOpportunityId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Note
|
||||
*
|
||||
* Deletes a note from this opportunity in ConnectWise.
|
||||
*
|
||||
* @param noteId - The CW note ID to delete
|
||||
*/
|
||||
public async deleteNote(noteId: number): Promise<void> {
|
||||
await opportunityCw.deleteNote(this.cwOpportunityId, noteId);
|
||||
await invalidateNotesCache(this.cwOpportunityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
@@ -258,13 +903,23 @@ export class OpportunityController {
|
||||
name: this.secondarySalesRepName,
|
||||
}
|
||||
: null,
|
||||
company: this.companyCwId
|
||||
company: this._company
|
||||
? this._company.toJson({
|
||||
includeAllContacts: true,
|
||||
includeAddress: true,
|
||||
includePrimaryContact: false,
|
||||
})
|
||||
: this.companyCwId
|
||||
? { id: this.companyCwId, name: this.companyName }
|
||||
: null,
|
||||
contact: this.contactCwId
|
||||
? { id: this.contactCwId, name: this.contactName }
|
||||
: null,
|
||||
site: this.siteCwId ? { id: this.siteCwId, name: this.siteName } : null,
|
||||
site: this._siteData
|
||||
? this._siteData
|
||||
: this.siteCwId
|
||||
? { id: this.siteCwId, name: this.siteName }
|
||||
: null,
|
||||
customerPO: this.customerPO,
|
||||
totalSalesTax: this.totalSalesTax,
|
||||
location: this.locationCwId
|
||||
@@ -283,8 +938,11 @@ export class OpportunityController {
|
||||
: null,
|
||||
companyId: this.companyId,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
productSequence: this.productSequence,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
customFields: this._customFields ?? [],
|
||||
activities: this._activities?.map((a) => a.toJson()) ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ export class RoleController {
|
||||
private _permissionsToken: string;
|
||||
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 updatedAt: Date;
|
||||
|
||||
@@ -62,6 +65,14 @@ export class RoleController {
|
||||
* @returns - Verified object with permissions in it.
|
||||
*/
|
||||
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;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,10 +19,18 @@ export default class UserController {
|
||||
public login: string;
|
||||
public email: string;
|
||||
public image: string | null;
|
||||
public cwIdentifier: string | null;
|
||||
|
||||
private _roles: Collection<string, Role>;
|
||||
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 updatedAt: Date;
|
||||
constructor(userdata: User & { roles: Role[] }) {
|
||||
@@ -31,6 +39,7 @@ export default class UserController {
|
||||
this.login = userdata.login;
|
||||
this.email = userdata.email;
|
||||
this.image = userdata.image;
|
||||
this.cwIdentifier = userdata.cwIdentifier ?? null;
|
||||
this.updatedAt = userdata.updatedAt;
|
||||
this.createdAt = userdata.createdAt;
|
||||
this._permissions = userdata.permissions ?? null;
|
||||
@@ -57,6 +66,7 @@ export default class UserController {
|
||||
this.login = userdata.login;
|
||||
this.email = userdata.email;
|
||||
this.image = userdata.image;
|
||||
this.cwIdentifier = userdata.cwIdentifier ?? null;
|
||||
this.updatedAt = userdata.updatedAt;
|
||||
this.createdAt = userdata.createdAt;
|
||||
}
|
||||
@@ -124,6 +134,7 @@ export default class UserController {
|
||||
this._updateInternalValues(updatedUser);
|
||||
this._roles = new Collection<string, Role>();
|
||||
updatedUser.roles.map((v: any) => this._roles.set(v.id, v));
|
||||
this.clearPermissionCache();
|
||||
|
||||
for (const role of resolvedRoles) {
|
||||
events.emit("user:role:assigned", { user: this, role });
|
||||
@@ -249,35 +260,34 @@ export default class UserController {
|
||||
* @returns {boolean} Does this user have the specified permission
|
||||
*/
|
||||
public async hasPermission(permission: string) {
|
||||
let resources = await prisma.user.findFirst({
|
||||
where: { id: this.id },
|
||||
select: {
|
||||
sessions: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
// Fast path: return cached result if we already resolved this permission
|
||||
const cached = this._permissionCache.get(permission);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
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
|
||||
? resourceKeys
|
||||
// @ts-ignore
|
||||
.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`,
|
||||
)
|
||||
: [];
|
||||
const result = this._resolvedRoleControllers
|
||||
.map((v) => v.checkPermission(permission))
|
||||
.includes(true);
|
||||
|
||||
let checks = [
|
||||
(await this.fetchRoles()).map((v) => v.checkPermission(permission)),
|
||||
].flatMap((v) => v);
|
||||
this._permissionCache.set(permission, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,6 +324,7 @@ export default class UserController {
|
||||
})(),
|
||||
login: opts?.safeReturn ? undefined : this.login,
|
||||
email: opts?.safeReturn ? undefined : this.email,
|
||||
cwIdentifier: opts?.safeReturn ? undefined : this.cwIdentifier,
|
||||
image: this.image,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
|
||||
+96
-29
@@ -13,6 +13,9 @@ import { refreshCompanies } from "./modules/cw-utils/refreshCompanies";
|
||||
import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog";
|
||||
import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory";
|
||||
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
|
||||
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
|
||||
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
|
||||
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
|
||||
import { events, setupEventDebugger } from "./modules/globalEvents";
|
||||
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
||||
import { RoleController } from "./controllers/RoleController";
|
||||
@@ -21,7 +24,55 @@ import cuid from "cuid";
|
||||
// Setup global event debugger in non-production environments
|
||||
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.
|
||||
const safeStartup = async (label: string, fn: () => Promise<void>) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[startup] ${label} failed — will retry on next interval`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start the HTTP server FIRST so the pod is reachable immediately.
|
||||
// All data-sync tasks run afterwards and are non-blocking.
|
||||
// ---------------------------------------------------------------------------
|
||||
Bun.serve({
|
||||
port: PORT,
|
||||
idleTimeout: 255,
|
||||
websocket: engine.handler().websocket,
|
||||
fetch: (req, server) => {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname.startsWith("/socket.io/")) {
|
||||
return engine.handleRequest(req, server as any);
|
||||
}
|
||||
|
||||
return app.fetch(req, server);
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[startup] Server listening on port ${PORT}`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background initialisation — none of this blocks the server.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Ensure administrator role exists
|
||||
await safeStartup("ensureAdminRole", async () => {
|
||||
const existingAdmin = await prisma.role.findFirst({
|
||||
where: { moniker: "administrator" },
|
||||
include: { users: { include: { roles: true } } },
|
||||
@@ -44,21 +95,13 @@ if (!existingAdmin) {
|
||||
});
|
||||
events.emit("role:created", new RoleController(created));
|
||||
}
|
||||
|
||||
// Helper to run a startup sync safely — failures are logged but never crash the process.
|
||||
const safeStartup = async (label: string, fn: () => Promise<void>) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.error(`[startup] ${label} failed — will retry on next interval`, err);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Refresh the internal list of companies every minute
|
||||
await safeStartup("refreshCompanies", refreshCompanies);
|
||||
setInterval(() => {
|
||||
return refreshCompanies().catch((err) =>
|
||||
console.error("[interval] refreshCompanies failed", err),
|
||||
console.error(`[interval] refreshCompanies failed: ${briefErr(err)}`),
|
||||
);
|
||||
}, 60 * 1000);
|
||||
|
||||
@@ -66,7 +109,7 @@ setInterval(() => {
|
||||
await safeStartup("refreshCatalog", refreshCatalog);
|
||||
setInterval(() => {
|
||||
return refreshCatalog().catch((err) =>
|
||||
console.error("[interval] refreshCatalog failed", err),
|
||||
console.error(`[interval] refreshCatalog failed: ${briefErr(err)}`),
|
||||
);
|
||||
}, 60 * 1000);
|
||||
|
||||
@@ -75,7 +118,7 @@ await safeStartup("refreshInventory", refreshInventory);
|
||||
setInterval(
|
||||
() => {
|
||||
return refreshInventory().catch((err) =>
|
||||
console.error("[interval] refreshInventory failed", err),
|
||||
console.error(`[interval] refreshInventory failed: ${briefErr(err)}`),
|
||||
);
|
||||
},
|
||||
2 * 60 * 1000,
|
||||
@@ -85,27 +128,51 @@ setInterval(
|
||||
await safeStartup("refreshOpportunities", refreshOpportunities);
|
||||
setInterval(() => {
|
||||
return refreshOpportunities().catch((err) =>
|
||||
console.error("[interval] refreshOpportunities failed", err),
|
||||
console.error(`[interval] refreshOpportunities failed: ${briefErr(err)}`),
|
||||
);
|
||||
}, 60 * 1000);
|
||||
|
||||
// Refresh opportunity CW cache every 30 seconds (activities + company hydration)
|
||||
// NOTE: Do NOT await — register the interval immediately so the cache refresh
|
||||
// is never blocked by a slow/stuck startup task above.
|
||||
safeStartup("refreshOpportunityCache", refreshOpportunityCache);
|
||||
setInterval(() => {
|
||||
return refreshOpportunityCache().catch((err) => {
|
||||
console.error(
|
||||
`[interval] refreshOpportunityCache failed: ${briefErr(err)}`,
|
||||
);
|
||||
});
|
||||
}, 30 * 1000);
|
||||
|
||||
// Refresh User Defined Fields every 5 minutes
|
||||
await safeStartup("refreshUDFs", () => userDefinedFieldsCw.refresh());
|
||||
setInterval(
|
||||
() => {
|
||||
return userDefinedFieldsCw
|
||||
.refresh()
|
||||
.catch((err) =>
|
||||
console.error(`[interval] refreshUDFs failed: ${briefErr(err)}`),
|
||||
);
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Refresh CW identifiers for all users every 30 minutes
|
||||
await safeStartup("refreshCwIdentifiers", refreshCwIdentifiers);
|
||||
setInterval(
|
||||
() => {
|
||||
return refreshCwIdentifiers().catch((err) =>
|
||||
console.error(`[interval] refreshCwIdentifiers failed: ${briefErr(err)}`),
|
||||
);
|
||||
},
|
||||
30 * 60 * 1000,
|
||||
);
|
||||
|
||||
await safeStartup("syncSites", () => unifiSites.syncSites());
|
||||
setInterval(() => {
|
||||
return unifiSites.syncSites().catch((err) =>
|
||||
console.error("[interval] syncSites failed", err),
|
||||
return unifiSites
|
||||
.syncSites()
|
||||
.catch((err) =>
|
||||
console.error(`[interval] syncSites failed: ${briefErr(err)}`),
|
||||
);
|
||||
}, 60 * 1000);
|
||||
|
||||
Bun.serve({
|
||||
port: PORT,
|
||||
websocket: engine.handler().websocket,
|
||||
fetch: (req, server) => {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname.startsWith("/socket.io/")) {
|
||||
return engine.handleRequest(req, server as any);
|
||||
}
|
||||
|
||||
return app.fetch(req, server);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ActivityController } from "../controllers/ActivityController";
|
||||
import { connectWiseApi } from "../constants";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import {
|
||||
CWCreateActivity,
|
||||
CWPatchOperation,
|
||||
} from "../modules/cw-utils/activities/activity.types";
|
||||
|
||||
export const activities = {
|
||||
/**
|
||||
* Fetch Activity
|
||||
*
|
||||
* Fetch a single activity by its ConnectWise activity ID
|
||||
* and return an ActivityController instance.
|
||||
*
|
||||
* @param cwActivityId - The ConnectWise activity ID
|
||||
* @returns {Promise<ActivityController>}
|
||||
*/
|
||||
async fetchItem(cwActivityId: number): Promise<ActivityController> {
|
||||
try {
|
||||
const cwData = await activityCw.fetch(cwActivityId);
|
||||
return new ActivityController(cwData);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "FetchActivityError",
|
||||
message: `Failed to fetch activity ${cwActivityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: (error as any).status ?? 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Activities (Paginated)
|
||||
*
|
||||
* Fetches activities from ConnectWise with optional conditions and pagination.
|
||||
*
|
||||
* @param page - Page number (1-based)
|
||||
* @param rpp - Records per page
|
||||
* @param conditions - Optional CW conditions string for filtering
|
||||
* @returns {Promise<ActivityController[]>}
|
||||
*/
|
||||
async fetchPages(
|
||||
page: number,
|
||||
rpp: number,
|
||||
conditions?: string,
|
||||
): Promise<ActivityController[]> {
|
||||
try {
|
||||
const pageNum = Math.max(page, 1);
|
||||
const conditionsParam = conditions
|
||||
? `&conditions=${encodeURIComponent(conditions)}`
|
||||
: "";
|
||||
const response = await connectWiseApi.get(
|
||||
`/sales/activities?page=${pageNum}&pageSize=${rpp}${conditionsParam}`,
|
||||
);
|
||||
const items = response.data;
|
||||
return items.map((item: any) => new ActivityController(item));
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "FetchActivitiesError",
|
||||
message: "Failed to fetch activities from ConnectWise",
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Activities by Company
|
||||
*
|
||||
* Fetches all activities for a company by its ConnectWise company ID.
|
||||
*
|
||||
* @param cwCompanyId - The ConnectWise company ID
|
||||
* @returns {Promise<ActivityController[]>}
|
||||
*/
|
||||
async fetchByCompany(cwCompanyId: number): Promise<ActivityController[]> {
|
||||
try {
|
||||
const collection = await activityCw.fetchByCompany(cwCompanyId);
|
||||
return collection.map((item) => new ActivityController(item));
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "FetchCompanyActivitiesError",
|
||||
message: `Failed to fetch activities for company ${cwCompanyId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Activities by Opportunity
|
||||
*
|
||||
* Fetches all activities for an opportunity by its ConnectWise opportunity ID.
|
||||
*
|
||||
* @param cwOpportunityId - The ConnectWise opportunity ID
|
||||
* @returns {Promise<ActivityController[]>}
|
||||
*/
|
||||
async fetchByOpportunity(
|
||||
cwOpportunityId: number,
|
||||
): Promise<ActivityController[]> {
|
||||
try {
|
||||
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
|
||||
return collection.map((item) => new ActivityController(item));
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "FetchOpportunityActivitiesError",
|
||||
message: `Failed to fetch activities for opportunity ${cwOpportunityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create Activity
|
||||
*
|
||||
* Creates a new activity in ConnectWise and returns an ActivityController.
|
||||
*
|
||||
* @param data - The activity data to create
|
||||
* @returns {Promise<ActivityController>}
|
||||
*/
|
||||
async create(data: CWCreateActivity): Promise<ActivityController> {
|
||||
try {
|
||||
return await ActivityController.create(data);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "CreateActivityError",
|
||||
message: "Failed to create activity in ConnectWise",
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update Activity
|
||||
*
|
||||
* Updates an existing activity in ConnectWise using JSON Patch operations
|
||||
* and returns an updated ActivityController.
|
||||
*
|
||||
* @param cwActivityId - The ConnectWise activity ID to update
|
||||
* @param operations - Array of JSON Patch operations to apply
|
||||
* @returns {Promise<ActivityController>}
|
||||
*/
|
||||
async update(
|
||||
cwActivityId: number,
|
||||
operations: CWPatchOperation[],
|
||||
): Promise<ActivityController> {
|
||||
try {
|
||||
const updated = await activityCw.update(cwActivityId, operations);
|
||||
return new ActivityController(updated);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "UpdateActivityError",
|
||||
message: `Failed to update activity ${cwActivityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Activity
|
||||
*
|
||||
* Deletes an activity from ConnectWise.
|
||||
*
|
||||
* @param cwActivityId - The ConnectWise activity ID to delete
|
||||
*/
|
||||
async delete(cwActivityId: number): Promise<void> {
|
||||
try {
|
||||
await activityCw.delete(cwActivityId);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "DeleteActivityError",
|
||||
message: `Failed to delete activity ${cwActivityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Count Activities
|
||||
*
|
||||
* Returns the total number of activities, optionally filtered.
|
||||
*
|
||||
* @param conditions - Optional CW conditions string for filtering
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async count(conditions?: string): Promise<number> {
|
||||
try {
|
||||
return await activityCw.countItems(conditions);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "CountActivitiesError",
|
||||
message: "Failed to count activities in ConnectWise",
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
+353
-12
@@ -1,28 +1,158 @@
|
||||
import { Company } from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { ActivityController } from "../controllers/ActivityController";
|
||||
import { CompanyController } from "../controllers/CompanyController";
|
||||
import { OpportunityController } from "../controllers/OpportunityController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
||||
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
|
||||
import {
|
||||
getCachedActivities,
|
||||
getCachedCompanyCwData,
|
||||
getCachedOppCwData,
|
||||
fetchAndCacheActivities,
|
||||
fetchAndCacheCompanyCwData,
|
||||
fetchAndCacheOppCwData,
|
||||
} from "../modules/cache/opportunityCache";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data-source hierarchy helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a CompanyController with hydrated CW data from a Prisma Company record.
|
||||
*
|
||||
* Data-source hierarchy (controlled by `strategy`):
|
||||
*
|
||||
* - `"cache-only"` — Redis cache → bare DB record (no CW call).
|
||||
* Ideal for list views where latency matters and the background
|
||||
* refresh job is responsible for keeping the cache warm.
|
||||
*
|
||||
* - `"cache-then-cw"` (default) — Redis cache → CW API → cache result.
|
||||
* On a cold cache, calls CW to ensure the caller gets full data.
|
||||
*
|
||||
* - `"cw-first"` — CW API (always) → cache result.
|
||||
* Forces a fresh fetch regardless of cache state.
|
||||
*/
|
||||
async function buildCompanyController(
|
||||
company: Company,
|
||||
opts?: {
|
||||
strategy?: "cache-only" | "cache-then-cw" | "cw-first";
|
||||
ttlMs?: number;
|
||||
},
|
||||
): Promise<CompanyController> {
|
||||
const strategy = opts?.strategy ?? "cache-then-cw";
|
||||
const ctrl = new CompanyController(company);
|
||||
|
||||
// ── cw-first: always fetch from CW ──────────────────────────────────
|
||||
if (strategy === "cw-first") {
|
||||
await ctrl.hydrateCwData();
|
||||
if (ctrl.cw_Data && opts?.ttlMs) {
|
||||
await fetchAndCacheCompanyCwData(company.cw_CompanyId, opts.ttlMs).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
// ── cache-only / cache-then-cw: try Redis first ─────────────────────
|
||||
const cached = await getCachedCompanyCwData(company.cw_CompanyId);
|
||||
if (cached) {
|
||||
ctrl.cw_Data = cached;
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
// cache-only stops here — return the bare DB-backed controller
|
||||
if (strategy === "cache-only") return ctrl;
|
||||
|
||||
// cache-then-cw: cache miss — fall through to CW
|
||||
await ctrl.hydrateCwData();
|
||||
if (ctrl.cw_Data && opts?.ttlMs) {
|
||||
await fetchAndCacheCompanyCwData(company.cw_CompanyId, opts.ttlMs).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ActivityController[] for an opportunity.
|
||||
*
|
||||
* Same three strategies as {@link buildCompanyController}:
|
||||
*
|
||||
* - `"cache-only"` — Redis → empty array (no CW call).
|
||||
* - `"cache-then-cw"` (default) — Redis → CW API → cache result.
|
||||
* - `"cw-first"` — CW API (always) → cache result.
|
||||
*/
|
||||
async function buildActivities(
|
||||
cwOpportunityId: number,
|
||||
opts?: {
|
||||
strategy?: "cache-only" | "cache-then-cw" | "cw-first";
|
||||
ttlMs?: number;
|
||||
},
|
||||
): Promise<ActivityController[]> {
|
||||
const strategy = opts?.strategy ?? "cache-then-cw";
|
||||
|
||||
// ── cw-first: always fetch from CW ──────────────────────────────────
|
||||
if (strategy === "cw-first") {
|
||||
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
|
||||
const arr = collection.map((item) => item);
|
||||
if (opts?.ttlMs) {
|
||||
await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
return arr.map((item) => new ActivityController(item));
|
||||
}
|
||||
|
||||
// ── cache-only / cache-then-cw: try Redis first ─────────────────────
|
||||
const cached = await getCachedActivities(cwOpportunityId);
|
||||
if (cached) {
|
||||
return cached.map((item) => new ActivityController(item));
|
||||
}
|
||||
|
||||
// cache-only stops here — return empty (background job will fill it)
|
||||
if (strategy === "cache-only") return [];
|
||||
|
||||
// cache-then-cw: cache miss — fall through to CW
|
||||
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
|
||||
const arr = collection.map((item) => item);
|
||||
if (opts?.ttlMs) {
|
||||
await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs).catch(() => {});
|
||||
}
|
||||
return arr.map((item) => new ActivityController(item));
|
||||
}
|
||||
|
||||
export const opportunities = {
|
||||
/**
|
||||
* Fetch Opportunity
|
||||
* Fetch Record (lightweight)
|
||||
*
|
||||
* Fetch an opportunity by its internal ID or ConnectWise opportunity ID
|
||||
* and return an OpportunityController instance.
|
||||
* Returns an OpportunityController backed only by the **database record**.
|
||||
* No ConnectWise API calls, no Redis lookups, no activity/company hydration.
|
||||
*
|
||||
* @param identifier - The internal ID (string) or CW opportunity ID (number)
|
||||
* 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 fetchItem(identifier: string | number): Promise<OpportunityController> {
|
||||
async fetchRecord(
|
||||
identifier: string | number,
|
||||
): Promise<OpportunityController> {
|
||||
const isNumeric =
|
||||
typeof identifier === "number" || /^\d+$/.test(String(identifier));
|
||||
|
||||
const item = await prisma.opportunity.findFirst({
|
||||
const record = await prisma.opportunity.findFirst({
|
||||
where: isNumeric
|
||||
? { cwOpportunityId: Number(identifier) }
|
||||
: { id: identifier as string },
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
if (!record) {
|
||||
throw new GenericError({
|
||||
message: "Opportunity not found",
|
||||
name: "OpportunityNotFound",
|
||||
@@ -31,12 +161,134 @@ export const opportunities = {
|
||||
});
|
||||
}
|
||||
|
||||
return new OpportunityController(item);
|
||||
return new OpportunityController(record, {
|
||||
company: record.company
|
||||
? new CompanyController(record.company)
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunity
|
||||
*
|
||||
* Fetch an opportunity by its internal ID or ConnectWise opportunity ID
|
||||
* and return an OpportunityController instance.
|
||||
*
|
||||
* **Data-source strategy:**
|
||||
* - `fresh: true` → `"cw-first"` — always fetches from CW, updates DB, caches result.
|
||||
* - `fresh: false` (default) → `"cache-then-cw"` — tries Redis cache for the
|
||||
* CW opportunity response first, falls back to CW on miss.
|
||||
*
|
||||
* The CW opportunity response is cached in Redis with the same TTL as
|
||||
* activities/company. The background refresh keeps this warm so most
|
||||
* detail-view loads skip the CW roundtrip entirely.
|
||||
*
|
||||
* @param identifier - The internal ID (string) or CW opportunity ID (number)
|
||||
* @param opts - Optional flags
|
||||
* @param opts.fresh - When `true`, bypass the cache and pull directly from CW.
|
||||
* @returns {Promise<OpportunityController>}
|
||||
*/
|
||||
async fetchItem(
|
||||
identifier: string | number,
|
||||
opts?: { fresh?: boolean },
|
||||
): Promise<OpportunityController> {
|
||||
const strategy: "cache-only" | "cache-then-cw" | "cw-first" = opts?.fresh
|
||||
? "cw-first"
|
||||
: "cache-then-cw";
|
||||
const isNumeric =
|
||||
typeof identifier === "number" || /^\d+$/.test(String(identifier));
|
||||
|
||||
// Look up the existing DB record (full, with company)
|
||||
const existing = await prisma.opportunity.findFirst({
|
||||
where: isNumeric
|
||||
? { cwOpportunityId: Number(identifier) }
|
||||
: { id: identifier as string },
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new GenericError({
|
||||
message: "Opportunity not found",
|
||||
name: "OpportunityNotFound",
|
||||
cause: `No opportunity exists with identifier '${identifier}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Compute TTL from the current DB state (used for cache and hydration)
|
||||
const ttlMs =
|
||||
computeCacheTTL({
|
||||
closedFlag: existing.closedFlag,
|
||||
closedDate: existing.closedDate,
|
||||
expectedCloseDate: existing.expectedCloseDate,
|
||||
lastUpdated: existing.cwLastUpdated,
|
||||
}) ?? undefined;
|
||||
|
||||
// ── Resolve CW opportunity data (cache-aware) ──────────────────────
|
||||
let cwData: any;
|
||||
let record = existing; // default: use the existing DB record as-is
|
||||
|
||||
if (!opts?.fresh) {
|
||||
// Try the Redis cache first
|
||||
cwData = await getCachedOppCwData(existing.cwOpportunityId);
|
||||
}
|
||||
|
||||
if (!cwData) {
|
||||
// Cache miss or forced fresh — fetch from CW and cache
|
||||
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
|
||||
const [activities, company] = await Promise.all([
|
||||
buildActivities(record.cwOpportunityId, { strategy, ttlMs }),
|
||||
record.company
|
||||
? buildCompanyController(record.company, { strategy, ttlMs })
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
return new OpportunityController(record, {
|
||||
company,
|
||||
customFields: cwData?.customFields ?? [],
|
||||
activities,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Opportunities (Paginated)
|
||||
*
|
||||
* Uses the **cache-only** strategy: Redis → bare DB data.
|
||||
* Activities and company hydration come from the Redis cache if
|
||||
* available; otherwise the controller is returned with DB-only data.
|
||||
* The background refresh job is responsible for keeping Redis warm.
|
||||
*
|
||||
* @param page - Page number (1-based)
|
||||
* @param rpp - Records per page
|
||||
* @param opts - Optional filters
|
||||
@@ -51,12 +303,26 @@ export const opportunities = {
|
||||
|
||||
const items = await prisma.opportunity.findMany({
|
||||
where: opts?.includeClosed ? undefined : { closedFlag: false },
|
||||
include: { company: true },
|
||||
skip,
|
||||
take: rpp,
|
||||
orderBy: { expectedCloseDate: "asc" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return items.map((item) => new OpportunityController(item));
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
return new OpportunityController(item, {
|
||||
company: item.company
|
||||
? await buildCompanyController(item.company, {
|
||||
strategy: "cache-only",
|
||||
})
|
||||
: undefined,
|
||||
activities: await buildActivities(item.cwOpportunityId, {
|
||||
strategy: "cache-only",
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -65,6 +331,8 @@ export const opportunities = {
|
||||
* Search opportunities by name, company name, contact name, notes,
|
||||
* sales rep, or status with pagination support.
|
||||
*
|
||||
* Uses the **cache-only** strategy (same as `fetchPages`).
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param page - Page number (1-based)
|
||||
* @param rpp - Records per page
|
||||
@@ -78,6 +346,9 @@ export const opportunities = {
|
||||
opts?: { includeClosed?: boolean },
|
||||
): Promise<OpportunityController[]> {
|
||||
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||
const numericQuery = /^\d+$/.test(query.trim())
|
||||
? Number(query.trim())
|
||||
: null;
|
||||
|
||||
const items = await prisma.opportunity.findMany({
|
||||
where: {
|
||||
@@ -90,14 +361,31 @@ export const opportunities = {
|
||||
{ primarySalesRepName: { contains: query, mode: "insensitive" } },
|
||||
{ statusName: { contains: query, mode: "insensitive" } },
|
||||
{ stageName: { contains: query, mode: "insensitive" } },
|
||||
...(numericQuery !== null
|
||||
? [{ cwOpportunityId: { equals: numericQuery } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
include: { company: true },
|
||||
skip,
|
||||
take: rpp,
|
||||
orderBy: { expectedCloseDate: "asc" },
|
||||
});
|
||||
|
||||
return items.map((item) => new OpportunityController(item));
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
return new OpportunityController(item, {
|
||||
company: item.company
|
||||
? await buildCompanyController(item.company, {
|
||||
strategy: "cache-only",
|
||||
})
|
||||
: undefined,
|
||||
activities: await buildActivities(item.cwOpportunityId, {
|
||||
strategy: "cache-only",
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -112,11 +400,50 @@ export const opportunities = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Count Search Results
|
||||
*
|
||||
* Returns the total number of opportunities matching a search query,
|
||||
* using the same filter logic as `search()`.
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param opts - Optional filters
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async searchCount(
|
||||
query: string,
|
||||
opts?: { includeClosed?: boolean },
|
||||
): Promise<number> {
|
||||
const numericQuery = /^\d+$/.test(query.trim())
|
||||
? Number(query.trim())
|
||||
: null;
|
||||
|
||||
return prisma.opportunity.count({
|
||||
where: {
|
||||
...(opts?.includeClosed ? {} : { closedFlag: false }),
|
||||
OR: [
|
||||
{ name: { contains: query, mode: "insensitive" } },
|
||||
{ companyName: { contains: query, mode: "insensitive" } },
|
||||
{ contactName: { contains: query, mode: "insensitive" } },
|
||||
{ notes: { contains: query, mode: "insensitive" } },
|
||||
{ primarySalesRepName: { contains: query, mode: "insensitive" } },
|
||||
{ statusName: { contains: query, mode: "insensitive" } },
|
||||
{ stageName: { contains: query, mode: "insensitive" } },
|
||||
...(numericQuery !== null
|
||||
? [{ cwOpportunityId: { equals: numericQuery } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunities by Company
|
||||
*
|
||||
* Fetch all opportunities for a company by its internal company ID.
|
||||
*
|
||||
* Uses the **cache-only** strategy (same as `fetchPages`).
|
||||
*
|
||||
* @param companyId - The internal company ID
|
||||
* @param opts - Optional filters
|
||||
* @returns {Promise<OpportunityController[]>}
|
||||
@@ -130,9 +457,23 @@ export const opportunities = {
|
||||
companyId,
|
||||
...(opts?.includeClosed ? {} : { closedFlag: false }),
|
||||
},
|
||||
include: { company: true },
|
||||
orderBy: { expectedCloseDate: "asc" },
|
||||
});
|
||||
|
||||
return items.map((item) => new OpportunityController(item));
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
return new OpportunityController(item, {
|
||||
company: item.company
|
||||
? await buildCompanyController(item.company, {
|
||||
strategy: "cache-only",
|
||||
})
|
||||
: undefined,
|
||||
activities: await buildActivities(item.cwOpportunityId, {
|
||||
strategy: "cache-only",
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
+171
-10
@@ -1,6 +1,11 @@
|
||||
import { prisma } from "../constants";
|
||||
import { CatalogItemController } from "../controllers/CatalogItemController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import {
|
||||
getSubcategoriesForCategory,
|
||||
getSubcategoriesForGroup,
|
||||
ECOSYSTEM_TREE,
|
||||
} from "../modules/catalog-categories/catalogCategories";
|
||||
|
||||
/**
|
||||
* Standard include clause used by catalog item queries.
|
||||
@@ -10,6 +15,95 @@ const catalogItemInclude = {
|
||||
linkedItems: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Filter options for catalog item queries.
|
||||
*/
|
||||
export interface CatalogFilterOpts {
|
||||
includeInactive?: boolean;
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
group?: string;
|
||||
manufacturer?: string;
|
||||
ecosystem?: string;
|
||||
inStock?: boolean;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Prisma `where` clause from filter options.
|
||||
*/
|
||||
function buildFilterWhere(opts: CatalogFilterOpts = {}) {
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
|
||||
if (!opts.includeInactive) {
|
||||
conditions.push({ inactive: false });
|
||||
}
|
||||
|
||||
if (opts.category) {
|
||||
conditions.push({ category: opts.category });
|
||||
}
|
||||
|
||||
if (opts.subcategory) {
|
||||
conditions.push({ subcategory: opts.subcategory });
|
||||
}
|
||||
|
||||
if (opts.group && opts.category) {
|
||||
const subcats = getSubcategoriesForGroup(opts.category, opts.group);
|
||||
if (subcats.length > 0) {
|
||||
conditions.push({ subcategory: { in: subcats } });
|
||||
}
|
||||
} else if (opts.group && !opts.category) {
|
||||
// Try to find the group in any category
|
||||
const {
|
||||
CATEGORY_TREE,
|
||||
isCategoryGroup,
|
||||
} = require("../modules/catalog-categories/catalogCategories");
|
||||
for (const cat of CATEGORY_TREE) {
|
||||
const subcats = getSubcategoriesForGroup(cat.name, opts.group);
|
||||
if (subcats.length > 0) {
|
||||
conditions.push({ category: cat.name, subcategory: { in: subcats } });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.manufacturer) {
|
||||
conditions.push({
|
||||
manufacturer: { contains: opts.manufacturer, mode: "insensitive" },
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.ecosystem) {
|
||||
const eco = ECOSYSTEM_TREE.find(
|
||||
(e) => e.name.toLowerCase() === opts.ecosystem!.toLowerCase(),
|
||||
);
|
||||
if (eco && eco.manufacturers.length > 0) {
|
||||
conditions.push({
|
||||
OR: eco.manufacturers.map((m) => ({
|
||||
manufacturer: { contains: m.name, mode: "insensitive" as const },
|
||||
subcategory: { startsWith: m.subcategoryPrefix },
|
||||
category: m.category,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.inStock) {
|
||||
conditions.push({ onHand: { gt: 0 } });
|
||||
}
|
||||
|
||||
if (opts.minPrice !== undefined) {
|
||||
conditions.push({ price: { gte: opts.minPrice } });
|
||||
}
|
||||
|
||||
if (opts.maxPrice !== undefined) {
|
||||
conditions.push({ price: { lte: opts.maxPrice } });
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? { AND: conditions } : undefined;
|
||||
}
|
||||
|
||||
export const procurement = {
|
||||
/**
|
||||
* Fetch Catalog Item
|
||||
@@ -51,22 +145,23 @@ export const procurement = {
|
||||
/**
|
||||
* Fetch All Catalog Items (Paginated)
|
||||
*
|
||||
* Fetch pages of catalog items for pagination.
|
||||
* Fetch pages of catalog items for pagination with optional filtering.
|
||||
*
|
||||
* @param page - Page number (1-based)
|
||||
* @param rpp - Records per page
|
||||
* @param opts - Filter options
|
||||
* @returns {Promise<CatalogItemController[]>} - Array of catalog item controllers
|
||||
*/
|
||||
async fetchPages(
|
||||
page: number,
|
||||
rpp: number,
|
||||
opts?: { includeInactive?: boolean },
|
||||
opts?: CatalogFilterOpts,
|
||||
): Promise<CatalogItemController[]> {
|
||||
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||
const take = rpp;
|
||||
|
||||
const items = await prisma.catalogItem.findMany({
|
||||
where: opts?.includeInactive ? undefined : { inactive: false },
|
||||
where: buildFilterWhere(opts),
|
||||
skip,
|
||||
take,
|
||||
include: catalogItemInclude,
|
||||
@@ -80,25 +175,28 @@ export const procurement = {
|
||||
* Search Catalog Items
|
||||
*
|
||||
* Search catalog items by name, description, part number, or vendor SKU
|
||||
* with pagination support.
|
||||
* with pagination support and optional category/subcategory/ecosystem filters.
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param page - Page number (1-based)
|
||||
* @param rpp - Records per page
|
||||
* @param opts - Filter options
|
||||
* @returns {Promise<CatalogItemController[]>} - Array of matching catalog item controllers
|
||||
*/
|
||||
async search(
|
||||
query: string,
|
||||
page: number,
|
||||
rpp: number,
|
||||
opts?: { includeInactive?: boolean },
|
||||
opts?: CatalogFilterOpts,
|
||||
): Promise<CatalogItemController[]> {
|
||||
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||
const take = rpp;
|
||||
|
||||
const filterWhere = buildFilterWhere(opts) ?? {};
|
||||
|
||||
const items = await prisma.catalogItem.findMany({
|
||||
where: {
|
||||
...(opts?.includeInactive ? {} : { inactive: false }),
|
||||
...filterWhere,
|
||||
OR: [
|
||||
{ identifier: { contains: query, mode: "insensitive" } },
|
||||
{ name: { contains: query, mode: "insensitive" } },
|
||||
@@ -120,17 +218,80 @@ export const procurement = {
|
||||
/**
|
||||
* Count Catalog Items
|
||||
*
|
||||
* Returns the total number of catalog items in the database.
|
||||
* Returns the total number of catalog items matching the given filters.
|
||||
*
|
||||
* @param opts - Optional filters
|
||||
* @param opts - Filter options
|
||||
* @returns {Promise<number>} - Total count
|
||||
*/
|
||||
async count(opts?: { activeOnly?: boolean }): Promise<number> {
|
||||
async count(
|
||||
opts?: CatalogFilterOpts & { activeOnly?: boolean },
|
||||
): Promise<number> {
|
||||
// Support legacy `activeOnly` flag by mapping it to `includeInactive`
|
||||
const filterOpts: CatalogFilterOpts = {
|
||||
...opts,
|
||||
includeInactive:
|
||||
opts?.includeInactive ?? (opts?.activeOnly ? false : true),
|
||||
};
|
||||
if (opts?.activeOnly) filterOpts.includeInactive = false;
|
||||
|
||||
return prisma.catalogItem.count({
|
||||
where: opts?.activeOnly ? { inactive: false } : undefined,
|
||||
where: buildFilterWhere(filterOpts),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Count Catalog Items (with search query)
|
||||
*
|
||||
* Returns the total number of catalog items matching a search query and filters.
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param opts - Filter options
|
||||
* @returns {Promise<number>} - Total count
|
||||
*/
|
||||
async countSearch(query: string, opts?: CatalogFilterOpts): Promise<number> {
|
||||
const filterWhere = buildFilterWhere(opts) ?? {};
|
||||
|
||||
return prisma.catalogItem.count({
|
||||
where: {
|
||||
...filterWhere,
|
||||
OR: [
|
||||
{ identifier: { contains: query, mode: "insensitive" } },
|
||||
{ name: { contains: query, mode: "insensitive" } },
|
||||
{ description: { contains: query, mode: "insensitive" } },
|
||||
{ partNumber: { contains: query, mode: "insensitive" } },
|
||||
{ vendorSku: { contains: query, mode: "insensitive" } },
|
||||
{ manufacturer: { contains: query, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Distinct Values
|
||||
*
|
||||
* Returns the distinct values for a given field across all catalog items.
|
||||
* Useful for populating filter dropdowns in the UI.
|
||||
*
|
||||
* @param field - The field to get distinct values for
|
||||
* @param opts - Filter options to scope the distinct query
|
||||
* @returns {Promise<string[]>} - Sorted array of distinct non-null values
|
||||
*/
|
||||
async fetchDistinctValues(
|
||||
field: "category" | "subcategory" | "manufacturer",
|
||||
opts?: CatalogFilterOpts,
|
||||
): Promise<string[]> {
|
||||
const items = await prisma.catalogItem.findMany({
|
||||
where: buildFilterWhere(opts),
|
||||
select: { [field]: true },
|
||||
distinct: [field],
|
||||
orderBy: { [field]: "asc" },
|
||||
});
|
||||
|
||||
return items
|
||||
.map((item: Record<string, unknown>) => item[field] as string | null)
|
||||
.filter((v): v is string => v !== null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Link Catalog Items
|
||||
*
|
||||
|
||||
@@ -4,6 +4,7 @@ import { prisma } from "../constants";
|
||||
import { SessionTokensObject } from "../controllers/SessionController";
|
||||
import UserController from "../controllers/UserController";
|
||||
import { fetchMicrosoftUser } from "../modules/fetchMicrosoftUser";
|
||||
import { findCwIdentifierByEmail } from "../modules/cw-utils/members/fetchAllMembers";
|
||||
import { events } from "../modules/globalEvents";
|
||||
import { sessions } from "./sessions";
|
||||
import * as msal from "@azure/msal-node";
|
||||
@@ -90,12 +91,18 @@ export const users = {
|
||||
async createUser(token: string): Promise<UserController> {
|
||||
const msData = await fetchMicrosoftUser(token);
|
||||
|
||||
// Attempt to resolve the user's ConnectWise identifier by email
|
||||
const cwIdentifier = await findCwIdentifierByEmail(msData.mail).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
userId: msData.id,
|
||||
email: msData.mail,
|
||||
name: `${msData.givenName} ${msData.surname}`,
|
||||
login: msData.userPrincipalName,
|
||||
cwIdentifier,
|
||||
token,
|
||||
},
|
||||
include: { roles: true },
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @module computeCacheTTL
|
||||
*
|
||||
* Adaptive Cache TTL Algorithm
|
||||
* ============================
|
||||
*
|
||||
* Determines how long a cached record should live before it must be
|
||||
* re-fetched from the upstream source (e.g. ConnectWise API).
|
||||
*
|
||||
* The algorithm prioritises freshness for records that are actively
|
||||
* being worked on, while avoiding unnecessary API calls for stale or
|
||||
* inactive data.
|
||||
*
|
||||
* ## Spec
|
||||
*
|
||||
* | # | Condition | TTL (ms) | TTL (human) | Rationale |
|
||||
* |---|------------------------------------------------------------------|----------|-------------|--------------------------------------------------------------------|
|
||||
* | 1 | `closedFlag` is `true` | `null` | Do not cache| Closed records are rarely accessed; caching wastes memory. |
|
||||
* | 2 | `expectedCloseDate` OR `lastUpdated` is within the last **5 days**| 30 000 | 30 seconds | High-activity window — data changes frequently and must stay fresh.|
|
||||
* | 3 | `expectedCloseDate` OR `lastUpdated` is within the last **14 days**| 60 000 | 60 seconds | Moderate activity — still relevant, but changes less often. |
|
||||
* | 4 | Everything else (older than 14 days) | 900 000 | 15 minutes | Low activity — safe to serve from cache for longer. |
|
||||
*
|
||||
* ## Evaluation order
|
||||
*
|
||||
* Rules are evaluated **top-to-bottom**; the first matching rule wins.
|
||||
* Rule 2 (5-day window) is a subset of Rule 3 (14-day window), so it
|
||||
* must be checked first.
|
||||
*
|
||||
* ## Inputs
|
||||
*
|
||||
* | Field | Type | Description |
|
||||
* |--------------------|------------------|--------------------------------------------------------------------|
|
||||
* | `closedFlag` | `boolean` | Whether the record is closed / inactive. |
|
||||
* | `expectedCloseDate`| `Date \| null` | The projected close date (future-looking relevance signal). |
|
||||
* | `lastUpdated` | `Date \| null` | The last time the upstream record was modified (backward-looking). |
|
||||
* | `now` | `Date` (optional)| Override for the current timestamp; defaults to `new Date()`. |
|
||||
*
|
||||
* ## Output
|
||||
*
|
||||
* Returns `number | null`:
|
||||
* - A positive integer representing the TTL in **milliseconds**, or
|
||||
* - `null` when the record should **not** be cached at all.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* ```ts
|
||||
* import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
|
||||
*
|
||||
* const ttl = computeCacheTTL({
|
||||
* closedFlag: opportunity.closedFlag,
|
||||
* expectedCloseDate: opportunity.expectedCloseDate,
|
||||
* lastUpdated: opportunity.cwLastUpdated,
|
||||
* });
|
||||
*
|
||||
* if (ttl !== null) {
|
||||
* await redis.set(key, serialised, "PX", ttl);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 30 seconds – TTL for high-activity records (within 5 days). */
|
||||
export const TTL_HIGH_ACTIVITY = 30_000;
|
||||
|
||||
/** 60 seconds – TTL for moderate-activity records (within 14 days). */
|
||||
export const TTL_MODERATE_ACTIVITY = 60_000;
|
||||
|
||||
/** 15 minutes – TTL for low-activity / stale records. */
|
||||
export const TTL_LOW_ACTIVITY = 900_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;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CacheTTLInput {
|
||||
/** Whether the record is closed / inactive. */
|
||||
closedFlag: boolean;
|
||||
/** When the record was closed — used for recently-closed caching (within 30 days). */
|
||||
closedDate: Date | null;
|
||||
/** The projected close date — serves as a forward-looking relevance signal. */
|
||||
expectedCloseDate: Date | null;
|
||||
/** The date the upstream record was last modified — backward-looking signal. */
|
||||
lastUpdated: Date | null;
|
||||
/**
|
||||
* Override for the current timestamp.
|
||||
* Useful for deterministic testing. Defaults to `new Date()`.
|
||||
*/
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Algorithm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the cache TTL for a record based on its activity signals.
|
||||
*
|
||||
* @param input - The record's activity signals. See {@link CacheTTLInput}.
|
||||
* @returns The TTL in milliseconds, or `null` if the record should not be cached.
|
||||
*
|
||||
* @see Module-level JSDoc for the full spec table and evaluation rules.
|
||||
*/
|
||||
export function computeCacheTTL(input: CacheTTLInput): number | null {
|
||||
const {
|
||||
closedFlag,
|
||||
closedDate,
|
||||
expectedCloseDate,
|
||||
lastUpdated,
|
||||
now = new Date(),
|
||||
} = input;
|
||||
|
||||
const nowMs = now.getTime();
|
||||
|
||||
/**
|
||||
* Check whether a date falls within a window around `now`.
|
||||
*
|
||||
* "Within" means the date is between `now - windowMs` and `now + windowMs`,
|
||||
* allowing both past updates and future-scheduled dates to qualify.
|
||||
*/
|
||||
const isWithinWindow = (date: Date | null, windowMs: number): boolean => {
|
||||
if (!date) return false;
|
||||
const diff = Math.abs(nowMs - date.getTime());
|
||||
return diff <= windowMs;
|
||||
};
|
||||
|
||||
// Rule 1 — Closed records
|
||||
if (closedFlag) {
|
||||
// Rule 1b — Recently closed (within 30 days) → cache at low-activity TTL
|
||||
if (isWithinWindow(closedDate, THIRTY_DAYS_MS)) {
|
||||
return TTL_LOW_ACTIVITY;
|
||||
}
|
||||
// Rule 1a — Closed longer than 30 days → do not cache
|
||||
return null;
|
||||
}
|
||||
|
||||
// Rule 2 — High activity (5 days)
|
||||
if (
|
||||
isWithinWindow(expectedCloseDate, FIVE_DAYS_MS) ||
|
||||
isWithinWindow(lastUpdated, FIVE_DAYS_MS)
|
||||
) {
|
||||
return TTL_HIGH_ACTIVITY;
|
||||
}
|
||||
|
||||
// Rule 3 — Moderate activity (14 days)
|
||||
if (
|
||||
isWithinWindow(expectedCloseDate, FOURTEEN_DAYS_MS) ||
|
||||
isWithinWindow(lastUpdated, FOURTEEN_DAYS_MS)
|
||||
) {
|
||||
return TTL_MODERATE_ACTIVITY;
|
||||
}
|
||||
|
||||
// Rule 4 — Low activity / stale
|
||||
return TTL_LOW_ACTIVITY;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @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 800 000 | 30 minutes | Lazy on-demand cache: fetched when requested, expires after 30 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 15 seconds — TTL for hot products (opportunity updated within 3 days). */
|
||||
export const PRODUCTS_TTL_HOT = 15_000;
|
||||
|
||||
/** 30 minutes — TTL for on-demand product cache (lazy fallback). */
|
||||
export const PRODUCTS_TTL_LAZY = 1_800_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;
|
||||
}
|
||||
+754
@@ -0,0 +1,754 @@
|
||||
/**
|
||||
* @module opportunityCache
|
||||
*
|
||||
* Redis-backed cache for expensive ConnectWise API data associated
|
||||
* with opportunities.
|
||||
*
|
||||
* ## What is cached
|
||||
*
|
||||
* Each non-closed opportunity may have two cached payloads keyed by
|
||||
* its `cwOpportunityId`:
|
||||
*
|
||||
* - **Activities** (`opp:activities:{cwOpportunityId}`) — the raw
|
||||
* `CWActivity[]` array fetched from `activityCw.fetchByOpportunity()`.
|
||||
* - **Company CW data** (`opp:company-cw:{cw_CompanyId}`) — the hydrated
|
||||
* 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
|
||||
* opportunities refresh every 30 s while stale ones live for 15 min.
|
||||
*
|
||||
* ## Background refresh
|
||||
*
|
||||
* {@link refreshOpportunityCache} is designed to be called on a
|
||||
* 30-second interval from `src/index.ts`. It scans all non-closed
|
||||
* DB opportunities, checks which cache keys have expired, and
|
||||
* re-fetches only those from ConnectWise.
|
||||
*/
|
||||
|
||||
import { prisma, redis } from "../../constants";
|
||||
import { activityCw } from "../cw-utils/activities/activities";
|
||||
import { computeCacheTTL } from "../algorithms/computeCacheTTL";
|
||||
import { computeSubResourceCacheTTL } from "../algorithms/computeSubResourceCacheTTL";
|
||||
import {
|
||||
computeProductsCacheTTL,
|
||||
PRODUCTS_TTL_HOT,
|
||||
} from "../algorithms/computeProductsCacheTTL";
|
||||
import { connectWiseApi } from "../../constants";
|
||||
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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACTIVITY_PREFIX = "opp:activities:";
|
||||
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. */
|
||||
export const activityCacheKey = (cwOppId: number) =>
|
||||
`${ACTIVITY_PREFIX}${cwOppId}`;
|
||||
|
||||
/** Redis key for cached company CW hydration data by CW company ID. */
|
||||
export const companyCwCacheKey = (cwCompanyId: number) =>
|
||||
`${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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Retrieve cached CW activities for an opportunity.
|
||||
*
|
||||
* @returns The parsed `CWActivity[]` or `null` on cache miss.
|
||||
*/
|
||||
export async function getCachedActivities(
|
||||
cwOpportunityId: number,
|
||||
): Promise<any[] | null> {
|
||||
const raw = await redis.get(activityCacheKey(cwOpportunityId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve cached company CW hydration data.
|
||||
*
|
||||
* @returns `{ company, defaultContact, allContacts }` or `null` on cache miss.
|
||||
*/
|
||||
export async function getCachedCompanyCwData(
|
||||
cwCompanyId: number,
|
||||
): Promise<{ company: any; defaultContact: any; allContacts: any[] } | null> {
|
||||
const raw = await redis.get(companyCwCacheKey(cwCompanyId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export async function fetchAndCacheActivities(
|
||||
cwOpportunityId: number,
|
||||
ttlMs: number,
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
// Use the direct (single-call) variant to avoid the extra count request
|
||||
const arr = await activityCw.fetchByOpportunityDirect(cwOpportunityId);
|
||||
await redis.set(
|
||||
activityCacheKey(cwOpportunityId),
|
||||
JSON.stringify(arr),
|
||||
"PX",
|
||||
ttlMs,
|
||||
);
|
||||
return arr;
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return [];
|
||||
if (isTransientError(err)) {
|
||||
console.warn(
|
||||
`[cache] activities opp#${cwOpportunityId}: ${describeError(err)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch company CW data (company, contacts) and cache with the given TTL.
|
||||
*
|
||||
* @returns The hydration blob or `null` if the company doesn't exist in CW.
|
||||
*/
|
||||
export async function fetchAndCacheCompanyCwData(
|
||||
cwCompanyId: number,
|
||||
ttlMs: number,
|
||||
): Promise<{ company: any; defaultContact: any; allContacts: any[] } | null> {
|
||||
try {
|
||||
const cwCompany = await fetchCwCompanyById(cwCompanyId);
|
||||
if (!cwCompany) return null;
|
||||
|
||||
const contactHref = cwCompany.defaultContact?._info?.contact_href;
|
||||
const defaultContactData = contactHref
|
||||
? await withCwRetry(() => connectWiseApi.get(contactHref), {
|
||||
label: `company#${cwCompanyId}/defaultContact`,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const allContactsData = await withCwRetry(
|
||||
() =>
|
||||
connectWiseApi.get(`${cwCompany._info.contacts_href}&pageSize=1000`),
|
||||
{ label: `company#${cwCompanyId}/allContacts` },
|
||||
);
|
||||
|
||||
const blob = {
|
||||
company: cwCompany,
|
||||
defaultContact: defaultContactData?.data ?? null,
|
||||
allContacts: allContactsData.data,
|
||||
};
|
||||
|
||||
await redis.set(
|
||||
companyCwCacheKey(cwCompanyId),
|
||||
JSON.stringify(blob),
|
||||
"PX",
|
||||
ttlMs,
|
||||
);
|
||||
|
||||
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 — 30 minutes. Site/address data rarely changes so we cache
|
||||
* aggressively. The background refresh does NOT proactively warm site keys;
|
||||
* they are populated lazily on the first detail-view request.
|
||||
*/
|
||||
const SITE_TTL_MS = 1_800_000;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background refresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refresh the opportunity cache.
|
||||
*
|
||||
* Scans all non-closed opportunities in the database, computes a TTL for each,
|
||||
* checks whether the cache key still exists, and re-fetches from ConnectWise
|
||||
* only for entries that have expired.
|
||||
*
|
||||
* Designed to be called every **30 seconds** from the process entry point.
|
||||
*/
|
||||
export async function refreshOpportunityCache(): Promise<void> {
|
||||
// Include non-closed AND recently-closed (within 30 days) opportunities
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const opportunities = await prisma.opportunity.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ closedFlag: false },
|
||||
{ closedFlag: true, closedDate: { gte: thirtyDaysAgo } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
cwOpportunityId: true,
|
||||
closedFlag: true,
|
||||
closedDate: true,
|
||||
expectedCloseDate: true,
|
||||
cwLastUpdated: true,
|
||||
statusCwId: true,
|
||||
company: { select: { cw_CompanyId: true } },
|
||||
},
|
||||
orderBy: { cwLastUpdated: "desc" },
|
||||
});
|
||||
|
||||
events.emit("cache:opportunities:refresh:started", {
|
||||
totalOpportunities: opportunities.length,
|
||||
});
|
||||
|
||||
let activitiesRefreshed = 0;
|
||||
let companiesRefreshed = 0;
|
||||
let notesRefreshed = 0;
|
||||
let contactsRefreshed = 0;
|
||||
let productsRefreshed = 0;
|
||||
let oppCwDataRefreshed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
// Batch-check which keys already exist via a single pipeline
|
||||
// (5 EXISTS per opportunity: oppCwData, activities, notes, contacts, products).
|
||||
const pipeline = redis.pipeline();
|
||||
for (const opp of opportunities) {
|
||||
pipeline.exists(oppCwDataCacheKey(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 refreshTasks: (() => Promise<void>)[] = [];
|
||||
|
||||
for (let i = 0; i < opportunities.length; i++) {
|
||||
const opp = opportunities[i]!;
|
||||
|
||||
const cacheTTLInput = {
|
||||
closedFlag: opp.closedFlag,
|
||||
closedDate: opp.closedDate,
|
||||
expectedCloseDate: opp.expectedCloseDate,
|
||||
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,
|
||||
// but guard anyway.
|
||||
if (ttl === null) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// existsResults entries are [error, result] tuples
|
||||
// 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) {
|
||||
refreshTasks.push(() =>
|
||||
fetchAndCacheActivities(opp.cwOpportunityId, ttl).then(() => {
|
||||
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
|
||||
if (opp.company?.cw_CompanyId) {
|
||||
const cwCompanyId = opp.company.cw_CompanyId;
|
||||
refreshTasks.push(async () => {
|
||||
const companyExists = await redis.exists(
|
||||
companyCwCacheKey(cwCompanyId),
|
||||
);
|
||||
if (!companyExists) {
|
||||
await fetchAndCacheCompanyCwData(cwCompanyId, ttl);
|
||||
companiesRefreshed++;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run refresh thunks with bounded concurrency and inter-batch delay.
|
||||
// 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) {
|
||||
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", {
|
||||
totalOpportunities: opportunities.length,
|
||||
activitiesRefreshed,
|
||||
companiesRefreshed,
|
||||
notesRefreshed,
|
||||
contactsRefreshed,
|
||||
productsRefreshed,
|
||||
oppCwDataRefreshed,
|
||||
skipped,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Catalog Categories & Ecosystems
|
||||
*
|
||||
* This module defines the complete category/subcategory hierarchy and
|
||||
* ecosystem decision trees used for product filtering in the UI.
|
||||
*
|
||||
* --- Terminology ---
|
||||
*
|
||||
* Category: Top-level CW category (e.g. "Technology", "Field", "General").
|
||||
* A category is NEVER a subcategory.
|
||||
*
|
||||
* Subcategory: The CW subcategory name stored on each catalog item.
|
||||
* At the second level of the tree, if there are no children
|
||||
* beneath it then the node name IS the subcategory.
|
||||
* If children exist, the second-level node is an *umbrella*
|
||||
* that groups related subcategories — the children are the
|
||||
* actual subcategory names.
|
||||
*
|
||||
* Ecosystem: A cross-cutting product grouping defined by manufacturer +
|
||||
* category + subcategory-prefix rules. Ecosystems let the UI
|
||||
* present a "Networking" or "Video Surveillance" view that
|
||||
* spans manufacturers regardless of where CW filed them.
|
||||
*
|
||||
* --- Data shapes ---
|
||||
*
|
||||
* SubcategoryNode – a leaf: `{ name, cwId? }`
|
||||
* CategoryGroup – an umbrella with children: `{ name, children[] }`
|
||||
* CategoryEntry – either a leaf OR a group at the 2nd level
|
||||
* TopLevelCategory – `{ name, cwId?, entries[] }`
|
||||
*
|
||||
* The `CATEGORY_TREE` export is the single source of truth; helpers derive
|
||||
* flat lists, lookup maps, and search predicates from it.
|
||||
*/
|
||||
|
||||
// ─── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SubcategoryNode {
|
||||
/** The exact CW subcategory name */
|
||||
name: string;
|
||||
/** CW subcategory id (optional, for reference) */
|
||||
cwId?: number;
|
||||
}
|
||||
|
||||
export interface CategoryGroup {
|
||||
/** Display name of the umbrella (e.g. "Network", "Cables", "AlarmBurg") */
|
||||
name: string;
|
||||
/** The subcategories that belong to this umbrella */
|
||||
children: SubcategoryNode[];
|
||||
}
|
||||
|
||||
/** A second-level entry is either a direct subcategory or an umbrella group */
|
||||
export type CategoryEntry = SubcategoryNode | CategoryGroup;
|
||||
|
||||
export interface TopLevelCategory {
|
||||
/** The CW category name */
|
||||
name: string;
|
||||
/** CW category id (optional, for reference) */
|
||||
cwId?: number;
|
||||
/** Second-level entries under this category */
|
||||
entries: CategoryEntry[];
|
||||
}
|
||||
|
||||
/** Helper type guard */
|
||||
export function isCategoryGroup(entry: CategoryEntry): entry is CategoryGroup {
|
||||
return "children" in entry;
|
||||
}
|
||||
|
||||
// ─── Ecosystem types ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface EcosystemManufacturer {
|
||||
/** Manufacturer name as stored in CW */
|
||||
name: string;
|
||||
/** CW manufacturer id */
|
||||
cwId?: number;
|
||||
/** Which CW category these products fall under */
|
||||
category: string;
|
||||
/** Subcategory prefix — matches any subcategory starting with this string */
|
||||
subcategoryPrefix: string;
|
||||
}
|
||||
|
||||
export interface Ecosystem {
|
||||
/** Display name (e.g. "Networking", "Video Surveillance") */
|
||||
name: string;
|
||||
/** Manufacturers that belong to this ecosystem */
|
||||
manufacturers: EcosystemManufacturer[];
|
||||
}
|
||||
|
||||
// ─── Category Tree ───────────────────────────────────────────────────────────
|
||||
|
||||
export const CATEGORY_TREE: TopLevelCategory[] = [
|
||||
{
|
||||
name: "Technology",
|
||||
cwId: 18,
|
||||
entries: [
|
||||
{ name: "GeneralEquip", cwId: 57 },
|
||||
{ name: "Home Entertainment", cwId: 114 },
|
||||
{ name: "Monitor", cwId: 115 },
|
||||
{ name: "Printers", cwId: 120 },
|
||||
{ name: "Storage", cwId: 108 },
|
||||
{
|
||||
name: "Network",
|
||||
children: [
|
||||
{ name: "Network-Other", cwId: 174 },
|
||||
{ name: "Network-Router", cwId: 119 },
|
||||
{ name: "Network-Switch", cwId: 112 },
|
||||
{ name: "Network-Wireless", cwId: 111 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Computer",
|
||||
children: [
|
||||
{ name: "Computer-Components", cwId: 109 },
|
||||
{ name: "Computer-Desktop", cwId: 106 },
|
||||
{ name: "Computer-Laptop", cwId: 107 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Recurring",
|
||||
children: [
|
||||
{ name: "Recurring - Online", cwId: 83 },
|
||||
{ name: "Recurring - Other", cwId: 84 },
|
||||
{ name: "Recurring - Protection", cwId: 81 },
|
||||
{ name: "Recurring - Telephone", cwId: 133 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Telephone",
|
||||
children: [
|
||||
{ name: "Tele-HSet-Digital", cwId: 116 },
|
||||
{ name: "Tele-HSet-IP", cwId: 206 },
|
||||
{ name: "Tele-HSet-SLT" },
|
||||
{ name: "Tele-Misc", cwId: 75 },
|
||||
{ name: "Tele-Paging", cwId: 76 },
|
||||
{ name: "Tele-SystemCards", cwId: 135 },
|
||||
{ name: "Tele-Systems", cwId: 78 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "General",
|
||||
cwId: 25,
|
||||
entries: [
|
||||
{ name: "Batteries", cwId: 80 },
|
||||
{ name: "Battery Backups", cwId: 144 },
|
||||
{ name: "BulkWire", cwId: 200 },
|
||||
{
|
||||
name: "Cables",
|
||||
children: [
|
||||
{ name: "Cables-Adapters", cwId: 182 },
|
||||
{ name: "Cables-HDMI", cwId: 176 },
|
||||
{ name: "Cables-Network", cwId: 87 },
|
||||
{ name: "Cables-Other", cwId: 177 },
|
||||
{ name: "Cables-USB", cwId: 178 },
|
||||
{ name: "Cables-VGA", cwId: 179 },
|
||||
],
|
||||
},
|
||||
{ name: "Elec Cords & Adapters", cwId: 142 },
|
||||
{ name: "Enclosures", cwId: 141 },
|
||||
{ name: "PowerSupply", cwId: 167 },
|
||||
{
|
||||
name: "RackEquip",
|
||||
children: [
|
||||
{ name: "RackEquip-Rack", cwId: 143 },
|
||||
{ name: "RackEquip-Shelves", cwId: 190 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Field",
|
||||
cwId: 28,
|
||||
entries: [
|
||||
{ name: "Conduit" },
|
||||
{ name: "Electric", cwId: 199 },
|
||||
{ name: "GateControl", cwId: 45 },
|
||||
{ name: "Locksets" },
|
||||
{ name: "Other", cwId: 46 },
|
||||
{ name: "Relays", cwId: 168 },
|
||||
{
|
||||
name: "AccessControl",
|
||||
children: [
|
||||
{ name: "AccessControl-Controllers", cwId: 137 },
|
||||
{ name: "AccessControl-Credential", cwId: 183 },
|
||||
{ name: "AccessControl-LockDevices", cwId: 138 },
|
||||
{ name: "AccessControl-Other", cwId: 44 },
|
||||
{ name: "AccessControl-Readers", cwId: 136 },
|
||||
{ name: "AccessControl-VideoEntry", cwId: 139 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "AlarmBurg",
|
||||
children: [
|
||||
{ name: "AlarmBurg-Communicators", cwId: 96 },
|
||||
{ name: "AlarmBurg-Keypads", cwId: 93 },
|
||||
{ name: "AlarmBurg-Modules", cwId: 140 },
|
||||
{ name: "AlarmBurg-Other", cwId: 92 },
|
||||
{ name: "AlarmBurg-Panels", cwId: 42 },
|
||||
{ name: "AlarmBurg-Sensors-Wireless", cwId: 147 },
|
||||
{ name: "AlarmBurg-Sensors-Wired", cwId: 146 },
|
||||
{ name: "AlarmBurg-Siren", cwId: 145 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "AlarmFire",
|
||||
children: [
|
||||
{ name: "AlarmFire-Communicators", cwId: 97 },
|
||||
{ name: "AlarmFire-Devices", cwId: 169 },
|
||||
{ name: "AlarmFire-Modules", cwId: 170 },
|
||||
{ name: "AlarmFire-Other", cwId: 98 },
|
||||
{ name: "AlarmFire-Panels", cwId: 95 },
|
||||
{ name: "AlarmFire-Sensors", cwId: 94 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Automation",
|
||||
children: [
|
||||
{ name: "Automation-General", cwId: 99 },
|
||||
{ name: "Automation-HVAC", cwId: 181 },
|
||||
{ name: "Automation-Lights", cwId: 180 },
|
||||
{ name: "Automation-Locks", cwId: 192 },
|
||||
{ name: "Automation-Thermostat" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "AV",
|
||||
children: [
|
||||
{ name: "AV-Adapters&Cables", cwId: 171 },
|
||||
{ name: "AV-Components", cwId: 172 },
|
||||
{ name: "AV-Mounts", cwId: 191 },
|
||||
{ name: "AV-Other", cwId: 184 },
|
||||
{ name: "AV-Speakers", cwId: 173 },
|
||||
{ name: "AV-Television", cwId: 175 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "StrCbl",
|
||||
children: [
|
||||
{ name: "StrCbl-Jacks", cwId: 186 },
|
||||
{ name: "StrCbl-PatchPanel", cwId: 187 },
|
||||
{ name: "StrCbl-Plates", cwId: 185 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Surveillance",
|
||||
children: [
|
||||
{ name: "Surveillance-Accs", cwId: 90 },
|
||||
{ name: "Surveillance-CamerasAnalog", cwId: 89 },
|
||||
{ name: "Surveillance-CamerasIP", cwId: 88 },
|
||||
{ name: "Surveillance-NVR", cwId: 43 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Ecosystem Tree ──────────────────────────────────────────────────────────
|
||||
|
||||
export const ECOSYSTEM_TREE: Ecosystem[] = [
|
||||
{
|
||||
name: "Networking",
|
||||
manufacturers: [
|
||||
{
|
||||
name: "Ubiquiti",
|
||||
cwId: 248,
|
||||
category: "Technology",
|
||||
subcategoryPrefix: "Network-",
|
||||
},
|
||||
{
|
||||
name: "TP-Link",
|
||||
cwId: 259,
|
||||
category: "Technology",
|
||||
subcategoryPrefix: "Network-",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Video Surveillance",
|
||||
manufacturers: [
|
||||
{
|
||||
name: "Uniview",
|
||||
cwId: 239,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "Surveillance-",
|
||||
},
|
||||
{
|
||||
name: "Hikvision",
|
||||
cwId: 299,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "Surveillance-",
|
||||
},
|
||||
{
|
||||
name: "Alarm.com",
|
||||
cwId: 294,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "Surveillance-",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Burg/Alarm",
|
||||
manufacturers: [
|
||||
{
|
||||
name: "Qolsys",
|
||||
cwId: 376,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "AlarmBurg-",
|
||||
},
|
||||
{
|
||||
name: "DSC",
|
||||
cwId: 287,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "AlarmBurg-",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Derived helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a flat list of all subcategory names under a given category.
|
||||
*/
|
||||
export function getSubcategoriesForCategory(categoryName: string): string[] {
|
||||
const category = CATEGORY_TREE.find((c) => c.name === categoryName);
|
||||
if (!category) return [];
|
||||
|
||||
const subcats: string[] = [];
|
||||
for (const entry of category.entries) {
|
||||
if (isCategoryGroup(entry)) {
|
||||
for (const child of entry.children) {
|
||||
subcats.push(child.name);
|
||||
}
|
||||
} else {
|
||||
subcats.push(entry.name);
|
||||
}
|
||||
}
|
||||
return subcats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all subcategory names under a given umbrella group within a category.
|
||||
* e.g. getSubcategoriesForGroup("Field", "AlarmBurg") → ["AlarmBurg-Communicators", ...]
|
||||
*/
|
||||
export function getSubcategoriesForGroup(
|
||||
categoryName: string,
|
||||
groupName: string,
|
||||
): string[] {
|
||||
const category = CATEGORY_TREE.find((c) => c.name === categoryName);
|
||||
if (!category) return [];
|
||||
|
||||
const group = category.entries.find(
|
||||
(e) => isCategoryGroup(e) && e.name === groupName,
|
||||
);
|
||||
if (!group || !isCategoryGroup(group)) return [];
|
||||
|
||||
return group.children.map((c) => c.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all top-level category names.
|
||||
*/
|
||||
export function getCategoryNames(): string[] {
|
||||
return CATEGORY_TREE.map((c) => c.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the umbrella group name for a given subcategory, or null if it's a
|
||||
* direct entry (not under an umbrella).
|
||||
*/
|
||||
export function getGroupForSubcategory(
|
||||
subcategoryName: string,
|
||||
): { category: string; group: string } | null {
|
||||
for (const cat of CATEGORY_TREE) {
|
||||
for (const entry of cat.entries) {
|
||||
if (isCategoryGroup(entry)) {
|
||||
if (entry.children.some((c) => c.name === subcategoryName)) {
|
||||
return { category: cat.name, group: entry.name };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full tree serialized for the API / UI consumption.
|
||||
* Each top-level category includes its entries, with umbrella groups
|
||||
* expanded to show children.
|
||||
*/
|
||||
export function serializeCategoryTree() {
|
||||
return CATEGORY_TREE.map((cat) => ({
|
||||
name: cat.name,
|
||||
cwId: cat.cwId ?? null,
|
||||
entries: cat.entries.map((entry) => {
|
||||
if (isCategoryGroup(entry)) {
|
||||
return {
|
||||
type: "group" as const,
|
||||
name: entry.name,
|
||||
subcategories: entry.children.map((c) => ({
|
||||
name: c.name,
|
||||
cwId: c.cwId ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "subcategory" as const,
|
||||
name: entry.name,
|
||||
cwId: (entry as SubcategoryNode).cwId ?? null,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ecosystem tree serialized for the API / UI consumption.
|
||||
*/
|
||||
export function serializeEcosystemTree() {
|
||||
return ECOSYSTEM_TREE.map((eco) => ({
|
||||
name: eco.name,
|
||||
manufacturers: eco.manufacturers.map((m) => ({
|
||||
name: m.name,
|
||||
cwId: m.cwId ?? null,
|
||||
category: m.category,
|
||||
subcategoryPrefix: m.subcategoryPrefix,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a flat list of every known subcategory name across all categories.
|
||||
*/
|
||||
export function getAllSubcategoryNames(): string[] {
|
||||
const names: string[] = [];
|
||||
for (const cat of CATEGORY_TREE) {
|
||||
for (const entry of cat.entries) {
|
||||
if (isCategoryGroup(entry)) {
|
||||
for (const child of entry.children) {
|
||||
names.push(child.name);
|
||||
}
|
||||
} else {
|
||||
names.push(entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a CW subcategory name, resolves which top-level category it belongs to.
|
||||
*/
|
||||
export function getCategoryForSubcategory(
|
||||
subcategoryName: string,
|
||||
): string | null {
|
||||
for (const cat of CATEGORY_TREE) {
|
||||
for (const entry of cat.entries) {
|
||||
if (isCategoryGroup(entry)) {
|
||||
if (entry.children.some((c) => c.name === subcategoryName)) {
|
||||
return cat.name;
|
||||
}
|
||||
} else if (entry.name === subcategoryName) {
|
||||
return cat.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a CW manufacturer name, returns which ecosystems it belongs to.
|
||||
*/
|
||||
export function getEcosystemsForManufacturer(
|
||||
manufacturerName: string,
|
||||
): string[] {
|
||||
return ECOSYSTEM_TREE.filter((eco) =>
|
||||
eco.manufacturers.some(
|
||||
(m) => m.name.toLowerCase() === manufacturerName.toLowerCase(),
|
||||
),
|
||||
).map((eco) => eco.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a catalog item (by manufacturer + subcategory) matches a given ecosystem.
|
||||
*/
|
||||
export function matchesEcosystem(
|
||||
ecosystemName: string,
|
||||
manufacturer: string | null,
|
||||
subcategory: string | null,
|
||||
): boolean {
|
||||
const eco = ECOSYSTEM_TREE.find((e) => e.name === ecosystemName);
|
||||
if (!eco) return false;
|
||||
|
||||
return eco.manufacturers.some(
|
||||
(m) =>
|
||||
m.name.toLowerCase() === (manufacturer ?? "").toLowerCase() &&
|
||||
(subcategory ?? "").startsWith(m.subcategoryPrefix),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { connectWiseApi } from "../../../constants";
|
||||
import {
|
||||
CWActivity,
|
||||
CWActivitySummary,
|
||||
CWCreateActivity,
|
||||
CWPatchOperation,
|
||||
} from "./activity.types";
|
||||
|
||||
export const activityCw = {
|
||||
/**
|
||||
* Count Activities
|
||||
*
|
||||
* Returns the total number of activities in ConnectWise.
|
||||
* Optionally accepts CW conditions string for filtered counts.
|
||||
*/
|
||||
countItems: async (conditions?: string): Promise<number> => {
|
||||
const query = conditions
|
||||
? `/sales/activities/count?conditions=${encodeURIComponent(conditions)}`
|
||||
: "/sales/activities/count";
|
||||
const response = await connectWiseApi.get(query);
|
||||
return response.data.count;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Activity Summaries
|
||||
*
|
||||
* Lightweight fetch returning only id and _info (for lastUpdated comparison).
|
||||
* Paginates through all activities.
|
||||
*/
|
||||
fetchAllSummaries: async (): Promise<
|
||||
Collection<number, CWActivitySummary>
|
||||
> => {
|
||||
const allItems = new Collection<number, CWActivitySummary>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const count = await activityCw.countItems();
|
||||
const totalPages = Math.ceil(count / pageSize);
|
||||
|
||||
for (let page = 0; page < totalPages; page++) {
|
||||
const response = await connectWiseApi.get(
|
||||
`/sales/activities?page=${page + 1}&pageSize=${pageSize}&fields=id,_info`,
|
||||
);
|
||||
const items: CWActivitySummary[] = response.data;
|
||||
|
||||
for (const item of items) {
|
||||
allItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Activities (Full)
|
||||
*
|
||||
* Fetches all activities with complete data. Paginates through
|
||||
* the full list. Optionally accepts CW conditions string for filtering.
|
||||
*/
|
||||
fetchAll: async (
|
||||
conditions?: string,
|
||||
): Promise<Collection<number, CWActivity>> => {
|
||||
const allItems = new Collection<number, CWActivity>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const count = await activityCw.countItems(conditions);
|
||||
const totalPages = Math.ceil(count / pageSize);
|
||||
|
||||
for (let page = 0; page < totalPages; page++) {
|
||||
const conditionsParam = conditions
|
||||
? `&conditions=${encodeURIComponent(conditions)}`
|
||||
: "";
|
||||
const response = await connectWiseApi.get(
|
||||
`/sales/activities?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
|
||||
);
|
||||
const items: CWActivity[] = response.data;
|
||||
|
||||
for (const item of items) {
|
||||
allItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Single Activity
|
||||
*
|
||||
* Fetches a single activity by its ConnectWise ID.
|
||||
*/
|
||||
fetch: async (id: number): Promise<CWActivity> => {
|
||||
const response = await connectWiseApi.get(`/sales/activities/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Activities by Company
|
||||
*
|
||||
* Fetches all activities associated with a specific ConnectWise company ID.
|
||||
*/
|
||||
fetchByCompany: async (
|
||||
cwCompanyId: number,
|
||||
): Promise<Collection<number, CWActivity>> => {
|
||||
return activityCw.fetchAll(`company/id=${cwCompanyId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Activities by Opportunity
|
||||
*
|
||||
* Fetches all activities associated with a specific opportunity ID.
|
||||
*/
|
||||
fetchByOpportunity: async (
|
||||
opportunityId: number,
|
||||
): Promise<Collection<number, CWActivity>> => {
|
||||
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
|
||||
*
|
||||
* Creates a new activity in ConnectWise.
|
||||
*/
|
||||
create: async (activity: CWCreateActivity): Promise<CWActivity> => {
|
||||
const response = await connectWiseApi.post("/sales/activities", activity);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update Activity (PATCH)
|
||||
*
|
||||
* Updates an existing activity using JSON Patch operations.
|
||||
*/
|
||||
update: async (
|
||||
id: number,
|
||||
operations: CWPatchOperation[],
|
||||
): Promise<CWActivity> => {
|
||||
const response = await connectWiseApi.patch(
|
||||
`/sales/activities/${id}`,
|
||||
operations,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace Activity (PUT)
|
||||
*
|
||||
* Replaces an entire activity record in ConnectWise.
|
||||
*/
|
||||
replace: async (
|
||||
id: number,
|
||||
activity: CWCreateActivity,
|
||||
): Promise<CWActivity> => {
|
||||
const response = await connectWiseApi.put(
|
||||
`/sales/activities/${id}`,
|
||||
activity,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Activity
|
||||
*
|
||||
* Deletes an activity by its ConnectWise ID.
|
||||
*/
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await connectWiseApi.delete(`/sales/activities/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
interface CWReference {
|
||||
id: number;
|
||||
name: string;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CWMemberReference {
|
||||
id: number;
|
||||
identifier: string;
|
||||
name: string;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CWCompanyReference {
|
||||
id: number;
|
||||
identifier: string;
|
||||
name: string;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CWContactReference {
|
||||
id: number;
|
||||
name: string;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWActivity {
|
||||
id: number;
|
||||
name: string;
|
||||
type: CWReference;
|
||||
company: CWCompanyReference;
|
||||
contact: CWContactReference;
|
||||
phoneNumber: string;
|
||||
email: string;
|
||||
status: CWReference;
|
||||
opportunity: CWReference;
|
||||
ticket: CWReference;
|
||||
agreement: CWReference;
|
||||
campaign: CWReference;
|
||||
notes: string;
|
||||
dateStart: string;
|
||||
dateEnd: string;
|
||||
assignTo: CWMemberReference;
|
||||
scheduleStatus: CWReference;
|
||||
reminder: CWReference;
|
||||
where: CWReference;
|
||||
notifyFlag: boolean;
|
||||
mobileGuid: string;
|
||||
currency: CWReference;
|
||||
customFields: CWActivityCustomField[];
|
||||
_info: CWActivityInfo;
|
||||
}
|
||||
|
||||
export interface CWActivityCustomField {
|
||||
id: number;
|
||||
caption: string;
|
||||
type: string;
|
||||
entryMethod: string;
|
||||
numberOfDecimals: number;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface CWActivityInfo {
|
||||
lastUpdated: string;
|
||||
updatedBy: string;
|
||||
dateEntered: string;
|
||||
enteredBy: string;
|
||||
}
|
||||
|
||||
export interface CWActivitySummary {
|
||||
id: number;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWCreateActivity {
|
||||
name: string;
|
||||
type?: { id: number };
|
||||
company?: { id: number };
|
||||
contact?: { id: number };
|
||||
phoneNumber?: string;
|
||||
email?: string;
|
||||
status?: { id: number };
|
||||
opportunity?: { id: number };
|
||||
ticket?: { id: number };
|
||||
agreement?: { id: number };
|
||||
campaign?: { id: number };
|
||||
notes?: string;
|
||||
dateStart?: string;
|
||||
dateEnd?: string;
|
||||
assignTo?: { id: number };
|
||||
scheduleStatus?: { id: number };
|
||||
reminder?: { id: number };
|
||||
where?: { id: number };
|
||||
notifyFlag?: boolean;
|
||||
}
|
||||
|
||||
export interface CWUpdateActivity {
|
||||
name?: string;
|
||||
type?: { id: number };
|
||||
company?: { id: number };
|
||||
contact?: { id: number };
|
||||
phoneNumber?: string;
|
||||
email?: string;
|
||||
status?: { id: number };
|
||||
opportunity?: { id: number };
|
||||
ticket?: { id: number };
|
||||
agreement?: { id: number };
|
||||
campaign?: { id: number };
|
||||
notes?: string;
|
||||
dateStart?: string;
|
||||
dateEnd?: string;
|
||||
assignTo?: { id: number };
|
||||
scheduleStatus?: { id: number };
|
||||
reminder?: { id: number };
|
||||
where?: { id: number };
|
||||
notifyFlag?: boolean;
|
||||
}
|
||||
|
||||
export interface CWPatchOperation {
|
||||
op: "replace" | "add" | "remove";
|
||||
path: string;
|
||||
value: unknown;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { activityCw } from "./activities";
|
||||
import { CWActivity, CWCreateActivity } from "./activity.types";
|
||||
|
||||
/**
|
||||
* Create a new activity in ConnectWise.
|
||||
*
|
||||
* @param activity - The activity data to create
|
||||
* @returns The newly created CW activity object
|
||||
* @throws GenericError if the creation fails
|
||||
*/
|
||||
export const createActivity = async (
|
||||
activity: CWCreateActivity,
|
||||
): Promise<CWActivity> => {
|
||||
try {
|
||||
return await activityCw.create(activity);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error("Error creating activity:", errBody);
|
||||
throw new GenericError({
|
||||
name: "CreateActivityError",
|
||||
message: "Failed to create activity in ConnectWise",
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { activityCw } from "./activities";
|
||||
import { CWActivity } from "./activity.types";
|
||||
|
||||
/**
|
||||
* Fetch a single activity by its ConnectWise ID.
|
||||
*
|
||||
* @param cwActivityId - The ConnectWise activity ID
|
||||
* @returns The full CW activity object
|
||||
* @throws GenericError if the fetch fails
|
||||
*/
|
||||
export const fetchActivity = async (
|
||||
cwActivityId: number,
|
||||
): Promise<CWActivity> => {
|
||||
try {
|
||||
return await activityCw.fetch(cwActivityId);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error(`Error fetching activity with ID ${cwActivityId}:`, errBody);
|
||||
throw new GenericError({
|
||||
name: "FetchActivityError",
|
||||
message: `Failed to fetch activity ${cwActivityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { activityCw } from "./activities";
|
||||
import { CWActivity } from "./activity.types";
|
||||
|
||||
/**
|
||||
* Fetch all activities from ConnectWise with optional conditions.
|
||||
*
|
||||
* @param conditions - Optional CW conditions string for filtering
|
||||
* @returns A Collection of CW activities keyed by their ID
|
||||
* @throws GenericError if the fetch fails
|
||||
*/
|
||||
export const fetchAllActivities = async (
|
||||
conditions?: string,
|
||||
): Promise<Collection<number, CWActivity>> => {
|
||||
try {
|
||||
return await activityCw.fetchAll(conditions);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error("Error fetching all activities:", errBody);
|
||||
throw new GenericError({
|
||||
name: "FetchAllActivitiesError",
|
||||
message: "Failed to fetch activities from ConnectWise",
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export { activityCw } from "./activities";
|
||||
export { fetchActivity } from "./fetchActivity";
|
||||
export { fetchAllActivities } from "./fetchAllActivities";
|
||||
export { createActivity } from "./createActivity";
|
||||
export { updateActivity } from "./updateActivity";
|
||||
|
||||
export type {
|
||||
CWActivity,
|
||||
CWActivitySummary,
|
||||
CWActivityCustomField,
|
||||
CWActivityInfo,
|
||||
CWCreateActivity,
|
||||
CWUpdateActivity,
|
||||
CWPatchOperation,
|
||||
} from "./activity.types";
|
||||
@@ -0,0 +1,29 @@
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { activityCw } from "./activities";
|
||||
import { CWActivity, CWPatchOperation } from "./activity.types";
|
||||
|
||||
/**
|
||||
* Update an existing activity in ConnectWise using JSON Patch operations.
|
||||
*
|
||||
* @param cwActivityId - The ConnectWise activity ID to update
|
||||
* @param operations - Array of JSON Patch operations to apply
|
||||
* @returns The updated CW activity object
|
||||
* @throws GenericError if the update fails
|
||||
*/
|
||||
export const updateActivity = async (
|
||||
cwActivityId: number,
|
||||
operations: CWPatchOperation[],
|
||||
): Promise<CWActivity> => {
|
||||
try {
|
||||
return await activityCw.update(cwActivityId, operations);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error(`Error updating activity with ID ${cwActivityId}:`, errBody);
|
||||
throw new GenericError({
|
||||
name: "UpdateActivityError",
|
||||
message: `Failed to update activity ${cwActivityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import { connectWiseApi } from "../../constants";
|
||||
import { Company } from "../../types/ConnectWiseTypes";
|
||||
import { withCwRetry } from "./withCwRetry";
|
||||
|
||||
export const fetchCwCompanyById = async (
|
||||
companyId: number,
|
||||
): Promise<Company | null> => {
|
||||
try {
|
||||
const response = await connectWiseApi.get(
|
||||
`/company/companies/${companyId}`,
|
||||
const response = await withCwRetry(
|
||||
() => connectWiseApi.get(`/company/companies/${companyId}`),
|
||||
{
|
||||
label: `fetchCompany#${companyId}`,
|
||||
maxAttempts: 3,
|
||||
baseDelayMs: 1_500,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { connectWiseApi } from "../../../constants";
|
||||
|
||||
export interface CWMember {
|
||||
id: number;
|
||||
identifier: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
officeEmail: string;
|
||||
inactiveFlag: boolean;
|
||||
_info: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch All CW Members
|
||||
*
|
||||
* Fetches every member from ConnectWise using pagination and returns them
|
||||
* in a Collection keyed by their identifier (e.g. "jroberts").
|
||||
*
|
||||
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
|
||||
*/
|
||||
export const fetchAllCwMembers = async (): Promise<
|
||||
Collection<string, CWMember>
|
||||
> => {
|
||||
const members = new Collection<string, CWMember>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const { data: countData } = await connectWiseApi.get("/system/members/count");
|
||||
const totalPages = Math.ceil(countData.count / pageSize);
|
||||
|
||||
for (let page = 0; page < totalPages; page++) {
|
||||
const { data } = await connectWiseApi.get<CWMember[]>(
|
||||
`/system/members?page=${page + 1}&pageSize=${pageSize}`,
|
||||
);
|
||||
|
||||
for (const member of data) {
|
||||
members.set(member.identifier, member);
|
||||
}
|
||||
}
|
||||
|
||||
return members;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find CW Member Identifier by Email
|
||||
*
|
||||
* Looks up a ConnectWise member whose `officeEmail` matches the provided
|
||||
* email address (case-insensitive) and returns their `identifier` string
|
||||
* (e.g. "jroberts"). Returns `null` if no match is found.
|
||||
*
|
||||
* @param email - The email address to search for
|
||||
* @param members - Optional pre-fetched member collection to search against (avoids extra API call)
|
||||
* @returns {Promise<string | null>} The CW identifier or null
|
||||
*/
|
||||
export const findCwIdentifierByEmail = async (
|
||||
email: string,
|
||||
members?: Collection<string, CWMember>,
|
||||
): Promise<string | null> => {
|
||||
const allMembers = members ?? (await fetchAllCwMembers());
|
||||
const normalised = email.toLowerCase();
|
||||
|
||||
const match = allMembers.find(
|
||||
(m) => m.officeEmail?.toLowerCase() === normalised,
|
||||
);
|
||||
|
||||
return match?.identifier ?? null;
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { prisma } from "../../../constants";
|
||||
import { redis } from "../../../constants";
|
||||
import { CWMember } from "./fetchAllMembers";
|
||||
|
||||
const REDIS_KEY = "cw:members";
|
||||
|
||||
export interface ResolvedMember {
|
||||
/** Local database user ID (null if no matching local user) */
|
||||
id: string | null;
|
||||
/** CW member identifier (e.g. "jroberts") */
|
||||
identifier: string;
|
||||
/** Full name resolved from CW member cache, or raw identifier as fallback */
|
||||
name: string;
|
||||
/** ConnectWise member ID */
|
||||
cwMemberId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW Member Cache
|
||||
*
|
||||
* Dual-layer cache (in-memory + Redis) of ConnectWise members keyed by
|
||||
* their identifier (e.g. "jroberts"). Populated by `refreshCwIdentifiers`
|
||||
* on startup and every 30 minutes thereafter.
|
||||
*/
|
||||
let memberCache = new Collection<string, CWMember>();
|
||||
|
||||
/**
|
||||
* Set the member cache contents.
|
||||
*
|
||||
* Replaces both the in-memory Collection and the Redis snapshot.
|
||||
*
|
||||
* @param members - Collection of CW members keyed by identifier
|
||||
*/
|
||||
export const setMemberCache = async (members: Collection<string, CWMember>) => {
|
||||
memberCache = members;
|
||||
await redis.set(REDIS_KEY, JSON.stringify([...members.values()]));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current member cache.
|
||||
*
|
||||
* Returns the in-memory Collection. If empty, attempts to hydrate from Redis
|
||||
* first. Returns whatever is available (may be empty if Redis is also cold).
|
||||
*/
|
||||
export const getMemberCache = async (): Promise<
|
||||
Collection<string, CWMember>
|
||||
> => {
|
||||
if (memberCache.size > 0) return memberCache;
|
||||
|
||||
const stored = await redis.get(REDIS_KEY);
|
||||
if (stored) {
|
||||
const parsed: CWMember[] = JSON.parse(stored);
|
||||
memberCache = new Collection(parsed.map((m) => [m.identifier, m]));
|
||||
}
|
||||
|
||||
return memberCache;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve CW Identifier to Full Name
|
||||
*
|
||||
* Looks up a ConnectWise member by their identifier in the in-memory cache
|
||||
* and returns their full name. Falls back to the raw identifier if not found.
|
||||
*
|
||||
* @param identifier - The CW member identifier (e.g. "jroberts")
|
||||
* @returns The member's full name (e.g. "John Roberts") or the raw identifier
|
||||
*/
|
||||
export const resolveMemberName = (identifier: string): string => {
|
||||
const member = memberCache.get(identifier);
|
||||
if (!member) return identifier;
|
||||
return `${member.firstName} ${member.lastName}`.trim() || identifier;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve CW Identifier to Full Member Info
|
||||
*
|
||||
* Looks up a ConnectWise member by their identifier in the in-memory cache
|
||||
* and cross-references with the local database to return a complete member
|
||||
* reference including local user ID, CW identifier, full name, and CW member ID.
|
||||
*
|
||||
* @param identifier - The CW member identifier (e.g. "jroberts")
|
||||
* @returns {Promise<ResolvedMember>} Resolved member info
|
||||
*/
|
||||
export const resolveMember = async (
|
||||
identifier: string,
|
||||
): Promise<ResolvedMember> => {
|
||||
const cwMember = memberCache.get(identifier);
|
||||
const name = cwMember
|
||||
? `${cwMember.firstName} ${cwMember.lastName}`.trim() || identifier
|
||||
: identifier;
|
||||
|
||||
const localUser = await prisma.user.findFirst({
|
||||
where: { cwIdentifier: identifier },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return {
|
||||
id: localUser?.id ?? null,
|
||||
identifier,
|
||||
name,
|
||||
cwMemberId: cwMember?.id ?? null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { connectWiseApi, prisma } from "../../../constants";
|
||||
import { events } from "../../globalEvents";
|
||||
import { fetchAllCwMembers, findCwIdentifierByEmail } from "./fetchAllMembers";
|
||||
import { setMemberCache } from "./memberCache";
|
||||
|
||||
/**
|
||||
* Refresh CW Identifiers
|
||||
*
|
||||
* Fetches all CW members and all users from the database, then updates
|
||||
* each user's `cwIdentifier` field by matching their email to a CW member's
|
||||
* `officeEmail`. Only users whose identifier has changed (or was previously
|
||||
* null) are updated to avoid unnecessary writes.
|
||||
*
|
||||
* Also refreshes the in-memory member cache used for name resolution.
|
||||
*/
|
||||
export const refreshCwIdentifiers = async () => {
|
||||
events.emit("cw:members:refresh:started");
|
||||
|
||||
const allMembers = await fetchAllCwMembers();
|
||||
await setMemberCache(allMembers);
|
||||
const allUsers = await prisma.user.findMany({
|
||||
select: { id: true, email: true, cwIdentifier: true },
|
||||
});
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
await Promise.all(
|
||||
allUsers.map(async (user) => {
|
||||
const identifier = await findCwIdentifierByEmail(user.email, allMembers);
|
||||
|
||||
if (identifier !== user.cwIdentifier) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { cwIdentifier: identifier },
|
||||
});
|
||||
updatedCount++;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
events.emit("cw:members:refresh:completed", {
|
||||
totalMembers: allMembers.size,
|
||||
totalUsers: allUsers.length,
|
||||
usersUpdated: updatedCount,
|
||||
});
|
||||
};
|
||||
@@ -3,8 +3,12 @@ import { connectWiseApi } from "../../../constants";
|
||||
import {
|
||||
CWOpportunity,
|
||||
CWOpportunitySummary,
|
||||
CWForecast,
|
||||
CWForecastItem,
|
||||
CWForecastItemCreate,
|
||||
CWOpportunityNote,
|
||||
CWOpportunityNoteCreate,
|
||||
CWOpportunityNoteUpdate,
|
||||
CWOpportunityContact,
|
||||
} from "./opportunity.types";
|
||||
|
||||
@@ -106,17 +110,148 @@ export const opportunityCw = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunity Forecasts
|
||||
* Fetch Opportunity Products
|
||||
*
|
||||
* Fetches forecast/revenue items for a given opportunity.
|
||||
* Fetches the full forecast object (products, revenue summaries, totals)
|
||||
* for a given opportunity.
|
||||
*/
|
||||
fetchForecasts: async (opportunityId: number): Promise<CWForecastItem[]> => {
|
||||
fetchProducts: async (opportunityId: number): Promise<CWForecast> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/sales/opportunities/${opportunityId}/forecast`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create Forecast Items
|
||||
*
|
||||
* Adds one or more forecast items (products) to an opportunity using
|
||||
* POST. The CW forecast endpoint expects a Forecast object with a
|
||||
* `forecastItems` array — we wrap just the new items inside that
|
||||
* structure so existing items are never sent or touched.
|
||||
*/
|
||||
createProducts: async (
|
||||
opportunityId: number,
|
||||
data: CWForecastItemCreate | CWForecastItemCreate[],
|
||||
): Promise<CWForecastItem[]> => {
|
||||
const items_to_add = Array.isArray(data) ? data : [data];
|
||||
const url = `/sales/opportunities/${opportunityId}/forecast`;
|
||||
|
||||
// 1. Fetch existing forecast to derive defaults & diff IDs later
|
||||
const existing = await opportunityCw.fetchProducts(opportunityId);
|
||||
const existingIds = new Set(
|
||||
(existing.forecastItems ?? []).map((fi) => fi.id),
|
||||
);
|
||||
|
||||
// Derive sensible defaults from an existing item when available
|
||||
const templateItem = (existing.forecastItems ?? [])[0];
|
||||
const defaultStatus = templateItem?.status
|
||||
? { id: templateItem.status.id }
|
||||
: { id: 1 };
|
||||
const defaultForecastType = templateItem?.forecastType ?? "Product";
|
||||
|
||||
// 2. Build forecast items with required CW fields filled in
|
||||
const forecastItems = items_to_add.map((newItem) => ({
|
||||
opportunity: { id: opportunityId },
|
||||
status: defaultStatus,
|
||||
forecastType: defaultForecastType,
|
||||
...(newItem as Record<string, unknown>),
|
||||
}));
|
||||
|
||||
// 3. POST a Forecast wrapper containing only the new items
|
||||
const response = await connectWiseApi.post(url, { forecastItems });
|
||||
const updatedForecast: CWForecast = response.data;
|
||||
|
||||
// 4. Find newly-created item(s) by diffing IDs
|
||||
const newItems = (updatedForecast.forecastItems ?? []).filter(
|
||||
(fi) => !existingIds.has(fi.id),
|
||||
);
|
||||
|
||||
// Fall back to the last N items if ID diffing finds nothing
|
||||
return newItems.length > 0
|
||||
? newItems
|
||||
: (updatedForecast.forecastItems ?? []).slice(-items_to_add.length);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update Forecast Item
|
||||
*
|
||||
* PATCHes a single forecast item on the parent `/forecast` endpoint.
|
||||
* CW supports JSON Patch with paths like `/forecastItems/{index}/field`.
|
||||
* This preserves item IDs (unlike PUT which always regenerates them)
|
||||
* and does NOT recalculate revenue/cost from linked catalog items.
|
||||
*
|
||||
* NOTE: Not all fields are patchable — `sequenceNumber` and `quantity`
|
||||
* are read-only on forecast items. Product ordering is managed locally
|
||||
* via `OpportunityController.resequenceProducts()` and stored in the
|
||||
* database `productSequence` field.
|
||||
*/
|
||||
updateProduct: async (
|
||||
opportunityId: number,
|
||||
forecastItemId: number,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<CWForecastItem> => {
|
||||
const forecast = await opportunityCw.fetchProducts(opportunityId);
|
||||
const items = forecast.forecastItems ?? [];
|
||||
const idx = items.findIndex((fi) => fi.id === forecastItemId);
|
||||
if (idx === -1) {
|
||||
throw new Error(
|
||||
`Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const operations = Object.entries(data).map(([key, value]) => ({
|
||||
op: "replace" as const,
|
||||
path: `/forecastItems/${idx}/${key}`,
|
||||
value,
|
||||
}));
|
||||
|
||||
const url = `/sales/opportunities/${opportunityId}/forecast`;
|
||||
const response = await connectWiseApi.patch(url, operations);
|
||||
const updated: CWForecast = response.data;
|
||||
return (updated.forecastItems ?? [])[idx]!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk-update Forecast Items
|
||||
*
|
||||
* PATCHes multiple forecast items in a single request via the parent
|
||||
* `/forecast` endpoint. All patch operations are sent in one array.
|
||||
*/
|
||||
bulkUpdateProducts: async (
|
||||
opportunityId: number,
|
||||
updates: Map<number, Record<string, unknown>>,
|
||||
): Promise<CWForecastItem[]> => {
|
||||
const forecast = await opportunityCw.fetchProducts(opportunityId);
|
||||
const items = forecast.forecastItems ?? [];
|
||||
|
||||
const operations: { op: "replace"; path: string; value: unknown }[] = [];
|
||||
const touchedIndices: number[] = [];
|
||||
|
||||
for (const [itemId, changes] of updates) {
|
||||
const idx = items.findIndex((fi) => fi.id === itemId);
|
||||
if (idx === -1) {
|
||||
throw new Error(
|
||||
`Forecast item ${itemId} not found on opportunity ${opportunityId}`,
|
||||
);
|
||||
}
|
||||
touchedIndices.push(idx);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
operations.push({
|
||||
op: "replace",
|
||||
path: `/forecastItems/${idx}/${key}`,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const url = `/sales/opportunities/${opportunityId}/forecast`;
|
||||
const response = await connectWiseApi.patch(url, operations);
|
||||
const updated: CWForecast = response.data;
|
||||
|
||||
return touchedIndices.map((i) => (updated.forecastItems ?? [])[i]!);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunity Notes
|
||||
*
|
||||
@@ -129,6 +264,69 @@ export const opportunityCw = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Single Note
|
||||
*
|
||||
* Fetches a single note by its ID on the given opportunity.
|
||||
*/
|
||||
fetchNote: async (
|
||||
opportunityId: number,
|
||||
noteId: number,
|
||||
): Promise<CWOpportunityNote> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/sales/opportunities/${opportunityId}/notes/${noteId}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create Note
|
||||
*
|
||||
* Creates a new note on the given opportunity.
|
||||
*/
|
||||
createNote: async (
|
||||
opportunityId: number,
|
||||
data: CWOpportunityNoteCreate,
|
||||
): Promise<CWOpportunityNote> => {
|
||||
const response = await connectWiseApi.post(
|
||||
`/sales/opportunities/${opportunityId}/notes`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update Note
|
||||
*
|
||||
* Updates an existing note on the given opportunity.
|
||||
*/
|
||||
updateNote: async (
|
||||
opportunityId: number,
|
||||
noteId: number,
|
||||
data: CWOpportunityNoteUpdate,
|
||||
): Promise<CWOpportunityNote> => {
|
||||
const response = await connectWiseApi.patch(
|
||||
`/sales/opportunities/${opportunityId}/notes/${noteId}`,
|
||||
Object.entries(data).map(([key, value]) => ({
|
||||
op: "replace",
|
||||
path: key,
|
||||
value,
|
||||
})),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Note
|
||||
*
|
||||
* Deletes a note from the given opportunity.
|
||||
*/
|
||||
deleteNote: async (opportunityId: number, noteId: number): Promise<void> => {
|
||||
await connectWiseApi.delete(
|
||||
`/sales/opportunities/${opportunityId}/notes/${noteId}`,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunity Contacts
|
||||
*
|
||||
@@ -142,4 +340,20 @@ export const opportunityCw = {
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Procurement Products
|
||||
*
|
||||
* Fetches procurement product records linked to an opportunity.
|
||||
* These contain cancellation data (cancelledFlag, cancelledReason, etc.)
|
||||
* that the forecast endpoint does not provide.
|
||||
*/
|
||||
fetchProcurementProducts: async (
|
||||
opportunityId: number,
|
||||
): Promise<Record<string, unknown>[]> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ interface CWSiteReference {
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CWCustomField {
|
||||
export interface CWCustomField {
|
||||
id: number;
|
||||
caption: string;
|
||||
type: string;
|
||||
@@ -103,16 +103,72 @@ export interface CWOpportunityInfo {
|
||||
|
||||
export interface CWForecastItem {
|
||||
id: number;
|
||||
forecastDescription: string;
|
||||
opportunity: CWReference;
|
||||
forecastType: string;
|
||||
forecastMonth: string;
|
||||
quantity: number;
|
||||
status: CWReference;
|
||||
catalogItem?: {
|
||||
id: number;
|
||||
identifier: string;
|
||||
_info?: Record<string, string>;
|
||||
};
|
||||
productDescription: string;
|
||||
productClass: string;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
forecastPercentage: number;
|
||||
status: CWReference;
|
||||
includedFlag: boolean;
|
||||
linkedFlag: boolean;
|
||||
margin: number;
|
||||
percentage: number;
|
||||
includeFlag: boolean;
|
||||
quoteWerksQuantity: number;
|
||||
forecastType: string;
|
||||
linkFlag: boolean;
|
||||
recurringRevenue: number;
|
||||
recurringCost: number;
|
||||
cycles: number;
|
||||
recurringFlag: boolean;
|
||||
sequenceNumber: number;
|
||||
subNumber: number;
|
||||
taxableFlag: boolean;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWForecastRevenueSummary {
|
||||
id: number;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
margin: number;
|
||||
percentage: number;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWForecast {
|
||||
id: number;
|
||||
forecastItems: CWForecastItem[];
|
||||
productRevenue: CWForecastRevenueSummary;
|
||||
serviceRevenue: CWForecastRevenueSummary;
|
||||
agreementRevenue: CWForecastRevenueSummary;
|
||||
timeRevenue: CWForecastRevenueSummary;
|
||||
expenseRevenue: CWForecastRevenueSummary;
|
||||
forecastRevenueTotals: CWForecastRevenueSummary;
|
||||
inclusiveRevenueTotals: CWForecastRevenueSummary;
|
||||
recurringTotal: number;
|
||||
wonRevenue: CWForecastRevenueSummary;
|
||||
lostRevenue: CWForecastRevenueSummary;
|
||||
openRevenue: CWForecastRevenueSummary;
|
||||
otherRevenue1: CWForecastRevenueSummary;
|
||||
otherRevenue2: CWForecastRevenueSummary;
|
||||
salesTaxRevenue: number;
|
||||
forecastTotalWithTaxes: number;
|
||||
expectedProbability: number;
|
||||
taxCode: CWReference;
|
||||
billingTerms: CWReference;
|
||||
currency: {
|
||||
id: number;
|
||||
symbol: string;
|
||||
currencyCode: string;
|
||||
name: string;
|
||||
_info?: Record<string, string>;
|
||||
};
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -127,6 +183,18 @@ export interface CWOpportunityNote {
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWOpportunityNoteCreate {
|
||||
text: string;
|
||||
type?: { id: number };
|
||||
flagged?: boolean;
|
||||
}
|
||||
|
||||
export interface CWOpportunityNoteUpdate {
|
||||
text?: string;
|
||||
type?: { id: number };
|
||||
flagged?: boolean;
|
||||
}
|
||||
|
||||
export interface CWOpportunityContact {
|
||||
id: number;
|
||||
opportunity: CWReference;
|
||||
@@ -138,6 +206,26 @@ export interface CWOpportunityContact {
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWForecastItemCreate {
|
||||
catalogItem?: { id: number };
|
||||
forecastDescription?: string;
|
||||
productDescription?: string;
|
||||
quantity?: number;
|
||||
status?: { id: number };
|
||||
productClass?: string;
|
||||
forecastType?: string;
|
||||
revenue?: number;
|
||||
cost?: number;
|
||||
includeFlag?: boolean;
|
||||
linkFlag?: boolean;
|
||||
recurringFlag?: boolean;
|
||||
taxableFlag?: boolean;
|
||||
recurringRevenue?: number;
|
||||
recurringCost?: number;
|
||||
cycles?: number;
|
||||
sequenceNumber?: number;
|
||||
}
|
||||
|
||||
export interface CWOpportunitySummary {
|
||||
id: number;
|
||||
_info?: Record<string, string>;
|
||||
|
||||
@@ -96,6 +96,10 @@ export const refreshCatalog = async () => {
|
||||
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,
|
||||
@@ -115,6 +119,10 @@ export const refreshCatalog = async () => {
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { connectWiseApi } from "../../../constants";
|
||||
|
||||
export interface CWCompanySite {
|
||||
id: number;
|
||||
name: string;
|
||||
addressLine1: string;
|
||||
addressLine2?: string;
|
||||
city: string;
|
||||
stateReference: { id: number; identifier: string; name: string } | null;
|
||||
zip: string;
|
||||
country: { id: number; name: string } | null;
|
||||
phoneNumber: string;
|
||||
faxNumber: string;
|
||||
taxCodeId: number | null;
|
||||
expenseReimbursement: number;
|
||||
primaryAddressFlag: boolean;
|
||||
defaultShippingFlag: boolean;
|
||||
defaultBillingFlag: boolean;
|
||||
defaultMailingFlag: boolean;
|
||||
mobileGuid: string;
|
||||
calendar: { id: number; name: string } | null;
|
||||
timeZone: { id: number; name: string } | null;
|
||||
company: { id: number; identifier: string; name: string };
|
||||
_info: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all sites for a ConnectWise company.
|
||||
*
|
||||
* @param cwCompanyId - The ConnectWise company ID
|
||||
* @returns Array of CW company sites
|
||||
*/
|
||||
export const fetchCompanySites = async (
|
||||
cwCompanyId: number,
|
||||
): Promise<CWCompanySite[]> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/company/companies/${cwCompanyId}/sites?pageSize=1000`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a single site by CW site ID for a given company.
|
||||
*
|
||||
* @param cwCompanyId - The ConnectWise company ID
|
||||
* @param cwSiteId - The ConnectWise site ID
|
||||
* @returns The CW company site
|
||||
*/
|
||||
export const fetchCompanySite = async (
|
||||
cwCompanyId: number,
|
||||
cwSiteId: number,
|
||||
): Promise<CWCompanySite> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/company/companies/${cwCompanyId}/sites/${cwSiteId}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serialize a CW site into a clean API-friendly object.
|
||||
*/
|
||||
export const serializeCwSite = (site: CWCompanySite) => ({
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
address: {
|
||||
line1: site.addressLine1,
|
||||
line2: site.addressLine2 ?? null,
|
||||
city: site.city,
|
||||
state: site.stateReference?.name ?? null,
|
||||
zip: site.zip,
|
||||
country: site.country?.name ?? "United States",
|
||||
},
|
||||
phoneNumber: site.phoneNumber || null,
|
||||
faxNumber: site.faxNumber || null,
|
||||
primaryAddressFlag: site.primaryAddressFlag,
|
||||
defaultShippingFlag: site.defaultShippingFlag,
|
||||
defaultBillingFlag: site.defaultBillingFlag,
|
||||
defaultMailingFlag: site.defaultMailingFlag,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export { userDefinedFieldsCw } from "./userDefinedFields";
|
||||
export type {
|
||||
CWUserDefinedField,
|
||||
CWUserDefinedFieldOption,
|
||||
CWUserDefinedFieldInfo,
|
||||
} from "./udf.types";
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface CWUserDefinedFieldOption {
|
||||
id: number;
|
||||
optionValue: string;
|
||||
defaultFlag: boolean;
|
||||
inactiveFlag: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface CWUserDefinedFieldInfo {
|
||||
lastUpdated: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface CWUserDefinedField {
|
||||
id: number;
|
||||
podId: number;
|
||||
caption: string;
|
||||
sequenceNumber: number;
|
||||
screenId: string;
|
||||
helpText?: string;
|
||||
fieldTypeIdentifier: string;
|
||||
numberDecimals: number;
|
||||
entryTypeIdentifier: string;
|
||||
requiredFlag: boolean;
|
||||
displayOnScreenFlag: boolean;
|
||||
readOnlyFlag: boolean;
|
||||
listViewFlag: boolean;
|
||||
options?: CWUserDefinedFieldOption[];
|
||||
businessUnitIds: number[];
|
||||
locationIds: number[];
|
||||
connectWiseID: string;
|
||||
dateCreated: string;
|
||||
_info: CWUserDefinedFieldInfo;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { connectWiseApi, redis } from "../../../constants";
|
||||
import { events } from "../../globalEvents";
|
||||
import { CWUserDefinedField } from "./udf.types";
|
||||
|
||||
const REDIS_KEY = "cw:userDefinedFields";
|
||||
|
||||
/** In-memory cache of all CW User Defined Fields, keyed by UDF id */
|
||||
let cache: Collection<number, CWUserDefinedField> = new Collection();
|
||||
|
||||
export const userDefinedFieldsCw = {
|
||||
/**
|
||||
* Get Cache
|
||||
*
|
||||
* Returns the current in-memory Collection of all User Defined Fields.
|
||||
* If the cache is empty, it will attempt to hydrate from Redis first,
|
||||
* then fall back to a live API fetch.
|
||||
*/
|
||||
get: async (): Promise<Collection<number, CWUserDefinedField>> => {
|
||||
if (cache.size > 0) return cache;
|
||||
|
||||
// Try hydrating from Redis
|
||||
const stored = await redis.get(REDIS_KEY);
|
||||
if (stored) {
|
||||
const parsed: CWUserDefinedField[] = JSON.parse(stored);
|
||||
cache = new Collection(parsed.map((udf) => [udf.id, udf]));
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Nothing cached anywhere — do a live fetch
|
||||
return userDefinedFieldsCw.refresh();
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All User Defined Fields
|
||||
*
|
||||
* Fetches all UDFs from the ConnectWise API.
|
||||
* Does NOT update the cache — use `refresh()` for that.
|
||||
*/
|
||||
fetchAll: async (): Promise<Collection<number, CWUserDefinedField>> => {
|
||||
const allItems = new Collection<number, CWUserDefinedField>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const response = await connectWiseApi.get(
|
||||
`/system/userDefinedFields?pageSize=${pageSize}`,
|
||||
);
|
||||
const items: CWUserDefinedField[] = response.data;
|
||||
|
||||
for (const item of items) {
|
||||
allItems.set(item.id, item);
|
||||
}
|
||||
|
||||
return allItems;
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh
|
||||
*
|
||||
* Fetches all UDFs from ConnectWise, replaces the in-memory cache
|
||||
* and persists the snapshot to Redis.
|
||||
*/
|
||||
refresh: async (): Promise<Collection<number, CWUserDefinedField>> => {
|
||||
events.emit("cw:udf:refresh:started");
|
||||
|
||||
const allItems = await userDefinedFieldsCw.fetchAll();
|
||||
cache = allItems;
|
||||
|
||||
// Persist to Redis
|
||||
await redis.set(REDIS_KEY, JSON.stringify([...allItems.values()]));
|
||||
|
||||
events.emit("cw:udf:refresh:completed", { count: allItems.size });
|
||||
return cache;
|
||||
},
|
||||
|
||||
/**
|
||||
* Find by ID
|
||||
*
|
||||
* Returns a single UDF by its ConnectWise ID from the cache.
|
||||
*/
|
||||
findById: async (id: number): Promise<CWUserDefinedField | undefined> => {
|
||||
const items = await userDefinedFieldsCw.get();
|
||||
return items.get(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Find by Caption
|
||||
*
|
||||
* Returns the first UDF matching the given caption (case-insensitive).
|
||||
*/
|
||||
findByCaption: async (
|
||||
caption: string,
|
||||
): Promise<CWUserDefinedField | undefined> => {
|
||||
const items = await userDefinedFieldsCw.get();
|
||||
const lowerCaption = caption.toLowerCase();
|
||||
return items.find((udf) => udf.caption.toLowerCase() === lowerCaption);
|
||||
},
|
||||
|
||||
/**
|
||||
* Find by Screen ID
|
||||
*
|
||||
* Returns all UDFs associated with a given screenId.
|
||||
*/
|
||||
findByScreenId: async (
|
||||
screenId: string,
|
||||
): Promise<Collection<number, CWUserDefinedField>> => {
|
||||
const items = await userDefinedFieldsCw.get();
|
||||
return items.filter((udf) => udf.screenId === screenId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Invalidate
|
||||
*
|
||||
* Clears the in-memory cache and removes the Redis key.
|
||||
*/
|
||||
invalidate: async (): Promise<void> => {
|
||||
cache = new Collection();
|
||||
await redis.del(REDIS_KEY);
|
||||
},
|
||||
};
|
||||
@@ -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));
|
||||
@@ -177,6 +177,34 @@ interface EventTypes {
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
}) => void;
|
||||
|
||||
// Cache Events
|
||||
"cache:opportunities:refresh:started": (data: {
|
||||
totalOpportunities: number;
|
||||
}) => void;
|
||||
"cache:opportunities:refresh:completed": (data: {
|
||||
totalOpportunities: number;
|
||||
activitiesRefreshed: number;
|
||||
companiesRefreshed: number;
|
||||
notesRefreshed: number;
|
||||
contactsRefreshed: number;
|
||||
productsRefreshed: number;
|
||||
oppCwDataRefreshed: number;
|
||||
skipped: number;
|
||||
}) => void;
|
||||
"cache:opportunities:refresh:error": (data: { error: unknown }) => void;
|
||||
|
||||
// ConnectWise User Defined Fields Events
|
||||
"cw:udf:refresh:started": () => void;
|
||||
"cw:udf:refresh:completed": (data: { count: number }) => void;
|
||||
|
||||
// ConnectWise Members Events
|
||||
"cw:members:refresh:started": () => void;
|
||||
"cw:members:refresh:completed": (data: {
|
||||
totalMembers: number;
|
||||
totalUsers: number;
|
||||
usersUpdated: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const events = new Eventra<EventTypes>();
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface PermissionCategory {
|
||||
description: string;
|
||||
/** Permission nodes in this category */
|
||||
permissions: PermissionNode[];
|
||||
/** Optional nested sub-categories for hierarchical grouping */
|
||||
subCategories?: Record<string, PermissionCategory>;
|
||||
}
|
||||
|
||||
export const PERMISSION_NODES = {
|
||||
@@ -353,10 +355,13 @@ export const PERMISSION_NODES = {
|
||||
},
|
||||
{
|
||||
node: "procurement.catalog.fetch.many",
|
||||
description: "Fetch multiple catalog items or count",
|
||||
description:
|
||||
"Fetch multiple catalog items, count, categories/ecosystems, or filter values",
|
||||
usedIn: [
|
||||
"src/api/procurement/fetchAll.ts",
|
||||
"src/api/procurement/count.ts",
|
||||
"src/api/procurement/categories.ts",
|
||||
"src/api/procurement/filters.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -385,18 +390,24 @@ export const PERMISSION_NODES = {
|
||||
{
|
||||
node: "sales.opportunity.fetch",
|
||||
description:
|
||||
"Fetch a single opportunity and its sub-resources (forecasts, notes, contacts)",
|
||||
"Fetch a single opportunity and its sub-resources (products, notes, contacts)",
|
||||
usedIn: [
|
||||
"src/api/sales/[id]/fetch.ts",
|
||||
"src/api/sales/[id]/forecasts.ts",
|
||||
"src/api/sales/[id]/products.ts",
|
||||
"src/api/sales/[id]/notes.ts",
|
||||
"src/api/sales/[id]/fetchNote.ts",
|
||||
"src/api/sales/[id]/contacts.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.fetch.many",
|
||||
description: "Fetch multiple opportunities or count",
|
||||
usedIn: ["src/api/sales/fetchAll.ts", "src/api/sales/count.ts"],
|
||||
description:
|
||||
"Fetch multiple opportunities, count, or opportunity types",
|
||||
usedIn: [
|
||||
"src/api/sales/fetchAll.ts",
|
||||
"src/api/sales/count.ts",
|
||||
"src/api/sales/fetchOpportunityTypes.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.refresh",
|
||||
@@ -404,6 +415,57 @@ export const PERMISSION_NODES = {
|
||||
usedIn: ["src/api/sales/[id]/refresh.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.note.create",
|
||||
description: "Create a new note on an opportunity",
|
||||
usedIn: ["src/api/sales/[id]/createNote.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.note.update",
|
||||
description: "Update an existing note on an opportunity",
|
||||
usedIn: ["src/api/sales/[id]/updateNote.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.note.delete",
|
||||
description: "Delete a note from an opportunity",
|
||||
usedIn: ["src/api/sales/[id]/deleteNote.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.product.update",
|
||||
description:
|
||||
"Update products (forecast items) on an opportunity, including resequencing",
|
||||
usedIn: ["src/api/sales/[id]/resequenceProducts.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.product.add",
|
||||
description:
|
||||
"Add a new product (forecast item) to an opportunity. Individual fields are gated by sales.opportunity.product.field.<field> permissions.",
|
||||
usedIn: ["src/api/sales/[id]/addProduct.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
fieldLevelPermissions: [
|
||||
"sales.opportunity.product.field.catalogItem",
|
||||
"sales.opportunity.product.field.forecastDescription",
|
||||
"sales.opportunity.product.field.productDescription",
|
||||
"sales.opportunity.product.field.quantity",
|
||||
"sales.opportunity.product.field.status",
|
||||
"sales.opportunity.product.field.productClass",
|
||||
"sales.opportunity.product.field.forecastType",
|
||||
"sales.opportunity.product.field.revenue",
|
||||
"sales.opportunity.product.field.cost",
|
||||
"sales.opportunity.product.field.includeFlag",
|
||||
"sales.opportunity.product.field.linkFlag",
|
||||
"sales.opportunity.product.field.recurringFlag",
|
||||
"sales.opportunity.product.field.taxableFlag",
|
||||
"sales.opportunity.product.field.recurringRevenue",
|
||||
"sales.opportunity.product.field.recurringCost",
|
||||
"sales.opportunity.product.field.cycles",
|
||||
"sales.opportunity.product.field.sequenceNumber",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -642,14 +704,307 @@ export const PERMISSION_NODES = {
|
||||
},
|
||||
],
|
||||
},
|
||||
objectTypes: {
|
||||
name: "Object Types",
|
||||
description:
|
||||
"Field-level read permissions that control which keys are visible on API response objects. Each sub-category corresponds to a domain object type. Use <scope>.* to grant access to all fields.",
|
||||
permissions: [],
|
||||
subCategories: {
|
||||
company: {
|
||||
name: "Company",
|
||||
description:
|
||||
"Field-level read permissions for Company response objects",
|
||||
permissions: [
|
||||
{
|
||||
node: "obj.company",
|
||||
description:
|
||||
"Field-level gate for Company objects. Each key on the response is checked as obj.company.<field>. Only fields the user has permission for are included.",
|
||||
usedIn: [
|
||||
"src/api/companies/[id]/fetch.ts",
|
||||
"src/api/companies/fetchAll.ts",
|
||||
],
|
||||
fieldLevelPermissions: [
|
||||
"obj.company.id",
|
||||
"obj.company.name",
|
||||
"obj.company.cw_Identifier",
|
||||
"obj.company.cw_CompanyId",
|
||||
"obj.company.cw_Data",
|
||||
"obj.company.createdAt",
|
||||
"obj.company.updatedAt",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
credential: {
|
||||
name: "Credential",
|
||||
description:
|
||||
"Field-level read permissions for Credential response objects",
|
||||
permissions: [
|
||||
{
|
||||
node: "obj.credential",
|
||||
description:
|
||||
"Field-level gate for Credential objects. Each key on the response is checked as obj.credential.<field>. Only fields the user has permission for are included.",
|
||||
usedIn: [
|
||||
"src/api/credentials/fetch.ts",
|
||||
"src/api/credentials/fetchByCompany.ts",
|
||||
"src/api/credentials/fetchSubCredentials.ts",
|
||||
"src/api/credential-types/fetchCredentials.ts",
|
||||
],
|
||||
fieldLevelPermissions: [
|
||||
"obj.credential.id",
|
||||
"obj.credential.name",
|
||||
"obj.credential.notes",
|
||||
"obj.credential.typeId",
|
||||
"obj.credential.companyId",
|
||||
"obj.credential.subCredentialOfId",
|
||||
"obj.credential.fields",
|
||||
"obj.credential.type",
|
||||
"obj.credential.company",
|
||||
"obj.credential.subCredentials",
|
||||
"obj.credential.secureFieldIds",
|
||||
"obj.credential.createdAt",
|
||||
"obj.credential.updatedAt",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
credentialType: {
|
||||
name: "Credential Type",
|
||||
description:
|
||||
"Field-level read permissions for Credential Type response objects",
|
||||
permissions: [
|
||||
{
|
||||
node: "obj.credentialType",
|
||||
description:
|
||||
"Field-level gate for Credential Type objects. Each key on the response is checked as obj.credentialType.<field>. Only fields the user has permission for are included.",
|
||||
usedIn: [
|
||||
"src/api/credential-types/fetch.ts",
|
||||
"src/api/credential-types/fetchAll.ts",
|
||||
],
|
||||
fieldLevelPermissions: [
|
||||
"obj.credentialType.id",
|
||||
"obj.credentialType.name",
|
||||
"obj.credentialType.permissionScope",
|
||||
"obj.credentialType.icon",
|
||||
"obj.credentialType.fields",
|
||||
"obj.credentialType.credentialCount",
|
||||
"obj.credentialType.createdAt",
|
||||
"obj.credentialType.updatedAt",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
user: {
|
||||
name: "User",
|
||||
description: "Field-level read permissions for User response objects",
|
||||
permissions: [
|
||||
{
|
||||
node: "obj.user",
|
||||
description:
|
||||
"Field-level gate for User objects. Each key on the response is checked as obj.user.<field>. Only fields the user has permission for are included.",
|
||||
usedIn: [
|
||||
"src/api/user/@me/fetch.ts",
|
||||
"src/api/user/fetch.ts",
|
||||
"src/api/user/fetchAll.ts",
|
||||
"src/api/roles/getUsers.ts",
|
||||
],
|
||||
fieldLevelPermissions: [
|
||||
"obj.user.id",
|
||||
"obj.user.name",
|
||||
"obj.user.roles",
|
||||
"obj.user.permissions",
|
||||
"obj.user.login",
|
||||
"obj.user.email",
|
||||
"obj.user.image",
|
||||
"obj.user.createdAt",
|
||||
"obj.user.updatedAt",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
role: {
|
||||
name: "Role",
|
||||
description: "Field-level read permissions for Role response objects",
|
||||
permissions: [
|
||||
{
|
||||
node: "obj.role",
|
||||
description:
|
||||
"Field-level gate for Role objects. Each key on the response is checked as obj.role.<field>. Only fields the user has permission for are included.",
|
||||
usedIn: [
|
||||
"src/api/roles/fetch.ts",
|
||||
"src/api/roles/fetchAll.ts",
|
||||
"src/api/user/fetchRoles.ts",
|
||||
],
|
||||
fieldLevelPermissions: [
|
||||
"obj.role.id",
|
||||
"obj.role.title",
|
||||
"obj.role.moniker",
|
||||
"obj.role.permissions",
|
||||
"obj.role.users",
|
||||
"obj.role.createdAt",
|
||||
"obj.role.updatedAt",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
catalogItem: {
|
||||
name: "Catalog Item",
|
||||
description:
|
||||
"Field-level read permissions for Catalog Item (procurement) response objects",
|
||||
permissions: [
|
||||
{
|
||||
node: "obj.catalogItem",
|
||||
description:
|
||||
"Field-level gate for Catalog Item objects. Each key on the response is checked as obj.catalogItem.<field>. Only fields the user has permission for are included.",
|
||||
usedIn: [
|
||||
"src/api/procurement/fetchAll.ts",
|
||||
"src/api/procurement/[id]/fetch.ts",
|
||||
"src/api/procurement/[id]/fetchLinked.ts",
|
||||
],
|
||||
fieldLevelPermissions: [
|
||||
"obj.catalogItem.id",
|
||||
"obj.catalogItem.cwCatalogId",
|
||||
"obj.catalogItem.identifier",
|
||||
"obj.catalogItem.name",
|
||||
"obj.catalogItem.description",
|
||||
"obj.catalogItem.customerDescription",
|
||||
"obj.catalogItem.internalNotes",
|
||||
"obj.catalogItem.manufacturer",
|
||||
"obj.catalogItem.manufactureCwId",
|
||||
"obj.catalogItem.partNumber",
|
||||
"obj.catalogItem.vendorName",
|
||||
"obj.catalogItem.vendorSku",
|
||||
"obj.catalogItem.vendorCwId",
|
||||
"obj.catalogItem.price",
|
||||
"obj.catalogItem.cost",
|
||||
"obj.catalogItem.inactive",
|
||||
"obj.catalogItem.salesTaxable",
|
||||
"obj.catalogItem.onHand",
|
||||
"obj.catalogItem.cwLastUpdated",
|
||||
"obj.catalogItem.linkedItems",
|
||||
"obj.catalogItem.createdAt",
|
||||
"obj.catalogItem.updatedAt",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
opportunity: {
|
||||
name: "Opportunity",
|
||||
description:
|
||||
"Field-level read permissions for Opportunity (sales) response objects",
|
||||
permissions: [
|
||||
{
|
||||
node: "obj.opportunity",
|
||||
description:
|
||||
"Field-level gate for Opportunity objects. Each key on the response is checked as obj.opportunity.<field>. Only fields the user has permission for are included.",
|
||||
usedIn: [
|
||||
"src/api/sales/fetchAll.ts",
|
||||
"src/api/sales/[id]/fetch.ts",
|
||||
],
|
||||
fieldLevelPermissions: [
|
||||
"obj.opportunity.id",
|
||||
"obj.opportunity.cwOpportunityId",
|
||||
"obj.opportunity.name",
|
||||
"obj.opportunity.notes",
|
||||
"obj.opportunity.type",
|
||||
"obj.opportunity.stage",
|
||||
"obj.opportunity.status",
|
||||
"obj.opportunity.priority",
|
||||
"obj.opportunity.rating",
|
||||
"obj.opportunity.source",
|
||||
"obj.opportunity.campaign",
|
||||
"obj.opportunity.primarySalesRep",
|
||||
"obj.opportunity.secondarySalesRep",
|
||||
"obj.opportunity.company",
|
||||
"obj.opportunity.contact",
|
||||
"obj.opportunity.site",
|
||||
"obj.opportunity.customerPO",
|
||||
"obj.opportunity.totalSalesTax",
|
||||
"obj.opportunity.location",
|
||||
"obj.opportunity.department",
|
||||
"obj.opportunity.expectedCloseDate",
|
||||
"obj.opportunity.pipelineChangeDate",
|
||||
"obj.opportunity.dateBecameLead",
|
||||
"obj.opportunity.closedDate",
|
||||
"obj.opportunity.closedFlag",
|
||||
"obj.opportunity.closedBy",
|
||||
"obj.opportunity.companyId",
|
||||
"obj.opportunity.cwLastUpdated",
|
||||
"obj.opportunity.createdAt",
|
||||
"obj.opportunity.updatedAt",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
unifiSite: {
|
||||
name: "UniFi Site",
|
||||
description:
|
||||
"Field-level read permissions for UniFi Site response objects",
|
||||
permissions: [
|
||||
{
|
||||
node: "obj.unifiSite",
|
||||
description:
|
||||
"Field-level gate for UniFi Site objects. Each key on the response is checked as obj.unifiSite.<field>. Only fields the user has permission for are included.",
|
||||
usedIn: [
|
||||
"src/api/unifi/sites/fetchAll.ts",
|
||||
"src/api/unifi/site/fetch.ts",
|
||||
"src/api/companies/[id]/unifiSites.ts",
|
||||
],
|
||||
fieldLevelPermissions: [
|
||||
"obj.unifiSite.id",
|
||||
"obj.unifiSite.name",
|
||||
"obj.unifiSite.siteId",
|
||||
"obj.unifiSite.companyId",
|
||||
"obj.unifiSite.company",
|
||||
"obj.unifiSite.createdAt",
|
||||
"obj.unifiSite.updatedAt",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
wifiNetwork: {
|
||||
name: "WiFi Network",
|
||||
description:
|
||||
"Field-level read permissions for UniFi WiFi Network (WLAN) response objects. See the unifi category for the full field-level permission list under unifi.site.wifi.read.",
|
||||
permissions: [
|
||||
{
|
||||
node: "unifi.site.wifi.read",
|
||||
description:
|
||||
"Field-level gate for WiFi network response data (defined in the unifi category). Each key on the WlanConf object is checked as unifi.site.wifi.read.<field>.",
|
||||
usedIn: ["src/api/unifi/site/wifi/fetchAll.ts"],
|
||||
dependencies: ["unifi.access", "unifi.site.wifi"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, PermissionCategory>;
|
||||
|
||||
/**
|
||||
* Recursively collects permission nodes from a category and its sub-categories.
|
||||
*/
|
||||
function collectPermissions(category: PermissionCategory): PermissionNode[] {
|
||||
const direct = category.permissions as PermissionNode[];
|
||||
const nested = category.subCategories
|
||||
? Object.values(category.subCategories).flatMap(collectPermissions)
|
||||
: [];
|
||||
return [...direct, ...nested];
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get all permission nodes flattened into a single array
|
||||
*/
|
||||
export function getAllPermissionNodes(): PermissionNode[] {
|
||||
return Object.values(PERMISSION_NODES).flatMap(
|
||||
(category) => category.permissions as PermissionNode[],
|
||||
return Object.values(PERMISSION_NODES).flatMap((category) =>
|
||||
collectPermissions(category),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
export interface QuoteStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
wonFlag: boolean;
|
||||
lostFlag: boolean;
|
||||
closedFlag: boolean;
|
||||
inactiveFlag: boolean;
|
||||
defaultFlag: boolean;
|
||||
enteredBy: string;
|
||||
dateEntered: string;
|
||||
_info: {
|
||||
lastUpdated: string;
|
||||
updatedBy: string;
|
||||
};
|
||||
connectWiseId: string;
|
||||
optimaEquivalency: number[];
|
||||
}
|
||||
|
||||
export const QUOTE_STATUSES: QuoteStatus[] = [
|
||||
//
|
||||
// FUTURE
|
||||
//
|
||||
{
|
||||
id: 51,
|
||||
name: "FutureLead",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2023-07-11T23:13:19Z",
|
||||
_info: {
|
||||
lastUpdated: "2024-04-28T15:03:57Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "070f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [
|
||||
35, // Z9. Later
|
||||
36, // Z0. TT Identified Need
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// NEW
|
||||
//
|
||||
{
|
||||
id: 24,
|
||||
name: "New",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: true,
|
||||
enteredBy: "CRobinso",
|
||||
dateEntered: "2021-01-03T15:06:59Z",
|
||||
_info: {
|
||||
lastUpdated: "2024-04-28T15:04:43Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "ec0e72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [
|
||||
1, // Pre2021-1) New
|
||||
13, // Pre2021-Initial Contact Made
|
||||
37, // 00. Pending New
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// INTERNAL REVIEW
|
||||
//
|
||||
{
|
||||
id: 56,
|
||||
name: "Internal Review",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2024-04-28T15:05:09Z",
|
||||
_info: {
|
||||
lastUpdated: "2024-04-28T15:05:09Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "0c0f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [
|
||||
10, // Pre2021-Order Approved
|
||||
26, // Z3. ConfirmedQuote
|
||||
27, // Z4. Waiting-VendorInfo
|
||||
28, // Z5. Waiting-OtherTTStaff
|
||||
41, // PRE2405. Review Ready
|
||||
54, // PRE24_90. Customer Approved
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// ACTIVE
|
||||
//
|
||||
{
|
||||
id: 58,
|
||||
name: "Active",
|
||||
wonFlag: false,
|
||||
lostFlag: false,
|
||||
closedFlag: false,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2024-04-28T15:07:17Z",
|
||||
_info: {
|
||||
lastUpdated: "2024-04-28T15:07:17Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "0e0f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [
|
||||
9, // Pre2021-Recommendation
|
||||
15, // Pre2021-3) Onsite Assess Sch'd
|
||||
16, // Pre2021-4) Quote Info Gathered
|
||||
17, // Pre2021-5) Quote Sent
|
||||
18, // Pre2021-6) Follow-up #1 Made
|
||||
19, // Pre2021-7) Follow-up #2 Made
|
||||
20, // Pre2021-8) Follow-up #3 Made
|
||||
|
||||
25, // ZOLD---Quote Sent
|
||||
43, // 03. Quote Sent
|
||||
|
||||
38, // PRE2402. On-Site Ready
|
||||
39, // PRE2403. On-Site Scheduled
|
||||
40, // PRE2404. On-Site Complete
|
||||
42, // PRE2407. Reviewed
|
||||
44, // PRE2409. Follow-Up 1
|
||||
45, // PRE2410. Changes Needed
|
||||
46, // PRE2411. Follow-Up 2
|
||||
47, // PRE2412. Follow-Up3
|
||||
48, // PRE2413. Follow-Up Extended
|
||||
52, // PRE2489. Overdue
|
||||
55, // PRE24_70. Quote Sent - Sell
|
||||
57, // 04. Confirmed Quote
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// WON
|
||||
//
|
||||
{
|
||||
id: 29,
|
||||
name: "Won",
|
||||
wonFlag: true,
|
||||
lostFlag: false,
|
||||
closedFlag: true,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "CRobinso",
|
||||
dateEntered: "2021-01-03T15:07:44Z",
|
||||
_info: {
|
||||
lastUpdated: "2024-01-21T20:39:41Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "f10e72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [
|
||||
2, // Pre2021-8) Won
|
||||
54, // PRE24_90. Customer Approved (if you treat as effectively Won)
|
||||
49, // 91. Pending Won
|
||||
],
|
||||
},
|
||||
|
||||
//
|
||||
// LOST
|
||||
//
|
||||
{
|
||||
id: 53,
|
||||
name: "Lost",
|
||||
wonFlag: false,
|
||||
lostFlag: true,
|
||||
closedFlag: true,
|
||||
inactiveFlag: false,
|
||||
defaultFlag: false,
|
||||
enteredBy: "crobinso",
|
||||
dateEntered: "2024-01-20T20:51:35Z",
|
||||
_info: {
|
||||
lastUpdated: "2024-01-20T20:51:41Z",
|
||||
updatedBy: "crobinso",
|
||||
},
|
||||
connectWiseId: "090f72a3-70d0-ef11-b2e0-000c29c55070",
|
||||
optimaEquivalency: [
|
||||
3, // Pre2021-9) Lost
|
||||
4, // Pre2021-No Decision
|
||||
12, // Pre2021-OLD
|
||||
|
||||
30, // Pre2024_99. Lost-Competitor
|
||||
31, // Pre2024_99. Lost-DIY
|
||||
32, // Pre2024_99. Lost-NoDecision
|
||||
33, // Pre2024_99. Lost-Pricing
|
||||
34, // Pre2024_99. Lost-OtherTTQuote
|
||||
|
||||
50, // 98. Pending Lost
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Test Script: Forecast Item Resequencing & Procurement Linkage
|
||||
*
|
||||
* Validates the CW forecast API behaviour discovered via probing:
|
||||
* - `sequenceNumber` is read-only — display order = array position
|
||||
* - PUT always regenerates all forecast item IDs
|
||||
* - Revenue & cost are preserved through PUT
|
||||
* - PATCH on /forecast with `/forecastItems/{idx}/field` paths works
|
||||
* for some fields (e.g. forecastDescription) and preserves IDs
|
||||
*
|
||||
* Test flow:
|
||||
* 1. Create opportunity under XYZ Test Company
|
||||
* 2. Add 4 products via POST
|
||||
* 3. Create procurement products (linked by forecastDetailId)
|
||||
* 4. Cancel one procurement product
|
||||
* 5. Reorder forecast items via PUT (reverse order)
|
||||
* 6. Remap procurement forecastDetailId to new IDs
|
||||
* 7. Verify: order correct, prices preserved, cancellation data intact
|
||||
* 8. Clean up
|
||||
*
|
||||
* Usage: bun run test-forecast-resequence.ts
|
||||
*/
|
||||
import axios from "axios";
|
||||
|
||||
const cw = axios.create({
|
||||
baseURL: "https://ttscw.totaltech.net/v4_6_release/apis/3.0/",
|
||||
headers: {
|
||||
Authorization: `Basic ${process.env.CW_BASIC_TOKEN}`,
|
||||
clientId: `${process.env.CW_CLIENT_ID}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const log = (label: string, ...args: unknown[]) =>
|
||||
console.log(`\n[${label}]`, ...args);
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
// ── 1. Find company ─────────────────────────────────────────────────────
|
||||
log("SETUP", "Finding XYZ Test Company...");
|
||||
const compRes = await cw.get(
|
||||
`/company/companies?conditions=${encodeURIComponent("name like 'XYZ Test%'")}&fields=id,identifier,name`,
|
||||
);
|
||||
if (compRes.data.length === 0) {
|
||||
console.error("ERROR: 'XYZ Test Company' not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
const company = compRes.data[0];
|
||||
log("SETUP", `Company: ${company.name} (id=${company.id})`);
|
||||
|
||||
// ── 2. Create opportunity ───────────────────────────────────────────────
|
||||
log("SETUP", "Creating test opportunity...");
|
||||
const oppRes = await cw.post("/sales/opportunities", {
|
||||
name: `[TEST] Resequence – ${new Date().toISOString().slice(0, 16)}`,
|
||||
company: { id: company.id },
|
||||
contact: { id: 1 },
|
||||
primarySalesRep: { id: 153 },
|
||||
expectedCloseDate: new Date(Date.now() + 30 * 86_400_000)
|
||||
.toISOString()
|
||||
.replace(/\.\d{3}Z$/, "Z"),
|
||||
});
|
||||
const oppId = oppRes.data.id;
|
||||
log("SETUP", `Created opportunity id=${oppId}`);
|
||||
|
||||
const forecastUrl = `/sales/opportunities/${oppId}/forecast`;
|
||||
|
||||
// Track IDs for cleanup
|
||||
const procIdsToClean: number[] = [];
|
||||
|
||||
try {
|
||||
// ── 3. Add 4 products ───────────────────────────────────────────────────
|
||||
log("PRODUCTS", "Adding 4 products...");
|
||||
const postRes = await cw.post(forecastUrl, {
|
||||
forecastItems: [
|
||||
{
|
||||
opportunity: { id: oppId },
|
||||
status: { id: 1 },
|
||||
forecastDescription: "Alpha",
|
||||
revenue: 100,
|
||||
cost: 50,
|
||||
forecastType: "Product",
|
||||
},
|
||||
{
|
||||
opportunity: { id: oppId },
|
||||
status: { id: 1 },
|
||||
forecastDescription: "Bravo",
|
||||
revenue: 250,
|
||||
cost: 125,
|
||||
forecastType: "Product",
|
||||
},
|
||||
{
|
||||
opportunity: { id: oppId },
|
||||
status: { id: 1 },
|
||||
forecastDescription: "Charlie",
|
||||
revenue: 30,
|
||||
cost: 10,
|
||||
forecastType: "Product",
|
||||
},
|
||||
{
|
||||
opportunity: { id: oppId },
|
||||
status: { id: 1 },
|
||||
forecastDescription: "Delta",
|
||||
revenue: 75,
|
||||
cost: 40,
|
||||
forecastType: "Product",
|
||||
},
|
||||
],
|
||||
});
|
||||
const items: any[] = postRes.data.forecastItems ?? [];
|
||||
log("PRODUCTS", `Created ${items.length} items:`);
|
||||
for (const it of items) {
|
||||
console.log(
|
||||
` id=${it.id} desc="${it.forecastDescription}" rev=${it.revenue} cost=${it.cost}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshot prices
|
||||
const priceSnap = new Map<string, { rev: number; cost: number }>(
|
||||
items.map((i) => [
|
||||
i.forecastDescription,
|
||||
{ rev: i.revenue, cost: i.cost },
|
||||
]),
|
||||
);
|
||||
|
||||
// ── 4. Create procurement products ──────────────────────────────────────
|
||||
log("PROCUREMENT", "Creating procurement products...");
|
||||
const procProducts: any[] = [];
|
||||
for (const item of items) {
|
||||
try {
|
||||
const pr = await cw.post("/procurement/products", {
|
||||
catalogItem: { id: 87 },
|
||||
description: item.forecastDescription,
|
||||
quantity: 1,
|
||||
price: item.revenue,
|
||||
cost: item.cost,
|
||||
billableOption: "Billable",
|
||||
opportunity: { id: oppId },
|
||||
forecastDetailId: item.id,
|
||||
});
|
||||
procProducts.push(pr.data);
|
||||
procIdsToClean.push(pr.data.id);
|
||||
console.log(
|
||||
` ✓ Proc ${pr.data.id} → forecastDetailId=${pr.data.forecastDetailId} "${item.forecastDescription}"`,
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.log(
|
||||
` ✗ Failed: ${e.response?.status} ${JSON.stringify(e.response?.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (procProducts.length === 0) {
|
||||
log(
|
||||
"PROCUREMENT",
|
||||
"Could not create procurement products (permission issue?).",
|
||||
);
|
||||
log(
|
||||
"PROCUREMENT",
|
||||
"Will run reorder test without cancellation verification.",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 5. Cancel "Bravo" procurement product ───────────────────────────────
|
||||
const bravoProc = procProducts.find((p: any) => p.description === "Bravo");
|
||||
if (bravoProc) {
|
||||
log("CANCEL", `Cancelling Bravo (proc id=${bravoProc.id})...`);
|
||||
try {
|
||||
await cw.patch(`/procurement/products/${bravoProc.id}`, [
|
||||
{ op: "replace", path: "cancelledFlag", value: true },
|
||||
{ op: "replace", path: "quantityCancelled", value: 1 },
|
||||
{
|
||||
op: "replace",
|
||||
path: "cancelledReason",
|
||||
value: "Test cancellation",
|
||||
},
|
||||
]);
|
||||
log("CANCEL", "✓ Cancelled.");
|
||||
} catch (e: any) {
|
||||
log(
|
||||
"CANCEL",
|
||||
`✗ ${e.response?.status} ${JSON.stringify(e.response?.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5b. Check for auto-created forecast items ─────────────────────────
|
||||
await sleep(300);
|
||||
const midForecast = await cw.get(forecastUrl);
|
||||
const midItems = midForecast.data.forecastItems ?? [];
|
||||
log(
|
||||
"OBSERVE",
|
||||
`Forecast items after procurement creation: ${midItems.length} (was ${items.length})`,
|
||||
);
|
||||
if (midItems.length !== items.length) {
|
||||
log(
|
||||
"OBSERVE",
|
||||
"⚠ Creating procurement products auto-created additional forecast items!",
|
||||
);
|
||||
for (const mi of midItems) {
|
||||
const isOriginal = items.some((i: any) => i.id === mi.id);
|
||||
console.log(
|
||||
` id=${mi.id} desc="${mi.forecastDescription}" ${isOriginal ? "(original)" : "(AUTO-CREATED by procurement)"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot procurement state before reorder
|
||||
const beforeProc = await cw.get(
|
||||
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,description`,
|
||||
);
|
||||
// Build map by description for cross-PUT comparison (IDs will change)
|
||||
const beforeByDesc = new Map<string, any>();
|
||||
log(
|
||||
"SNAPSHOT",
|
||||
`${beforeProc.data.length} procurement products before reorder:`,
|
||||
);
|
||||
for (const p of beforeProc.data) {
|
||||
beforeByDesc.set(p.description, p);
|
||||
console.log(
|
||||
` Proc ${p.id}: forecastDetailId=${p.forecastDetailId} cancelled=${p.cancelledFlag} qty=${p.quantityCancelled} reason="${p.cancelledReason ?? ""}" "${p.description}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Record old procurement IDs for later comparison
|
||||
const oldProcIds = new Set(beforeProc.data.map((p: any) => p.id));
|
||||
|
||||
// ── 6. Reorder: reverse ONLY the original 4 forecast items ──────────────
|
||||
log("REORDER", "Reversing forecast item order via PUT...");
|
||||
|
||||
// Only reorder the original items; keep any auto-created ones in place
|
||||
const originalDescs = new Set(items.map((i: any) => i.forecastDescription));
|
||||
const originals = midItems.filter(
|
||||
(i: any) =>
|
||||
originalDescs.has(i.forecastDescription) &&
|
||||
items.some((o: any) => o.id === i.id),
|
||||
);
|
||||
const extras = midItems.filter(
|
||||
(i: any) => !originals.some((o: any) => o.id === i.id),
|
||||
);
|
||||
|
||||
const reversedOriginals = [...originals].reverse();
|
||||
const reorderedAll = [...reversedOriginals, ...extras];
|
||||
|
||||
const clone = JSON.parse(JSON.stringify(midForecast.data));
|
||||
clone.forecastItems = JSON.parse(JSON.stringify(reorderedAll));
|
||||
|
||||
const putRes = await cw.put(forecastUrl, clone);
|
||||
const newItems: any[] = putRes.data.forecastItems ?? [];
|
||||
|
||||
log("REORDER", `After PUT (${newItems.length} items):`);
|
||||
for (const it of newItems) {
|
||||
console.log(
|
||||
` id=${it.id} desc="${it.forecastDescription}" rev=${it.revenue} cost=${it.cost}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Build old→new ID map by position (for original items only)
|
||||
const idMap = new Map<number, number>();
|
||||
for (let i = 0; i < reversedOriginals.length && i < newItems.length; i++) {
|
||||
idMap.set(reversedOriginals[i].id, newItems[i].id);
|
||||
}
|
||||
log("ID MAP", "Forecast item Old → New:");
|
||||
for (const [oldId, newId] of idMap) {
|
||||
console.log(` ${oldId} → ${newId}`);
|
||||
}
|
||||
|
||||
// ── 7. Check if procurement products survived PUT ───────────────────────
|
||||
await sleep(300);
|
||||
const afterProc = await cw.get(
|
||||
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,description`,
|
||||
);
|
||||
const newProcIds = new Set(afterProc.data.map((p: any) => p.id));
|
||||
|
||||
log(
|
||||
"PROCUREMENT SURVIVAL",
|
||||
"Checking if procurement product IDs survived PUT...",
|
||||
);
|
||||
const procSurvived = [...oldProcIds].every((id) => newProcIds.has(id));
|
||||
if (procSurvived) {
|
||||
console.log(" ✓ All original procurement product IDs survived PUT.");
|
||||
} else {
|
||||
console.log(" ✗ PUT REGENERATED procurement product IDs!");
|
||||
console.log(` Before: [${[...oldProcIds].join(", ")}]`);
|
||||
console.log(` After: [${[...newProcIds].join(", ")}]`);
|
||||
}
|
||||
|
||||
// Try remap if old IDs still exist
|
||||
let remapOk = true;
|
||||
if (procSurvived) {
|
||||
log("REMAP", "Updating procurement products forecastDetailId...");
|
||||
for (const pp of beforeProc.data) {
|
||||
const oldFdId = pp.forecastDetailId as number;
|
||||
const newFdId = idMap.get(oldFdId);
|
||||
if (!newFdId || newFdId === oldFdId) continue;
|
||||
try {
|
||||
await cw.patch(`/procurement/products/${pp.id}`, [
|
||||
{ op: "replace", path: "forecastDetailId", value: newFdId },
|
||||
]);
|
||||
console.log(
|
||||
` ✓ Proc ${pp.id}: forecastDetailId ${oldFdId} → ${newFdId}`,
|
||||
);
|
||||
} catch (e: any) {
|
||||
remapOk = false;
|
||||
console.log(
|
||||
` ✗ Proc ${pp.id} remap failed: ${e.response?.status} ${JSON.stringify(e.response?.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
remapOk = false;
|
||||
log(
|
||||
"REMAP",
|
||||
"⚠ SKIPPED — procurement products were regenerated by PUT; old IDs no longer exist.",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 8. Verify ───────────────────────────────────────────────────────────
|
||||
await sleep(300);
|
||||
|
||||
// 8a. Verify order (first 4 items)
|
||||
log("VERIFY ORDER", "Expected reverse: Delta, Charlie, Bravo, Alpha");
|
||||
const expectedOrder = ["Delta", "Charlie", "Bravo", "Alpha"];
|
||||
let orderOk = true;
|
||||
for (let i = 0; i < expectedOrder.length; i++) {
|
||||
const actual = newItems[i]?.forecastDescription;
|
||||
const ok = actual === expectedOrder[i];
|
||||
if (!ok) orderOk = false;
|
||||
console.log(
|
||||
` Position ${i}: ${ok ? "✓" : "✗"} expected "${expectedOrder[i]}", got "${actual}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// 8b. Verify prices (by description)
|
||||
log("VERIFY PRICES", "");
|
||||
let pricesOk = true;
|
||||
for (const item of newItems) {
|
||||
const orig = priceSnap.get(item.forecastDescription);
|
||||
if (!orig) continue;
|
||||
if (item.revenue !== orig.rev || item.cost !== orig.cost) {
|
||||
pricesOk = false;
|
||||
console.log(
|
||||
` ✗ "${item.forecastDescription}": rev ${orig.rev}→${item.revenue}, cost ${orig.cost}→${item.cost}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (pricesOk) console.log(" ✓ All prices preserved.");
|
||||
|
||||
// 8c. Verify cancellation data — match by description since IDs may have changed
|
||||
let cancelOk = true;
|
||||
if (procProducts.length > 0) {
|
||||
log(
|
||||
"VERIFY CANCELLATION",
|
||||
"Checking cancellation data on procurement products after PUT...",
|
||||
);
|
||||
const finalProc = await cw.get(
|
||||
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,description`,
|
||||
);
|
||||
|
||||
// Track by procIdsToClean for cleanup
|
||||
for (const p of finalProc.data) {
|
||||
if (!procIdsToClean.includes(p.id)) procIdsToClean.push(p.id);
|
||||
}
|
||||
|
||||
for (const pp of finalProc.data) {
|
||||
const orig = beforeByDesc.get(pp.description);
|
||||
if (!orig) {
|
||||
console.log(
|
||||
` ? Proc ${pp.id} "${pp.description}" — no matching pre-PUT record`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cancelledMatch =
|
||||
pp.cancelledFlag === orig.cancelledFlag &&
|
||||
pp.quantityCancelled === orig.quantityCancelled &&
|
||||
(pp.cancelledReason ?? "") === (orig.cancelledReason ?? "");
|
||||
|
||||
if (!cancelledMatch) {
|
||||
cancelOk = false;
|
||||
console.log(
|
||||
` ✗ Proc ${pp.id} "${pp.description}": CANCELLATION DATA CHANGED\n` +
|
||||
` Before: cancelled=${orig.cancelledFlag} qty=${orig.quantityCancelled} reason="${orig.cancelledReason ?? ""}"\n` +
|
||||
` After: cancelled=${pp.cancelledFlag} qty=${pp.quantityCancelled} reason="${pp.cancelledReason ?? ""}"`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
` ✓ Proc ${pp.id} "${pp.description}": cancelled=${pp.cancelledFlag} qty=${pp.quantityCancelled} reason="${pp.cancelledReason ?? ""}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────────
|
||||
log("SUMMARY", "");
|
||||
console.log(
|
||||
" Order correct: ",
|
||||
orderOk ? "✓ PASS" : "✗ FAIL",
|
||||
);
|
||||
console.log(
|
||||
" Prices preserved: ",
|
||||
pricesOk ? "✓ PASS" : "✗ FAIL",
|
||||
);
|
||||
console.log(
|
||||
" Proc IDs survived PUT: ",
|
||||
procSurvived ? "✓ PASS" : "✗ FAIL",
|
||||
);
|
||||
console.log(
|
||||
" Procurement remap: ",
|
||||
remapOk ? "✓ PASS" : "✗ FAIL (skipped or failed)",
|
||||
);
|
||||
console.log(
|
||||
" Cancellation data preserved:",
|
||||
cancelOk ? "✓ PASS" : "✗ FAIL",
|
||||
);
|
||||
|
||||
const allPass = orderOk && pricesOk && procSurvived && remapOk && cancelOk;
|
||||
log("RESULT", allPass ? "✓ ALL TESTS PASSED" : "✗ SOME TESTS FAILED");
|
||||
} finally {
|
||||
// ── Cleanup ─────────────────────────────────────────────────────────────
|
||||
log("CLEANUP", "Deleting procurement products...");
|
||||
for (const id of procIdsToClean) {
|
||||
try {
|
||||
await cw.delete(`/procurement/products/${id}`);
|
||||
} catch {}
|
||||
}
|
||||
log("CLEANUP", `Deleted ${procIdsToClean.length} procurement products.`);
|
||||
|
||||
log("CLEANUP", `Deleting opportunity ${oppId}...`);
|
||||
try {
|
||||
await cw.delete(`/sales/opportunities/${oppId}`);
|
||||
log("CLEANUP", "✓ Done.");
|
||||
} catch (e: any) {
|
||||
log("CLEANUP", `✗ ${e.response?.status ?? e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n[FATAL]", err.response?.data ?? err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user