Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d935e7180 | |||
| fe71248e88 | |||
| 7411310083 | |||
| 30b408e0db | |||
| d7b374f8ab | |||
| 883b648d5e | |||
| b787120461 | |||
| 1326725995 | |||
| 508fa39835 |
@@ -24,9 +24,10 @@ Keep each layer focused:
|
|||||||
|
|
||||||
## Runtime / tooling
|
## 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)
|
- `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:gen` — `prisma generate`
|
||||||
- `db:push` — `prisma migrate dev --skip-generate`
|
- `db:push` — `prisma migrate dev --skip-generate`
|
||||||
- `utils:dev` — `docker compose -f .docker/docker-compose.yml up --build`
|
- `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
|
## 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`)
|
## 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
|
## Local dev / quick checks
|
||||||
|
|
||||||
- Start dev server: `npm run dev`
|
- Start dev server: `bun run dev`
|
||||||
- Regenerate Prisma client: `npm run db:gen`
|
- Run tests: `bun test`
|
||||||
- Apply DB migrations locally: `npm run db:push`
|
- Regenerate Prisma client: `bun run db:gen`
|
||||||
- Docker dev utilities: `npm run utils:dev`
|
- Apply DB migrations locally: `bun run db:push`
|
||||||
- Generate private keys: `npm run utils:gen_private_keys`
|
- Docker dev utilities: `bun run utils:dev`
|
||||||
- Create admin role: `npm run utils:create_admin_role`
|
- Generate private keys: `bun run utils:gen_private_keys`
|
||||||
- Assign user role: `npm run utils:assign_user_role`
|
- Create admin role: `bun run utils:create_admin_role`
|
||||||
|
- Assign user role: `bun run utils:assign_user_role`
|
||||||
|
|
||||||
## When editing generated or infra files
|
## 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`.
|
2. `PERMISSIONS.md` — human-readable documentation of all permission nodes; must strictly reflect the data in `PermissionNodes.ts`.
|
||||||
3. `API_ROUTES.md` — comprehensive documentation of all API routes, including method, path, auth requirements, permissions, request/response examples.
|
3. `API_ROUTES.md` — comprehensive documentation of all API routes, including method, path, auth requirements, permissions, request/response examples.
|
||||||
|
|
||||||
Always verify that new routes have their required permissions listed in `PermissionNodes.ts`, that `PERMISSIONS.md` tables match the TS file exactly, and that `API_ROUTES.md` includes full documentation for every mounted route. Run through all three files at the end of any route or permission change to catch discrepancies.
|
Additionally, whenever you add, remove, or modify **caching logic** (TTL algorithms, cache key patterns, background refresh mechanics, retry settings, or invalidation behavior), you **must** update:
|
||||||
|
|
||||||
|
4. `CACHING.md` — comprehensive documentation of the Redis-backed opportunity cache, TTL algorithms, background refresh mechanics, retry logic, and debugging tools.
|
||||||
|
|
||||||
|
Always verify that new routes have their required permissions listed in `PermissionNodes.ts`, that `PERMISSIONS.md` tables match the TS file exactly, that `API_ROUTES.md` includes full documentation for every mounted route, and that `CACHING.md` accurately reflects any caching changes. Run through all relevant files at the end of any route, permission, or caching change to catch discrepancies.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,28 @@ on:
|
|||||||
types: [created]
|
types: [created]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate Prisma client
|
||||||
|
run: DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" bunx prisma generate
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: bun test --preload ./tests/setup.ts
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
|
needs: [test]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate Prisma client
|
||||||
|
run: DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" bunx prisma generate
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: bun test --preload ./tests/setup.ts
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
*.jsonl
|
||||||
|
cw-api-logs/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"bun": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+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 |
|
||||||
+4
-1
@@ -67,4 +67,7 @@ RUN bun install --frozen-lockfile
|
|||||||
COPY prisma/ prisma/
|
COPY prisma/ prisma/
|
||||||
COPY prisma.config.ts ./
|
COPY prisma.config.ts ./
|
||||||
|
|
||||||
CMD ["bunx", "prisma", "migrate", "deploy"]
|
COPY prisma/migrate-entrypoint.sh ./prisma/migrate-entrypoint.sh
|
||||||
|
RUN chmod +x prisma/migrate-entrypoint.sh
|
||||||
|
|
||||||
|
CMD ["sh", "prisma/migrate-entrypoint.sh"]
|
||||||
@@ -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
|
### Procurement Permissions
|
||||||
|
|
||||||
| Permission Node | Description | Used In | Dependencies |
|
| 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` | 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.inventory.refresh` | Refresh on-hand inventory for a catalog item from ConnectWise | [src/api/procurement/[id]/refreshInventory.ts](src/api/procurement/[id]/refreshInventory.ts) | `procurement.catalog.fetch` |
|
||||||
| `procurement.catalog.link` | Link or unlink catalog items to each other | [src/api/procurement/[id]/link.ts](src/api/procurement/[id]/link.ts), [src/api/procurement/[id]/unlink.ts](src/api/procurement/[id]/unlink.ts) | `procurement.catalog.fetch` |
|
| `procurement.catalog.link` | Link or unlink catalog items to each other | [src/api/procurement/[id]/link.ts](src/api/procurement/[id]/link.ts), [src/api/procurement/[id]/unlink.ts](src/api/procurement/[id]/unlink.ts) | `procurement.catalog.fetch` |
|
||||||
|
|
||||||
### Sales Permissions
|
### 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 |
|
| 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` | 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) 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.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.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
|
### 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` | 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` |
|
| `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
|
## Permission Issuers
|
||||||
|
|
||||||
Permissions can be issued by different sources:
|
Permissions can be issued by different sources:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"cuid": "^3.0.0",
|
"cuid": "^3.0.0",
|
||||||
"hono": "^4.11.5",
|
"hono": "^4.11.5",
|
||||||
|
"ioredis": "^5.10.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"keypair": "^1.0.4",
|
"keypair": "^1.0.4",
|
||||||
"prisma": "^7.3.0",
|
"prisma": "^7.3.0",
|
||||||
@@ -57,6 +58,8 @@
|
|||||||
|
|
||||||
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
|
||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
@@ -241,8 +248,12 @@
|
|||||||
|
|
||||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||||
|
|
||||||
|
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||||
|
|
||||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
"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.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||||
|
|
||||||
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
|
"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=="],
|
"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=="],
|
"regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="],
|
||||||
|
|
||||||
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="],
|
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="],
|
||||||
@@ -363,6 +378,8 @@
|
|||||||
|
|
||||||
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
|
"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=="],
|
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||||
|
|
||||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
"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',
|
email: 'email',
|
||||||
emailVerified: 'emailVerified',
|
emailVerified: 'emailVerified',
|
||||||
image: 'image',
|
image: 'image',
|
||||||
|
cwIdentifier: 'cwIdentifier',
|
||||||
userId: 'userId',
|
userId: 'userId',
|
||||||
token: 'token',
|
token: 'token',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
@@ -1266,6 +1267,10 @@ export const CatalogItemScalarFieldEnum = {
|
|||||||
description: 'description',
|
description: 'description',
|
||||||
customerDescription: 'customerDescription',
|
customerDescription: 'customerDescription',
|
||||||
internalNotes: 'internalNotes',
|
internalNotes: 'internalNotes',
|
||||||
|
category: 'category',
|
||||||
|
categoryCwId: 'categoryCwId',
|
||||||
|
subcategory: 'subcategory',
|
||||||
|
subcategoryCwId: 'subcategoryCwId',
|
||||||
manufacturer: 'manufacturer',
|
manufacturer: 'manufacturer',
|
||||||
manufactureCwId: 'manufactureCwId',
|
manufactureCwId: 'manufactureCwId',
|
||||||
partNumber: 'partNumber',
|
partNumber: 'partNumber',
|
||||||
@@ -1329,6 +1334,7 @@ export const OpportunityScalarFieldEnum = {
|
|||||||
closedByName: 'closedByName',
|
closedByName: 'closedByName',
|
||||||
closedByCwId: 'closedByCwId',
|
closedByCwId: 'closedByCwId',
|
||||||
companyId: 'companyId',
|
companyId: 'companyId',
|
||||||
|
productSequence: 'productSequence',
|
||||||
cwLastUpdated: 'cwLastUpdated',
|
cwLastUpdated: 'cwLastUpdated',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export const UserScalarFieldEnum = {
|
|||||||
email: 'email',
|
email: 'email',
|
||||||
emailVerified: 'emailVerified',
|
emailVerified: 'emailVerified',
|
||||||
image: 'image',
|
image: 'image',
|
||||||
|
cwIdentifier: 'cwIdentifier',
|
||||||
userId: 'userId',
|
userId: 'userId',
|
||||||
token: 'token',
|
token: 'token',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
@@ -153,6 +154,10 @@ export const CatalogItemScalarFieldEnum = {
|
|||||||
description: 'description',
|
description: 'description',
|
||||||
customerDescription: 'customerDescription',
|
customerDescription: 'customerDescription',
|
||||||
internalNotes: 'internalNotes',
|
internalNotes: 'internalNotes',
|
||||||
|
category: 'category',
|
||||||
|
categoryCwId: 'categoryCwId',
|
||||||
|
subcategory: 'subcategory',
|
||||||
|
subcategoryCwId: 'subcategoryCwId',
|
||||||
manufacturer: 'manufacturer',
|
manufacturer: 'manufacturer',
|
||||||
manufactureCwId: 'manufactureCwId',
|
manufactureCwId: 'manufactureCwId',
|
||||||
partNumber: 'partNumber',
|
partNumber: 'partNumber',
|
||||||
@@ -216,6 +221,7 @@ export const OpportunityScalarFieldEnum = {
|
|||||||
closedByName: 'closedByName',
|
closedByName: 'closedByName',
|
||||||
closedByCwId: 'closedByCwId',
|
closedByCwId: 'closedByCwId',
|
||||||
companyId: 'companyId',
|
companyId: 'companyId',
|
||||||
|
productSequence: 'productSequence',
|
||||||
cwLastUpdated: 'cwLastUpdated',
|
cwLastUpdated: 'cwLastUpdated',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export type AggregateCatalogItem = {
|
|||||||
|
|
||||||
export type CatalogItemAvgAggregateOutputType = {
|
export type CatalogItemAvgAggregateOutputType = {
|
||||||
cwCatalogId: number | null
|
cwCatalogId: number | null
|
||||||
|
categoryCwId: number | null
|
||||||
|
subcategoryCwId: number | null
|
||||||
manufactureCwId: number | null
|
manufactureCwId: number | null
|
||||||
vendorCwId: number | null
|
vendorCwId: number | null
|
||||||
price: number | null
|
price: number | null
|
||||||
@@ -37,6 +39,8 @@ export type CatalogItemAvgAggregateOutputType = {
|
|||||||
|
|
||||||
export type CatalogItemSumAggregateOutputType = {
|
export type CatalogItemSumAggregateOutputType = {
|
||||||
cwCatalogId: number | null
|
cwCatalogId: number | null
|
||||||
|
categoryCwId: number | null
|
||||||
|
subcategoryCwId: number | null
|
||||||
manufactureCwId: number | null
|
manufactureCwId: number | null
|
||||||
vendorCwId: number | null
|
vendorCwId: number | null
|
||||||
price: number | null
|
price: number | null
|
||||||
@@ -52,6 +56,10 @@ export type CatalogItemMinAggregateOutputType = {
|
|||||||
description: string | null
|
description: string | null
|
||||||
customerDescription: string | null
|
customerDescription: string | null
|
||||||
internalNotes: string | null
|
internalNotes: string | null
|
||||||
|
category: string | null
|
||||||
|
categoryCwId: number | null
|
||||||
|
subcategory: string | null
|
||||||
|
subcategoryCwId: number | null
|
||||||
manufacturer: string | null
|
manufacturer: string | null
|
||||||
manufactureCwId: number | null
|
manufactureCwId: number | null
|
||||||
partNumber: string | null
|
partNumber: string | null
|
||||||
@@ -76,6 +84,10 @@ export type CatalogItemMaxAggregateOutputType = {
|
|||||||
description: string | null
|
description: string | null
|
||||||
customerDescription: string | null
|
customerDescription: string | null
|
||||||
internalNotes: string | null
|
internalNotes: string | null
|
||||||
|
category: string | null
|
||||||
|
categoryCwId: number | null
|
||||||
|
subcategory: string | null
|
||||||
|
subcategoryCwId: number | null
|
||||||
manufacturer: string | null
|
manufacturer: string | null
|
||||||
manufactureCwId: number | null
|
manufactureCwId: number | null
|
||||||
partNumber: string | null
|
partNumber: string | null
|
||||||
@@ -100,6 +112,10 @@ export type CatalogItemCountAggregateOutputType = {
|
|||||||
description: number
|
description: number
|
||||||
customerDescription: number
|
customerDescription: number
|
||||||
internalNotes: number
|
internalNotes: number
|
||||||
|
category: number
|
||||||
|
categoryCwId: number
|
||||||
|
subcategory: number
|
||||||
|
subcategoryCwId: number
|
||||||
manufacturer: number
|
manufacturer: number
|
||||||
manufactureCwId: number
|
manufactureCwId: number
|
||||||
partNumber: number
|
partNumber: number
|
||||||
@@ -120,6 +136,8 @@ export type CatalogItemCountAggregateOutputType = {
|
|||||||
|
|
||||||
export type CatalogItemAvgAggregateInputType = {
|
export type CatalogItemAvgAggregateInputType = {
|
||||||
cwCatalogId?: true
|
cwCatalogId?: true
|
||||||
|
categoryCwId?: true
|
||||||
|
subcategoryCwId?: true
|
||||||
manufactureCwId?: true
|
manufactureCwId?: true
|
||||||
vendorCwId?: true
|
vendorCwId?: true
|
||||||
price?: true
|
price?: true
|
||||||
@@ -129,6 +147,8 @@ export type CatalogItemAvgAggregateInputType = {
|
|||||||
|
|
||||||
export type CatalogItemSumAggregateInputType = {
|
export type CatalogItemSumAggregateInputType = {
|
||||||
cwCatalogId?: true
|
cwCatalogId?: true
|
||||||
|
categoryCwId?: true
|
||||||
|
subcategoryCwId?: true
|
||||||
manufactureCwId?: true
|
manufactureCwId?: true
|
||||||
vendorCwId?: true
|
vendorCwId?: true
|
||||||
price?: true
|
price?: true
|
||||||
@@ -144,6 +164,10 @@ export type CatalogItemMinAggregateInputType = {
|
|||||||
description?: true
|
description?: true
|
||||||
customerDescription?: true
|
customerDescription?: true
|
||||||
internalNotes?: true
|
internalNotes?: true
|
||||||
|
category?: true
|
||||||
|
categoryCwId?: true
|
||||||
|
subcategory?: true
|
||||||
|
subcategoryCwId?: true
|
||||||
manufacturer?: true
|
manufacturer?: true
|
||||||
manufactureCwId?: true
|
manufactureCwId?: true
|
||||||
partNumber?: true
|
partNumber?: true
|
||||||
@@ -168,6 +192,10 @@ export type CatalogItemMaxAggregateInputType = {
|
|||||||
description?: true
|
description?: true
|
||||||
customerDescription?: true
|
customerDescription?: true
|
||||||
internalNotes?: true
|
internalNotes?: true
|
||||||
|
category?: true
|
||||||
|
categoryCwId?: true
|
||||||
|
subcategory?: true
|
||||||
|
subcategoryCwId?: true
|
||||||
manufacturer?: true
|
manufacturer?: true
|
||||||
manufactureCwId?: true
|
manufactureCwId?: true
|
||||||
partNumber?: true
|
partNumber?: true
|
||||||
@@ -192,6 +220,10 @@ export type CatalogItemCountAggregateInputType = {
|
|||||||
description?: true
|
description?: true
|
||||||
customerDescription?: true
|
customerDescription?: true
|
||||||
internalNotes?: true
|
internalNotes?: true
|
||||||
|
category?: true
|
||||||
|
categoryCwId?: true
|
||||||
|
subcategory?: true
|
||||||
|
subcategoryCwId?: true
|
||||||
manufacturer?: true
|
manufacturer?: true
|
||||||
manufactureCwId?: true
|
manufactureCwId?: true
|
||||||
partNumber?: true
|
partNumber?: true
|
||||||
@@ -303,6 +335,10 @@ export type CatalogItemGroupByOutputType = {
|
|||||||
description: string | null
|
description: string | null
|
||||||
customerDescription: string | null
|
customerDescription: string | null
|
||||||
internalNotes: string | null
|
internalNotes: string | null
|
||||||
|
category: string | null
|
||||||
|
categoryCwId: number | null
|
||||||
|
subcategory: string | null
|
||||||
|
subcategoryCwId: number | null
|
||||||
manufacturer: string | null
|
manufacturer: string | null
|
||||||
manufactureCwId: number | null
|
manufactureCwId: number | null
|
||||||
partNumber: string | null
|
partNumber: string | null
|
||||||
@@ -350,6 +386,10 @@ export type CatalogItemWhereInput = {
|
|||||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||||
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
@@ -376,6 +416,10 @@ export type CatalogItemOrderByWithRelationInput = {
|
|||||||
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
manufactureCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
manufactureCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
partNumber?: Prisma.SortOrderInput | Prisma.SortOrder
|
partNumber?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
@@ -405,6 +449,10 @@ export type CatalogItemWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||||
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
@@ -431,6 +479,10 @@ export type CatalogItemOrderByWithAggregationInput = {
|
|||||||
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
manufactureCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
manufactureCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
partNumber?: Prisma.SortOrderInput | Prisma.SortOrder
|
partNumber?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
@@ -463,6 +515,10 @@ export type CatalogItemScalarWhereWithAggregatesInput = {
|
|||||||
description?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
description?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||||
customerDescription?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
customerDescription?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||||
manufactureCwId?: Prisma.IntNullableWithAggregatesFilter<"CatalogItem"> | number | null
|
manufactureCwId?: Prisma.IntNullableWithAggregatesFilter<"CatalogItem"> | number | null
|
||||||
partNumber?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
partNumber?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||||
@@ -487,6 +543,10 @@ export type CatalogItemCreateInput = {
|
|||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
internalNotes?: string | null
|
internalNotes?: string | null
|
||||||
|
category?: string | null
|
||||||
|
categoryCwId?: number | null
|
||||||
|
subcategory?: string | null
|
||||||
|
subcategoryCwId?: number | null
|
||||||
manufacturer?: string | null
|
manufacturer?: string | null
|
||||||
manufactureCwId?: number | null
|
manufactureCwId?: number | null
|
||||||
partNumber?: string | null
|
partNumber?: string | null
|
||||||
@@ -513,6 +573,10 @@ export type CatalogItemUncheckedCreateInput = {
|
|||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
internalNotes?: string | null
|
internalNotes?: string | null
|
||||||
|
category?: string | null
|
||||||
|
categoryCwId?: number | null
|
||||||
|
subcategory?: string | null
|
||||||
|
subcategoryCwId?: number | null
|
||||||
manufacturer?: string | null
|
manufacturer?: string | null
|
||||||
manufactureCwId?: number | null
|
manufactureCwId?: number | null
|
||||||
partNumber?: string | null
|
partNumber?: string | null
|
||||||
@@ -539,6 +603,10 @@ export type CatalogItemUpdateInput = {
|
|||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -565,6 +633,10 @@ export type CatalogItemUncheckedUpdateInput = {
|
|||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -591,6 +663,10 @@ export type CatalogItemCreateManyInput = {
|
|||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
internalNotes?: string | null
|
internalNotes?: string | null
|
||||||
|
category?: string | null
|
||||||
|
categoryCwId?: number | null
|
||||||
|
subcategory?: string | null
|
||||||
|
subcategoryCwId?: number | null
|
||||||
manufacturer?: string | null
|
manufacturer?: string | null
|
||||||
manufactureCwId?: number | null
|
manufactureCwId?: number | null
|
||||||
partNumber?: string | null
|
partNumber?: string | null
|
||||||
@@ -615,6 +691,10 @@ export type CatalogItemUpdateManyMutationInput = {
|
|||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -639,6 +719,10 @@ export type CatalogItemUncheckedUpdateManyInput = {
|
|||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -673,6 +757,10 @@ export type CatalogItemCountOrderByAggregateInput = {
|
|||||||
description?: Prisma.SortOrder
|
description?: Prisma.SortOrder
|
||||||
customerDescription?: Prisma.SortOrder
|
customerDescription?: Prisma.SortOrder
|
||||||
internalNotes?: Prisma.SortOrder
|
internalNotes?: Prisma.SortOrder
|
||||||
|
category?: Prisma.SortOrder
|
||||||
|
categoryCwId?: Prisma.SortOrder
|
||||||
|
subcategory?: Prisma.SortOrder
|
||||||
|
subcategoryCwId?: Prisma.SortOrder
|
||||||
manufacturer?: Prisma.SortOrder
|
manufacturer?: Prisma.SortOrder
|
||||||
manufactureCwId?: Prisma.SortOrder
|
manufactureCwId?: Prisma.SortOrder
|
||||||
partNumber?: Prisma.SortOrder
|
partNumber?: Prisma.SortOrder
|
||||||
@@ -691,6 +779,8 @@ export type CatalogItemCountOrderByAggregateInput = {
|
|||||||
|
|
||||||
export type CatalogItemAvgOrderByAggregateInput = {
|
export type CatalogItemAvgOrderByAggregateInput = {
|
||||||
cwCatalogId?: Prisma.SortOrder
|
cwCatalogId?: Prisma.SortOrder
|
||||||
|
categoryCwId?: Prisma.SortOrder
|
||||||
|
subcategoryCwId?: Prisma.SortOrder
|
||||||
manufactureCwId?: Prisma.SortOrder
|
manufactureCwId?: Prisma.SortOrder
|
||||||
vendorCwId?: Prisma.SortOrder
|
vendorCwId?: Prisma.SortOrder
|
||||||
price?: Prisma.SortOrder
|
price?: Prisma.SortOrder
|
||||||
@@ -706,6 +796,10 @@ export type CatalogItemMaxOrderByAggregateInput = {
|
|||||||
description?: Prisma.SortOrder
|
description?: Prisma.SortOrder
|
||||||
customerDescription?: Prisma.SortOrder
|
customerDescription?: Prisma.SortOrder
|
||||||
internalNotes?: Prisma.SortOrder
|
internalNotes?: Prisma.SortOrder
|
||||||
|
category?: Prisma.SortOrder
|
||||||
|
categoryCwId?: Prisma.SortOrder
|
||||||
|
subcategory?: Prisma.SortOrder
|
||||||
|
subcategoryCwId?: Prisma.SortOrder
|
||||||
manufacturer?: Prisma.SortOrder
|
manufacturer?: Prisma.SortOrder
|
||||||
manufactureCwId?: Prisma.SortOrder
|
manufactureCwId?: Prisma.SortOrder
|
||||||
partNumber?: Prisma.SortOrder
|
partNumber?: Prisma.SortOrder
|
||||||
@@ -730,6 +824,10 @@ export type CatalogItemMinOrderByAggregateInput = {
|
|||||||
description?: Prisma.SortOrder
|
description?: Prisma.SortOrder
|
||||||
customerDescription?: Prisma.SortOrder
|
customerDescription?: Prisma.SortOrder
|
||||||
internalNotes?: Prisma.SortOrder
|
internalNotes?: Prisma.SortOrder
|
||||||
|
category?: Prisma.SortOrder
|
||||||
|
categoryCwId?: Prisma.SortOrder
|
||||||
|
subcategory?: Prisma.SortOrder
|
||||||
|
subcategoryCwId?: Prisma.SortOrder
|
||||||
manufacturer?: Prisma.SortOrder
|
manufacturer?: Prisma.SortOrder
|
||||||
manufactureCwId?: Prisma.SortOrder
|
manufactureCwId?: Prisma.SortOrder
|
||||||
partNumber?: Prisma.SortOrder
|
partNumber?: Prisma.SortOrder
|
||||||
@@ -748,6 +846,8 @@ export type CatalogItemMinOrderByAggregateInput = {
|
|||||||
|
|
||||||
export type CatalogItemSumOrderByAggregateInput = {
|
export type CatalogItemSumOrderByAggregateInput = {
|
||||||
cwCatalogId?: Prisma.SortOrder
|
cwCatalogId?: Prisma.SortOrder
|
||||||
|
categoryCwId?: Prisma.SortOrder
|
||||||
|
subcategoryCwId?: Prisma.SortOrder
|
||||||
manufactureCwId?: Prisma.SortOrder
|
manufactureCwId?: Prisma.SortOrder
|
||||||
vendorCwId?: Prisma.SortOrder
|
vendorCwId?: Prisma.SortOrder
|
||||||
price?: Prisma.SortOrder
|
price?: Prisma.SortOrder
|
||||||
@@ -855,6 +955,10 @@ export type CatalogItemCreateWithoutLinkedToInput = {
|
|||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
internalNotes?: string | null
|
internalNotes?: string | null
|
||||||
|
category?: string | null
|
||||||
|
categoryCwId?: number | null
|
||||||
|
subcategory?: string | null
|
||||||
|
subcategoryCwId?: number | null
|
||||||
manufacturer?: string | null
|
manufacturer?: string | null
|
||||||
manufactureCwId?: number | null
|
manufactureCwId?: number | null
|
||||||
partNumber?: string | null
|
partNumber?: string | null
|
||||||
@@ -880,6 +984,10 @@ export type CatalogItemUncheckedCreateWithoutLinkedToInput = {
|
|||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
internalNotes?: string | null
|
internalNotes?: string | null
|
||||||
|
category?: string | null
|
||||||
|
categoryCwId?: number | null
|
||||||
|
subcategory?: string | null
|
||||||
|
subcategoryCwId?: number | null
|
||||||
manufacturer?: string | null
|
manufacturer?: string | null
|
||||||
manufactureCwId?: number | null
|
manufactureCwId?: number | null
|
||||||
partNumber?: string | null
|
partNumber?: string | null
|
||||||
@@ -910,6 +1018,10 @@ export type CatalogItemCreateWithoutLinkedItemsInput = {
|
|||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
internalNotes?: string | null
|
internalNotes?: string | null
|
||||||
|
category?: string | null
|
||||||
|
categoryCwId?: number | null
|
||||||
|
subcategory?: string | null
|
||||||
|
subcategoryCwId?: number | null
|
||||||
manufacturer?: string | null
|
manufacturer?: string | null
|
||||||
manufactureCwId?: number | null
|
manufactureCwId?: number | null
|
||||||
partNumber?: string | null
|
partNumber?: string | null
|
||||||
@@ -935,6 +1047,10 @@ export type CatalogItemUncheckedCreateWithoutLinkedItemsInput = {
|
|||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
internalNotes?: string | null
|
internalNotes?: string | null
|
||||||
|
category?: string | null
|
||||||
|
categoryCwId?: number | null
|
||||||
|
subcategory?: string | null
|
||||||
|
subcategoryCwId?: number | null
|
||||||
manufacturer?: string | null
|
manufacturer?: string | null
|
||||||
manufactureCwId?: number | null
|
manufactureCwId?: number | null
|
||||||
partNumber?: string | null
|
partNumber?: string | null
|
||||||
@@ -984,6 +1100,10 @@ export type CatalogItemScalarWhereInput = {
|
|||||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||||
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
@@ -1024,6 +1144,10 @@ export type CatalogItemUpdateWithoutLinkedToInput = {
|
|||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -1049,6 +1173,10 @@ export type CatalogItemUncheckedUpdateWithoutLinkedToInput = {
|
|||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -1074,6 +1202,10 @@ export type CatalogItemUncheckedUpdateManyWithoutLinkedToInput = {
|
|||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -1098,6 +1230,10 @@ export type CatalogItemUpdateWithoutLinkedItemsInput = {
|
|||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -1123,6 +1259,10 @@ export type CatalogItemUncheckedUpdateWithoutLinkedItemsInput = {
|
|||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -1148,6 +1288,10 @@ export type CatalogItemUncheckedUpdateManyWithoutLinkedItemsInput = {
|
|||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
internalNotes?: 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
|
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -1212,6 +1356,10 @@ export type CatalogItemSelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
|||||||
description?: boolean
|
description?: boolean
|
||||||
customerDescription?: boolean
|
customerDescription?: boolean
|
||||||
internalNotes?: boolean
|
internalNotes?: boolean
|
||||||
|
category?: boolean
|
||||||
|
categoryCwId?: boolean
|
||||||
|
subcategory?: boolean
|
||||||
|
subcategoryCwId?: boolean
|
||||||
manufacturer?: boolean
|
manufacturer?: boolean
|
||||||
manufactureCwId?: boolean
|
manufactureCwId?: boolean
|
||||||
partNumber?: boolean
|
partNumber?: boolean
|
||||||
@@ -1239,6 +1387,10 @@ export type CatalogItemSelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
|||||||
description?: boolean
|
description?: boolean
|
||||||
customerDescription?: boolean
|
customerDescription?: boolean
|
||||||
internalNotes?: boolean
|
internalNotes?: boolean
|
||||||
|
category?: boolean
|
||||||
|
categoryCwId?: boolean
|
||||||
|
subcategory?: boolean
|
||||||
|
subcategoryCwId?: boolean
|
||||||
manufacturer?: boolean
|
manufacturer?: boolean
|
||||||
manufactureCwId?: boolean
|
manufactureCwId?: boolean
|
||||||
partNumber?: boolean
|
partNumber?: boolean
|
||||||
@@ -1263,6 +1415,10 @@ export type CatalogItemSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
|||||||
description?: boolean
|
description?: boolean
|
||||||
customerDescription?: boolean
|
customerDescription?: boolean
|
||||||
internalNotes?: boolean
|
internalNotes?: boolean
|
||||||
|
category?: boolean
|
||||||
|
categoryCwId?: boolean
|
||||||
|
subcategory?: boolean
|
||||||
|
subcategoryCwId?: boolean
|
||||||
manufacturer?: boolean
|
manufacturer?: boolean
|
||||||
manufactureCwId?: boolean
|
manufactureCwId?: boolean
|
||||||
partNumber?: boolean
|
partNumber?: boolean
|
||||||
@@ -1287,6 +1443,10 @@ export type CatalogItemSelectScalar = {
|
|||||||
description?: boolean
|
description?: boolean
|
||||||
customerDescription?: boolean
|
customerDescription?: boolean
|
||||||
internalNotes?: boolean
|
internalNotes?: boolean
|
||||||
|
category?: boolean
|
||||||
|
categoryCwId?: boolean
|
||||||
|
subcategory?: boolean
|
||||||
|
subcategoryCwId?: boolean
|
||||||
manufacturer?: boolean
|
manufacturer?: boolean
|
||||||
manufactureCwId?: boolean
|
manufactureCwId?: boolean
|
||||||
partNumber?: boolean
|
partNumber?: boolean
|
||||||
@@ -1303,7 +1463,7 @@ export type CatalogItemSelectScalar = {
|
|||||||
updatedAt?: boolean
|
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> = {
|
export type CatalogItemInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
linkedItems?: boolean | Prisma.CatalogItem$linkedItemsArgs<ExtArgs>
|
linkedItems?: boolean | Prisma.CatalogItem$linkedItemsArgs<ExtArgs>
|
||||||
linkedTo?: boolean | Prisma.CatalogItem$linkedToArgs<ExtArgs>
|
linkedTo?: boolean | Prisma.CatalogItem$linkedToArgs<ExtArgs>
|
||||||
@@ -1326,6 +1486,10 @@ export type $CatalogItemPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
|||||||
description: string | null
|
description: string | null
|
||||||
customerDescription: string | null
|
customerDescription: string | null
|
||||||
internalNotes: string | null
|
internalNotes: string | null
|
||||||
|
category: string | null
|
||||||
|
categoryCwId: number | null
|
||||||
|
subcategory: string | null
|
||||||
|
subcategoryCwId: number | null
|
||||||
manufacturer: string | null
|
manufacturer: string | null
|
||||||
manufactureCwId: number | null
|
manufactureCwId: number | null
|
||||||
partNumber: string | null
|
partNumber: string | null
|
||||||
@@ -1772,6 +1936,10 @@ export interface CatalogItemFieldRefs {
|
|||||||
readonly description: Prisma.FieldRef<"CatalogItem", 'String'>
|
readonly description: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||||
readonly customerDescription: Prisma.FieldRef<"CatalogItem", 'String'>
|
readonly customerDescription: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||||
readonly internalNotes: 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 manufacturer: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||||
readonly manufactureCwId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
readonly manufactureCwId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
||||||
readonly partNumber: Prisma.FieldRef<"CatalogItem", 'String'>
|
readonly partNumber: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export type OpportunityAvgAggregateOutputType = {
|
|||||||
locationCwId: number | null
|
locationCwId: number | null
|
||||||
departmentCwId: number | null
|
departmentCwId: number | null
|
||||||
closedByCwId: number | null
|
closedByCwId: number | null
|
||||||
|
productSequence: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpportunitySumAggregateOutputType = {
|
export type OpportunitySumAggregateOutputType = {
|
||||||
@@ -62,6 +63,7 @@ export type OpportunitySumAggregateOutputType = {
|
|||||||
locationCwId: number | null
|
locationCwId: number | null
|
||||||
departmentCwId: number | null
|
departmentCwId: number | null
|
||||||
closedByCwId: number | null
|
closedByCwId: number | null
|
||||||
|
productSequence: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpportunityMinAggregateOutputType = {
|
export type OpportunityMinAggregateOutputType = {
|
||||||
@@ -206,6 +208,7 @@ export type OpportunityCountAggregateOutputType = {
|
|||||||
closedByName: number
|
closedByName: number
|
||||||
closedByCwId: number
|
closedByCwId: number
|
||||||
companyId: number
|
companyId: number
|
||||||
|
productSequence: number
|
||||||
cwLastUpdated: number
|
cwLastUpdated: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
@@ -230,6 +233,7 @@ export type OpportunityAvgAggregateInputType = {
|
|||||||
locationCwId?: true
|
locationCwId?: true
|
||||||
departmentCwId?: true
|
departmentCwId?: true
|
||||||
closedByCwId?: true
|
closedByCwId?: true
|
||||||
|
productSequence?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpportunitySumAggregateInputType = {
|
export type OpportunitySumAggregateInputType = {
|
||||||
@@ -249,6 +253,7 @@ export type OpportunitySumAggregateInputType = {
|
|||||||
locationCwId?: true
|
locationCwId?: true
|
||||||
departmentCwId?: true
|
departmentCwId?: true
|
||||||
closedByCwId?: true
|
closedByCwId?: true
|
||||||
|
productSequence?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpportunityMinAggregateInputType = {
|
export type OpportunityMinAggregateInputType = {
|
||||||
@@ -393,6 +398,7 @@ export type OpportunityCountAggregateInputType = {
|
|||||||
closedByName?: true
|
closedByName?: true
|
||||||
closedByCwId?: true
|
closedByCwId?: true
|
||||||
companyId?: true
|
companyId?: true
|
||||||
|
productSequence?: true
|
||||||
cwLastUpdated?: true
|
cwLastUpdated?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
@@ -529,6 +535,7 @@ export type OpportunityGroupByOutputType = {
|
|||||||
closedByName: string | null
|
closedByName: string | null
|
||||||
closedByCwId: number | null
|
closedByCwId: number | null
|
||||||
companyId: string | null
|
companyId: string | null
|
||||||
|
productSequence: number[]
|
||||||
cwLastUpdated: Date | null
|
cwLastUpdated: Date | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
@@ -601,6 +608,7 @@ export type OpportunityWhereInput = {
|
|||||||
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||||
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||||
|
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
@@ -651,6 +659,7 @@ export type OpportunityOrderByWithRelationInput = {
|
|||||||
closedByName?: Prisma.SortOrderInput | Prisma.SortOrder
|
closedByName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
productSequence?: Prisma.SortOrder
|
||||||
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
@@ -704,6 +713,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||||
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||||
|
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
@@ -754,6 +764,7 @@ export type OpportunityOrderByWithAggregationInput = {
|
|||||||
closedByName?: Prisma.SortOrderInput | Prisma.SortOrder
|
closedByName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
productSequence?: Prisma.SortOrder
|
||||||
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
@@ -811,6 +822,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
|
|||||||
closedByName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
closedByName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||||
closedByCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null
|
closedByCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null
|
||||||
companyId?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
companyId?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||||
|
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||||
cwLastUpdated?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
|
cwLastUpdated?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
||||||
@@ -859,6 +871,7 @@ export type OpportunityCreateInput = {
|
|||||||
closedFlag?: boolean
|
closedFlag?: boolean
|
||||||
closedByName?: string | null
|
closedByName?: string | null
|
||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -909,6 +922,7 @@ export type OpportunityUncheckedCreateInput = {
|
|||||||
closedByName?: string | null
|
closedByName?: string | null
|
||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
companyId?: string | null
|
companyId?: string | null
|
||||||
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -957,6 +971,7 @@ export type OpportunityUpdateInput = {
|
|||||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -1007,6 +1022,7 @@ export type OpportunityUncheckedUpdateInput = {
|
|||||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -1056,6 +1072,7 @@ export type OpportunityCreateManyInput = {
|
|||||||
closedByName?: string | null
|
closedByName?: string | null
|
||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
companyId?: string | null
|
companyId?: string | null
|
||||||
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -1104,6 +1121,7 @@ export type OpportunityUpdateManyMutationInput = {
|
|||||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -1153,6 +1171,7 @@ export type OpportunityUncheckedUpdateManyInput = {
|
|||||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -1168,6 +1187,14 @@ export type OpportunityOrderByRelationAggregateInput = {
|
|||||||
_count?: Prisma.SortOrder
|
_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 = {
|
export type OpportunityCountOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
cwOpportunityId?: Prisma.SortOrder
|
cwOpportunityId?: Prisma.SortOrder
|
||||||
@@ -1212,6 +1239,7 @@ export type OpportunityCountOrderByAggregateInput = {
|
|||||||
closedByName?: Prisma.SortOrder
|
closedByName?: Prisma.SortOrder
|
||||||
closedByCwId?: Prisma.SortOrder
|
closedByCwId?: Prisma.SortOrder
|
||||||
companyId?: Prisma.SortOrder
|
companyId?: Prisma.SortOrder
|
||||||
|
productSequence?: Prisma.SortOrder
|
||||||
cwLastUpdated?: Prisma.SortOrder
|
cwLastUpdated?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
@@ -1234,6 +1262,7 @@ export type OpportunityAvgOrderByAggregateInput = {
|
|||||||
locationCwId?: Prisma.SortOrder
|
locationCwId?: Prisma.SortOrder
|
||||||
departmentCwId?: Prisma.SortOrder
|
departmentCwId?: Prisma.SortOrder
|
||||||
closedByCwId?: Prisma.SortOrder
|
closedByCwId?: Prisma.SortOrder
|
||||||
|
productSequence?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpportunityMaxOrderByAggregateInput = {
|
export type OpportunityMaxOrderByAggregateInput = {
|
||||||
@@ -1351,6 +1380,7 @@ export type OpportunitySumOrderByAggregateInput = {
|
|||||||
locationCwId?: Prisma.SortOrder
|
locationCwId?: Prisma.SortOrder
|
||||||
departmentCwId?: Prisma.SortOrder
|
departmentCwId?: Prisma.SortOrder
|
||||||
closedByCwId?: Prisma.SortOrder
|
closedByCwId?: Prisma.SortOrder
|
||||||
|
productSequence?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpportunityCreateNestedManyWithoutCompanyInput = {
|
export type OpportunityCreateNestedManyWithoutCompanyInput = {
|
||||||
@@ -1395,6 +1425,15 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyNestedInput = {
|
|||||||
deleteMany?: Prisma.OpportunityScalarWhereInput | Prisma.OpportunityScalarWhereInput[]
|
deleteMany?: Prisma.OpportunityScalarWhereInput | Prisma.OpportunityScalarWhereInput[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OpportunityCreateproductSequenceInput = {
|
||||||
|
set: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpportunityUpdateproductSequenceInput = {
|
||||||
|
set?: number[]
|
||||||
|
push?: number | number[]
|
||||||
|
}
|
||||||
|
|
||||||
export type OpportunityCreateWithoutCompanyInput = {
|
export type OpportunityCreateWithoutCompanyInput = {
|
||||||
id?: string
|
id?: string
|
||||||
cwOpportunityId: number
|
cwOpportunityId: number
|
||||||
@@ -1438,6 +1477,7 @@ export type OpportunityCreateWithoutCompanyInput = {
|
|||||||
closedFlag?: boolean
|
closedFlag?: boolean
|
||||||
closedByName?: string | null
|
closedByName?: string | null
|
||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -1486,6 +1526,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
|
|||||||
closedFlag?: boolean
|
closedFlag?: boolean
|
||||||
closedByName?: string | null
|
closedByName?: string | null
|
||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -1564,6 +1605,7 @@ export type OpportunityScalarWhereInput = {
|
|||||||
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||||
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||||
|
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
@@ -1612,6 +1654,7 @@ export type OpportunityCreateManyCompanyInput = {
|
|||||||
closedFlag?: boolean
|
closedFlag?: boolean
|
||||||
closedByName?: string | null
|
closedByName?: string | null
|
||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
@@ -1660,6 +1703,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
|
|||||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -1708,6 +1752,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
|
|||||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -1756,6 +1801,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
|
|||||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -1807,6 +1853,7 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
|||||||
closedByName?: boolean
|
closedByName?: boolean
|
||||||
closedByCwId?: boolean
|
closedByCwId?: boolean
|
||||||
companyId?: boolean
|
companyId?: boolean
|
||||||
|
productSequence?: boolean
|
||||||
cwLastUpdated?: boolean
|
cwLastUpdated?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
@@ -1857,6 +1904,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
|||||||
closedByName?: boolean
|
closedByName?: boolean
|
||||||
closedByCwId?: boolean
|
closedByCwId?: boolean
|
||||||
companyId?: boolean
|
companyId?: boolean
|
||||||
|
productSequence?: boolean
|
||||||
cwLastUpdated?: boolean
|
cwLastUpdated?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
@@ -1907,6 +1955,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
|||||||
closedByName?: boolean
|
closedByName?: boolean
|
||||||
closedByCwId?: boolean
|
closedByCwId?: boolean
|
||||||
companyId?: boolean
|
companyId?: boolean
|
||||||
|
productSequence?: boolean
|
||||||
cwLastUpdated?: boolean
|
cwLastUpdated?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
@@ -1957,12 +2006,13 @@ export type OpportunitySelectScalar = {
|
|||||||
closedByName?: boolean
|
closedByName?: boolean
|
||||||
closedByCwId?: boolean
|
closedByCwId?: boolean
|
||||||
companyId?: boolean
|
companyId?: boolean
|
||||||
|
productSequence?: boolean
|
||||||
cwLastUpdated?: boolean
|
cwLastUpdated?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "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> = {
|
export type OpportunityInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
@@ -2022,6 +2072,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
|||||||
closedByName: string | null
|
closedByName: string | null
|
||||||
closedByCwId: number | null
|
closedByCwId: number | null
|
||||||
companyId: string | null
|
companyId: string | null
|
||||||
|
productSequence: number[]
|
||||||
cwLastUpdated: Date | null
|
cwLastUpdated: Date | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
@@ -2492,6 +2543,7 @@ export interface OpportunityFieldRefs {
|
|||||||
readonly closedByName: Prisma.FieldRef<"Opportunity", 'String'>
|
readonly closedByName: Prisma.FieldRef<"Opportunity", 'String'>
|
||||||
readonly closedByCwId: Prisma.FieldRef<"Opportunity", 'Int'>
|
readonly closedByCwId: Prisma.FieldRef<"Opportunity", 'Int'>
|
||||||
readonly companyId: Prisma.FieldRef<"Opportunity", 'String'>
|
readonly companyId: Prisma.FieldRef<"Opportunity", 'String'>
|
||||||
|
readonly productSequence: Prisma.FieldRef<"Opportunity", 'Int[]'>
|
||||||
readonly cwLastUpdated: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
readonly cwLastUpdated: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||||
readonly createdAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
readonly createdAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||||
readonly updatedAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
readonly updatedAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type UserMinAggregateOutputType = {
|
|||||||
email: string | null
|
email: string | null
|
||||||
emailVerified: Date | null
|
emailVerified: Date | null
|
||||||
image: string | null
|
image: string | null
|
||||||
|
cwIdentifier: string | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
token: string | null
|
token: string | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
@@ -46,6 +47,7 @@ export type UserMaxAggregateOutputType = {
|
|||||||
email: string | null
|
email: string | null
|
||||||
emailVerified: Date | null
|
emailVerified: Date | null
|
||||||
image: string | null
|
image: string | null
|
||||||
|
cwIdentifier: string | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
token: string | null
|
token: string | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
@@ -60,6 +62,7 @@ export type UserCountAggregateOutputType = {
|
|||||||
email: number
|
email: number
|
||||||
emailVerified: number
|
emailVerified: number
|
||||||
image: number
|
image: number
|
||||||
|
cwIdentifier: number
|
||||||
userId: number
|
userId: number
|
||||||
token: number
|
token: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
@@ -76,6 +79,7 @@ export type UserMinAggregateInputType = {
|
|||||||
email?: true
|
email?: true
|
||||||
emailVerified?: true
|
emailVerified?: true
|
||||||
image?: true
|
image?: true
|
||||||
|
cwIdentifier?: true
|
||||||
userId?: true
|
userId?: true
|
||||||
token?: true
|
token?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
@@ -90,6 +94,7 @@ export type UserMaxAggregateInputType = {
|
|||||||
email?: true
|
email?: true
|
||||||
emailVerified?: true
|
emailVerified?: true
|
||||||
image?: true
|
image?: true
|
||||||
|
cwIdentifier?: true
|
||||||
userId?: true
|
userId?: true
|
||||||
token?: true
|
token?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
@@ -104,6 +109,7 @@ export type UserCountAggregateInputType = {
|
|||||||
email?: true
|
email?: true
|
||||||
emailVerified?: true
|
emailVerified?: true
|
||||||
image?: true
|
image?: true
|
||||||
|
cwIdentifier?: true
|
||||||
userId?: true
|
userId?: true
|
||||||
token?: true
|
token?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
@@ -191,6 +197,7 @@ export type UserGroupByOutputType = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified: Date | null
|
emailVerified: Date | null
|
||||||
image: string | null
|
image: string | null
|
||||||
|
cwIdentifier: string | null
|
||||||
userId: string
|
userId: string
|
||||||
token: string | null
|
token: string | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
@@ -226,6 +233,7 @@ export type UserWhereInput = {
|
|||||||
email?: Prisma.StringFilter<"User"> | string
|
email?: Prisma.StringFilter<"User"> | string
|
||||||
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
|
cwIdentifier?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
userId?: Prisma.StringFilter<"User"> | string
|
userId?: Prisma.StringFilter<"User"> | string
|
||||||
token?: Prisma.StringNullableFilter<"User"> | string | null
|
token?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||||
@@ -242,6 +250,7 @@ export type UserOrderByWithRelationInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrderInput | Prisma.SortOrder
|
emailVerified?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
cwIdentifier?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
token?: Prisma.SortOrderInput | Prisma.SortOrder
|
token?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
@@ -262,6 +271,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
name?: Prisma.StringNullableFilter<"User"> | string | null
|
name?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
|
cwIdentifier?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
token?: Prisma.StringNullableFilter<"User"> | string | null
|
token?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||||
@@ -277,6 +287,7 @@ export type UserOrderByWithAggregationInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrderInput | Prisma.SortOrder
|
emailVerified?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
cwIdentifier?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
token?: Prisma.SortOrderInput | Prisma.SortOrder
|
token?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
@@ -297,6 +308,7 @@ export type UserScalarWhereWithAggregatesInput = {
|
|||||||
email?: Prisma.StringWithAggregatesFilter<"User"> | string
|
email?: Prisma.StringWithAggregatesFilter<"User"> | string
|
||||||
emailVerified?: Prisma.DateTimeNullableWithAggregatesFilter<"User"> | Date | string | null
|
emailVerified?: Prisma.DateTimeNullableWithAggregatesFilter<"User"> | Date | string | null
|
||||||
image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||||
|
cwIdentifier?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||||
userId?: Prisma.StringWithAggregatesFilter<"User"> | string
|
userId?: Prisma.StringWithAggregatesFilter<"User"> | string
|
||||||
token?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
token?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
||||||
@@ -311,6 +323,7 @@ export type UserCreateInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: Date | string | null
|
emailVerified?: Date | string | null
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
cwIdentifier?: string | null
|
||||||
userId: string
|
userId: string
|
||||||
token?: string | null
|
token?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
@@ -327,6 +340,7 @@ export type UserUncheckedCreateInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: Date | string | null
|
emailVerified?: Date | string | null
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
cwIdentifier?: string | null
|
||||||
userId: string
|
userId: string
|
||||||
token?: string | null
|
token?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
@@ -343,6 +357,7 @@ export type UserUpdateInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -359,6 +374,7 @@ export type UserUncheckedUpdateInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -375,6 +391,7 @@ export type UserCreateManyInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: Date | string | null
|
emailVerified?: Date | string | null
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
cwIdentifier?: string | null
|
||||||
userId: string
|
userId: string
|
||||||
token?: string | null
|
token?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
@@ -389,6 +406,7 @@ export type UserUpdateManyMutationInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -403,6 +421,7 @@ export type UserUncheckedUpdateManyInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -422,6 +441,7 @@ export type UserCountOrderByAggregateInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrder
|
emailVerified?: Prisma.SortOrder
|
||||||
image?: Prisma.SortOrder
|
image?: Prisma.SortOrder
|
||||||
|
cwIdentifier?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
token?: Prisma.SortOrder
|
token?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
@@ -436,6 +456,7 @@ export type UserMaxOrderByAggregateInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrder
|
emailVerified?: Prisma.SortOrder
|
||||||
image?: Prisma.SortOrder
|
image?: Prisma.SortOrder
|
||||||
|
cwIdentifier?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
token?: Prisma.SortOrder
|
token?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
@@ -450,6 +471,7 @@ export type UserMinOrderByAggregateInput = {
|
|||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
emailVerified?: Prisma.SortOrder
|
emailVerified?: Prisma.SortOrder
|
||||||
image?: Prisma.SortOrder
|
image?: Prisma.SortOrder
|
||||||
|
cwIdentifier?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
token?: Prisma.SortOrder
|
token?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
@@ -530,6 +552,7 @@ export type UserCreateWithoutSessionsInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: Date | string | null
|
emailVerified?: Date | string | null
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
cwIdentifier?: string | null
|
||||||
userId: string
|
userId: string
|
||||||
token?: string | null
|
token?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
@@ -545,6 +568,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: Date | string | null
|
emailVerified?: Date | string | null
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
cwIdentifier?: string | null
|
||||||
userId: string
|
userId: string
|
||||||
token?: string | null
|
token?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
@@ -576,6 +600,7 @@ export type UserUpdateWithoutSessionsInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -591,6 +616,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -606,6 +632,7 @@ export type UserCreateWithoutRolesInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: Date | string | null
|
emailVerified?: Date | string | null
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
cwIdentifier?: string | null
|
||||||
userId: string
|
userId: string
|
||||||
token?: string | null
|
token?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
@@ -621,6 +648,7 @@ export type UserUncheckedCreateWithoutRolesInput = {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified?: Date | string | null
|
emailVerified?: Date | string | null
|
||||||
image?: string | null
|
image?: string | null
|
||||||
|
cwIdentifier?: string | null
|
||||||
userId: string
|
userId: string
|
||||||
token?: string | null
|
token?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
@@ -660,6 +688,7 @@ export type UserScalarWhereInput = {
|
|||||||
email?: Prisma.StringFilter<"User"> | string
|
email?: Prisma.StringFilter<"User"> | string
|
||||||
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
|
cwIdentifier?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
userId?: Prisma.StringFilter<"User"> | string
|
userId?: Prisma.StringFilter<"User"> | string
|
||||||
token?: Prisma.StringNullableFilter<"User"> | string | null
|
token?: Prisma.StringNullableFilter<"User"> | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||||
@@ -674,6 +703,7 @@ export type UserUpdateWithoutRolesInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -689,6 +719,7 @@ export type UserUncheckedUpdateWithoutRolesInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -704,6 +735,7 @@ export type UserUncheckedUpdateManyWithoutRolesInput = {
|
|||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
@@ -758,6 +790,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
|||||||
email?: boolean
|
email?: boolean
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: boolean
|
image?: boolean
|
||||||
|
cwIdentifier?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
token?: boolean
|
token?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
@@ -775,6 +808,7 @@ export type UserSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
|||||||
email?: boolean
|
email?: boolean
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: boolean
|
image?: boolean
|
||||||
|
cwIdentifier?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
token?: boolean
|
token?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
@@ -789,6 +823,7 @@ export type UserSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
|||||||
email?: boolean
|
email?: boolean
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: boolean
|
image?: boolean
|
||||||
|
cwIdentifier?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
token?: boolean
|
token?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
@@ -803,13 +838,14 @@ export type UserSelectScalar = {
|
|||||||
email?: boolean
|
email?: boolean
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
image?: boolean
|
image?: boolean
|
||||||
|
cwIdentifier?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
token?: boolean
|
token?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: 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> = {
|
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
|
roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
|
||||||
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
|
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
|
||||||
@@ -832,6 +868,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||||||
email: string
|
email: string
|
||||||
emailVerified: Date | null
|
emailVerified: Date | null
|
||||||
image: string | null
|
image: string | null
|
||||||
|
cwIdentifier: string | null
|
||||||
userId: string
|
userId: string
|
||||||
token: string | null
|
token: string | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
@@ -1268,6 +1305,7 @@ export interface UserFieldRefs {
|
|||||||
readonly email: Prisma.FieldRef<"User", 'String'>
|
readonly email: Prisma.FieldRef<"User", 'String'>
|
||||||
readonly emailVerified: Prisma.FieldRef<"User", 'DateTime'>
|
readonly emailVerified: Prisma.FieldRef<"User", 'DateTime'>
|
||||||
readonly image: Prisma.FieldRef<"User", 'String'>
|
readonly image: Prisma.FieldRef<"User", 'String'>
|
||||||
|
readonly cwIdentifier: Prisma.FieldRef<"User", 'String'>
|
||||||
readonly userId: Prisma.FieldRef<"User", 'String'>
|
readonly userId: Prisma.FieldRef<"User", 'String'>
|
||||||
readonly token: Prisma.FieldRef<"User", 'String'>
|
readonly token: Prisma.FieldRef<"User", 'String'>
|
||||||
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
|
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development bun --watch src/index.ts",
|
"dev": "NODE_ENV=development bun --watch src/index.ts",
|
||||||
|
"dev:log": "LOG_CW_API=1 NODE_ENV=development bun --watch src/index.ts",
|
||||||
|
"test": "bun test --preload ./tests/setup.ts",
|
||||||
"db:gen": "prisma generate",
|
"db:gen": "prisma generate",
|
||||||
"db:push": "prisma migrate dev --skip-generate",
|
"db:push": "prisma migrate dev --skip-generate",
|
||||||
"db:deploy": "prisma migrate deploy",
|
"db:deploy": "prisma migrate deploy",
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
"utils:gen_private_keys": "bun ./utils/genPrivateKeys",
|
"utils:gen_private_keys": "bun ./utils/genPrivateKeys",
|
||||||
"utils:create_admin_role": "bun ./utils/createAdminRole",
|
"utils:create_admin_role": "bun ./utils/createAdminRole",
|
||||||
"utils:assign_user_role": "bun ./utils/assignUserRole",
|
"utils:assign_user_role": "bun ./utils/assignUserRole",
|
||||||
|
"utils:analyze_cw": "python3 debug-scripts/analyze-cw-calls.py",
|
||||||
"db:check": "bunx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --shadow-database-url $DATABASE_URL --exit-code"
|
"db:check": "bunx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --shadow-database-url $DATABASE_URL --exit-code"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -40,6 +43,7 @@
|
|||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"cuid": "^3.0.0",
|
"cuid": "^3.0.0",
|
||||||
"hono": "^4.11.5",
|
"hono": "^4.11.5",
|
||||||
|
"ioredis": "^5.10.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"keypair": "^1.0.4",
|
"keypair": "^1.0.4",
|
||||||
"prisma": "^7.3.0",
|
"prisma": "^7.3.0",
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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?
|
emailVerified DateTime?
|
||||||
image String?
|
image String?
|
||||||
|
|
||||||
|
cwIdentifier String?
|
||||||
|
|
||||||
userId String @unique
|
userId String @unique
|
||||||
token String?
|
token String?
|
||||||
|
|
||||||
@@ -95,6 +97,11 @@ model CatalogItem {
|
|||||||
linkedItems CatalogItem[] @relation("LinkedItems")
|
linkedItems CatalogItem[] @relation("LinkedItems")
|
||||||
linkedTo CatalogItem[] @relation("LinkedItems")
|
linkedTo CatalogItem[] @relation("LinkedItems")
|
||||||
|
|
||||||
|
category String?
|
||||||
|
categoryCwId Int?
|
||||||
|
subcategory String?
|
||||||
|
subcategoryCwId Int?
|
||||||
|
|
||||||
manufacturer String?
|
manufacturer String?
|
||||||
manufactureCwId Int?
|
manufactureCwId Int?
|
||||||
|
|
||||||
@@ -178,6 +185,10 @@ model Opportunity {
|
|||||||
companyId String?
|
companyId String?
|
||||||
company Company? @relation(fields: [companyId], references: [id])
|
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?
|
cwLastUpdated DateTime?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../middleware/authorization";
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
import GenericError from "../../../Errors/GenericError";
|
import GenericError from "../../../Errors/GenericError";
|
||||||
|
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* /v1/company/companies/[id] */
|
/* /v1/company/companies/[id] */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -42,13 +43,20 @@ export default createRoute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = apiResponse.successful(
|
const companyData = company.toJson({
|
||||||
"Company Fetched Successfully!",
|
|
||||||
company.toJson({
|
|
||||||
includeAddress,
|
includeAddress,
|
||||||
includePrimaryContact,
|
includePrimaryContact,
|
||||||
includeAllContacts,
|
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);
|
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 { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../middleware/authorization";
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* GET /v1/company/companies/:identifier/unifi/sites */
|
/* GET /v1/company/companies/:identifier/unifi/sites */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -12,9 +13,16 @@ export default createRoute(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const company = await companies.fetch(c.req.param("identifier"));
|
const company = await companies.fetch(c.req.param("identifier"));
|
||||||
const sites = await unifiSites.fetchByCompany(company.id);
|
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(
|
const response = apiResponse.successful(
|
||||||
"Company UniFi Sites Fetched Successfully!",
|
"Company UniFi Sites Fetched Successfully!",
|
||||||
sites,
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* /v1/company/companies */
|
/* /v1/company/companies */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -22,9 +23,15 @@ export default createRoute(
|
|||||||
? (await companies.search(search, 1, 999999)).length
|
? (await companies.search(search, 1, 999999)).length
|
||||||
: await companies.count();
|
: await companies.count();
|
||||||
|
|
||||||
|
const gatedData = await Promise.all(
|
||||||
|
data.map((item) =>
|
||||||
|
processObjectValuePerms(item, "obj.company", c.get("user")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
let response = apiResponse.successful(
|
let response = apiResponse.successful(
|
||||||
"Companies Fetched Successfully!",
|
"Companies Fetched Successfully!",
|
||||||
data,
|
gatedData,
|
||||||
{
|
{
|
||||||
pagination: {
|
pagination: {
|
||||||
previousPage: page == 1 ? null : page - 1, // Previous Page
|
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 { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* /v1/credential-type/:identifier */
|
/* /v1/credential-type/:identifier */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -15,9 +16,15 @@ export default createRoute(
|
|||||||
c.req.param("identifier"),
|
c.req.param("identifier"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const gatedData = await processObjectValuePerms(
|
||||||
|
credentialType.toJson({ includeCredentialCount: true }),
|
||||||
|
"obj.credentialType",
|
||||||
|
c.get("user"),
|
||||||
|
);
|
||||||
|
|
||||||
const response = apiResponse.successful(
|
const response = apiResponse.successful(
|
||||||
"Credential Type Fetched Successfully!",
|
"Credential Type Fetched Successfully!",
|
||||||
credentialType.toJson({ includeCredentialCount: true }),
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* /v1/credential-type */
|
/* /v1/credential-type */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -13,11 +14,19 @@ export default createRoute(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const allCredentialTypes = await credentialTypes.fetchAll();
|
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(
|
const response = apiResponse.successful(
|
||||||
"Credential Types Fetched Successfully!",
|
"Credential Types Fetched Successfully!",
|
||||||
allCredentialTypes.map((ct) =>
|
gatedData,
|
||||||
ct.toJson({ includeCredentialCount: true }),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* /v1/credential-type/:id/credentials */
|
/* /v1/credential-type/:id/credentials */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -14,9 +15,15 @@ export default createRoute(
|
|||||||
const credentialType = await credentialTypes.fetch(c.req.param("id"));
|
const credentialType = await credentialTypes.fetch(c.req.param("id"));
|
||||||
const credentials = await credentialType.fetchCredentials();
|
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(
|
const response = apiResponse.successful(
|
||||||
"Credentials Fetched Successfully!",
|
"Credentials Fetched Successfully!",
|
||||||
credentials.map((cred) => cred.toJson()),
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* /v1/credential/:id */
|
/* /v1/credential/:id */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -12,10 +13,15 @@ export default createRoute(
|
|||||||
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const credential = await credentials.fetch(c.req.param("id"));
|
const credential = await credentials.fetch(c.req.param("id"));
|
||||||
|
const gatedData = await processObjectValuePerms(
|
||||||
|
credential.toJson(),
|
||||||
|
"obj.credential",
|
||||||
|
c.get("user"),
|
||||||
|
);
|
||||||
|
|
||||||
const response = apiResponse.successful(
|
const response = apiResponse.successful(
|
||||||
"Credential Fetched Successfully!",
|
"Credential Fetched Successfully!",
|
||||||
credential.toJson(),
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* /v1/credential/company/:companyId */
|
/* /v1/credential/company/:companyId */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -15,9 +16,15 @@ export default createRoute(
|
|||||||
c.req.param("companyId"),
|
c.req.param("companyId"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const gatedData = await Promise.all(
|
||||||
|
companyCredentials.map((cred) =>
|
||||||
|
processObjectValuePerms(cred.toJson(), "obj.credential", c.get("user")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const response = apiResponse.successful(
|
const response = apiResponse.successful(
|
||||||
"Company Credentials Fetched Successfully!",
|
"Company Credentials Fetched Successfully!",
|
||||||
companyCredentials.map((cred) => cred.toJson()),
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* GET /v1/credential/credentials/:id/sub-credentials */
|
/* GET /v1/credential/credentials/:id/sub-credentials */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -17,9 +18,15 @@ export default createRoute(
|
|||||||
|
|
||||||
const subCredentials = await credentials.fetchSubCredentials(parentId);
|
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(
|
const response = apiResponse.successful(
|
||||||
"Sub-Credentials Fetched Successfully!",
|
"Sub-Credentials Fetched Successfully!",
|
||||||
subCredentials.map((sc) => sc.toJson()),
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../middleware/authorization";
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* /v1/procurement/items/:identifier */
|
/* /v1/procurement/items/:identifier */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -14,9 +15,15 @@ export default createRoute(
|
|||||||
|
|
||||||
const item = await procurement.fetchItem(identifier);
|
const item = await procurement.fetchItem(identifier);
|
||||||
|
|
||||||
|
const gatedData = await processObjectValuePerms(
|
||||||
|
item.toJson({ includeLinkedItems }),
|
||||||
|
"obj.catalogItem",
|
||||||
|
c.get("user"),
|
||||||
|
);
|
||||||
|
|
||||||
const response = apiResponse.successful(
|
const response = apiResponse.successful(
|
||||||
"Catalog item fetched successfully!",
|
"Catalog item fetched successfully!",
|
||||||
item.toJson({ includeLinkedItems }),
|
gatedData,
|
||||||
);
|
);
|
||||||
|
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../middleware/authorization";
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* GET /v1/procurement/items/:identifier/linked */
|
/* GET /v1/procurement/items/:identifier/linked */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -14,9 +15,15 @@ export default createRoute(
|
|||||||
|
|
||||||
const linkedItems = item.getLinkedItems().map((linked) => linked.toJson());
|
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(
|
const response = apiResponse.successful(
|
||||||
"Linked catalog items fetched successfully!",
|
"Linked catalog items fetched successfully!",
|
||||||
linkedItems,
|
gatedData,
|
||||||
);
|
);
|
||||||
|
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { 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 { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* /v1/procurement/items */
|
/* /v1/procurement/items */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -14,17 +15,53 @@ export default createRoute(
|
|||||||
const search = c.req.query("search") as string;
|
const search = c.req.query("search") as string;
|
||||||
const includeInactive = c.req.query("includeInactive") === "true";
|
const includeInactive = c.req.query("includeInactive") === "true";
|
||||||
|
|
||||||
const data = search
|
// Category / filter params
|
||||||
? await procurement.search(search, page, rpp, { includeInactive })
|
const category = c.req.query("category") as string | undefined;
|
||||||
: await procurement.fetchPages(page, rpp, { includeInactive });
|
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({
|
const filterOpts: CatalogFilterOpts = {
|
||||||
activeOnly: !includeInactive,
|
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(
|
const response = apiResponse.successful(
|
||||||
"Catalog items fetched successfully!",
|
"Catalog items fetched successfully!",
|
||||||
data.map((item) => item.toJson()),
|
gatedData,
|
||||||
{
|
{
|
||||||
pagination: {
|
pagination: {
|
||||||
previousPage: page <= 1 ? null : page - 1,
|
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 unlink } from "./[id]/unlink";
|
||||||
import { default as fetchLinked } from "./[id]/fetchLinked";
|
import { default as fetchLinked } from "./[id]/fetchLinked";
|
||||||
import { default as count } from "./count";
|
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 { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* GET /v1/role/:identifier */
|
/* GET /v1/role/:identifier */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -15,9 +16,15 @@ export default createRoute(
|
|||||||
|
|
||||||
const role = await roles.fetch(identifier);
|
const role = await roles.fetch(identifier);
|
||||||
|
|
||||||
|
const gatedData = await processObjectValuePerms(
|
||||||
|
role.toJson({ viewPermissions: true }),
|
||||||
|
"obj.role",
|
||||||
|
c.get("user"),
|
||||||
|
);
|
||||||
|
|
||||||
const response = apiResponse.successful(
|
const response = apiResponse.successful(
|
||||||
"Role Fetched Successfully!",
|
"Role Fetched Successfully!",
|
||||||
role.toJson({ viewPermissions: true }),
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* GET /v1/role */
|
/* GET /v1/role */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -13,13 +14,19 @@ export default createRoute(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const allRoles = await roles.fetchAllRoles();
|
const allRoles = await roles.fetchAllRoles();
|
||||||
|
|
||||||
const rolesArray = allRoles.map((role) =>
|
const gatedData = await Promise.all(
|
||||||
|
allRoles.map((role) =>
|
||||||
|
processObjectValuePerms(
|
||||||
role.toJson({ viewPermissions: true }),
|
role.toJson({ viewPermissions: true }),
|
||||||
|
"obj.role",
|
||||||
|
c.get("user"),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = apiResponse.successful(
|
const response = apiResponse.successful(
|
||||||
"Roles Fetched Successfully!",
|
"Roles Fetched Successfully!",
|
||||||
rolesArray,
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* GET /v1/role/:identifier/users */
|
/* GET /v1/role/:identifier/users */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -16,11 +17,15 @@ export default createRoute(
|
|||||||
const role = await roles.fetch(identifier);
|
const role = await roles.fetch(identifier);
|
||||||
const users = role.getUsers();
|
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(
|
const response = apiResponse.successful(
|
||||||
"Users Fetched Successfully!",
|
"Users Fetched Successfully!",
|
||||||
usersArray,
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../middleware/authorization";
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
import { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
|
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/contacts */
|
/* GET /v1/sales/opportunities/:identifier/contacts */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -11,24 +10,9 @@ export default createRoute(
|
|||||||
["/opportunities/:identifier/contacts"],
|
["/opportunities/:identifier/contacts"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const item = await opportunities.fetchItem(identifier);
|
const item = await opportunities.fetchRecord(identifier);
|
||||||
|
|
||||||
const contacts = await opportunityCw.fetchContacts(item.cwOpportunityId);
|
const data = await item.fetchContacts();
|
||||||
|
|
||||||
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 response = apiResponse.successful(
|
const response = apiResponse.successful(
|
||||||
"Opportunity contacts fetched successfully!",
|
"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 { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../middleware/authorization";
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier */
|
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier"],
|
["/opportunities/:identifier"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
|
const includeParam = c.req.query("include") ?? "";
|
||||||
|
const includes = new Set(
|
||||||
|
includeParam
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
const item = await opportunities.fetchItem(identifier);
|
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(
|
const response = apiResponse.successful(
|
||||||
"Opportunity fetched successfully!",
|
"Opportunity fetched successfully!",
|
||||||
item.toJson(),
|
gatedData,
|
||||||
);
|
);
|
||||||
|
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../middleware/authorization";
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
import { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
|
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/notes */
|
/* GET /v1/sales/opportunities/:identifier/notes */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -11,17 +10,9 @@ export default createRoute(
|
|||||||
["/opportunities/:identifier/notes"],
|
["/opportunities/:identifier/notes"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const item = await opportunities.fetchItem(identifier);
|
const item = await opportunities.fetchRecord(identifier);
|
||||||
|
|
||||||
const notes = await opportunityCw.fetchNotes(item.cwOpportunityId);
|
const data = await item.fetchNotes();
|
||||||
|
|
||||||
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 response = apiResponse.successful(
|
const response = apiResponse.successful(
|
||||||
"Opportunity notes fetched successfully!",
|
"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 { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities */
|
/* GET /v1/sales/opportunities */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -18,13 +19,23 @@ export default createRoute(
|
|||||||
? await opportunities.search(search, page, rpp, { includeClosed })
|
? await opportunities.search(search, page, rpp, { includeClosed })
|
||||||
: await opportunities.fetchPages(page, rpp, { includeClosed });
|
: await opportunities.fetchPages(page, rpp, { includeClosed });
|
||||||
|
|
||||||
const totalRecords = await opportunities.count({
|
const totalRecords = search
|
||||||
openOnly: !includeClosed,
|
? 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(
|
const response = apiResponse.successful(
|
||||||
"Opportunities fetched successfully!",
|
"Opportunities fetched successfully!",
|
||||||
data.map((item) => item.toJson()),
|
gatedData,
|
||||||
{
|
{
|
||||||
pagination: {
|
pagination: {
|
||||||
previousPage: page <= 1 ? null : page - 1,
|
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 fetchAll } from "./fetchAll";
|
||||||
|
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
|
||||||
import { default as count } from "./count";
|
import { default as count } from "./count";
|
||||||
import { default as fetch } from "./[id]/fetch";
|
import { default as fetch } from "./[id]/fetch";
|
||||||
import { default as refresh } from "./[id]/refresh";
|
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 notes } from "./[id]/notes";
|
||||||
|
import { default as fetchNote } from "./[id]/fetchNote";
|
||||||
|
import { default as createNote } from "./[id]/createNote";
|
||||||
|
import { default as updateNote } from "./[id]/updateNote";
|
||||||
|
import { default as deleteNote } from "./[id]/deleteNote";
|
||||||
import { default as contacts } from "./[id]/contacts";
|
import { default as 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 { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../middleware/authorization";
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* GET /v1/unifi/site/:id */
|
/* GET /v1/unifi/site/:id */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -10,9 +11,16 @@ export default createRoute(
|
|||||||
["/site/:id"],
|
["/site/:id"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const site = await unifiSites.fetch(c.req.param("id"));
|
const site = await unifiSites.fetch(c.req.param("id"));
|
||||||
|
|
||||||
|
const gatedData = await processObjectValuePerms(
|
||||||
|
site,
|
||||||
|
"obj.unifiSite",
|
||||||
|
c.get("user"),
|
||||||
|
);
|
||||||
|
|
||||||
const response = apiResponse.successful(
|
const response = apiResponse.successful(
|
||||||
"UniFi Site Fetched Successfully!",
|
"UniFi Site Fetched Successfully!",
|
||||||
site,
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../middleware/authorization";
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* GET /v1/unifi/sites */
|
/* GET /v1/unifi/sites */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -10,9 +11,16 @@ export default createRoute(
|
|||||||
["/sites"],
|
["/sites"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const sites = await unifiSites.fetchAll();
|
const sites = await unifiSites.fetchAll();
|
||||||
|
|
||||||
|
const gatedData = await Promise.all(
|
||||||
|
sites.map((site) =>
|
||||||
|
processObjectValuePerms(site, "obj.unifiSite", c.get("user")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const response = apiResponse.successful(
|
const response = apiResponse.successful(
|
||||||
"UniFi Sites Fetched Successfully!",
|
"UniFi Sites Fetched Successfully!",
|
||||||
sites,
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
import { authMiddleware } from "../../middleware/authorization";
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
// /v1/user/@me
|
// /v1/user/@me
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/@me"],
|
["/@me"],
|
||||||
(c) => {
|
async (c) => {
|
||||||
const response = apiResponse.successful(
|
const gatedData = await processObjectValuePerms(
|
||||||
"Fetched user.",
|
|
||||||
c.get("user")?.toJson(),
|
c.get("user")?.toJson(),
|
||||||
|
"obj.user",
|
||||||
|
c.get("user"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const response = apiResponse.successful("Fetched user.", gatedData);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
},
|
},
|
||||||
authMiddleware({ scopes: ["user.read"] }),
|
authMiddleware({ scopes: ["user.read"] }),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createRoute } from "../../modules/api-utils/createRoute";
|
|||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
import { users } from "../../managers/users";
|
import { users } from "../../managers/users";
|
||||||
import GenericError from "../../Errors/GenericError";
|
import GenericError from "../../Errors/GenericError";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* GET /v1/user/users/:identifier */
|
/* GET /v1/user/users/:identifier */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -21,9 +22,15 @@ export default createRoute(
|
|||||||
status: 404,
|
status: 404,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gatedData = await processObjectValuePerms(
|
||||||
|
user.toJson(),
|
||||||
|
"obj.user",
|
||||||
|
c.get("user"),
|
||||||
|
);
|
||||||
|
|
||||||
const response = apiResponse.successful(
|
const response = apiResponse.successful(
|
||||||
"User Fetched Successfully!",
|
"User Fetched Successfully!",
|
||||||
user.toJson(),
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { createRoute } from "../../modules/api-utils/createRoute";
|
||||||
import { authMiddleware } from "../middleware/authorization";
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
import { users } from "../../managers/users";
|
import { users } from "../../managers/users";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* GET /v1/user/users */
|
/* GET /v1/user/users */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -11,11 +12,16 @@ export default createRoute(
|
|||||||
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const allUsers = await users.fetchAllUsers();
|
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(
|
const response = apiResponse.successful(
|
||||||
"Users Fetched Successfully!",
|
"Users Fetched Successfully!",
|
||||||
usersArray,
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { authMiddleware } from "../middleware/authorization";
|
||||||
import { users } from "../../managers/users";
|
import { users } from "../../managers/users";
|
||||||
import GenericError from "../../Errors/GenericError";
|
import GenericError from "../../Errors/GenericError";
|
||||||
|
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||||
|
|
||||||
/* GET /v1/user/users/:identifier/roles */
|
/* GET /v1/user/users/:identifier/roles */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
@@ -22,11 +23,20 @@ export default createRoute(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const roles = await user.fetchRoles();
|
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(
|
const response = apiResponse.successful(
|
||||||
"User Roles Fetched Successfully!",
|
"User Roles Fetched Successfully!",
|
||||||
rolesArray,
|
gatedData,
|
||||||
);
|
);
|
||||||
return c.json(response, response.status as ContentfulStatusCode);
|
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 { Server as Engine } from "@socket.io/bun-engine";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { UnifiClient } from "./modules/unifi-api/UnifiClient";
|
import { UnifiClient } from "./modules/unifi-api/UnifiClient";
|
||||||
|
import { attachCwApiLogger } from "./modules/cw-utils/cwApiLogger";
|
||||||
|
import Redis from "ioredis";
|
||||||
|
|
||||||
const connectionString = `${process.env.DATABASE_URL}`;
|
const connectionString = `${process.env.DATABASE_URL}`;
|
||||||
const adapter = new PrismaPg({ connectionString });
|
const adapter = new PrismaPg({ connectionString });
|
||||||
@@ -22,6 +24,10 @@ export const API_BASE_URL =
|
|||||||
|
|
||||||
export const prisma = new PrismaClient({ adapter });
|
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 sessionDuration = 30 * 24 * 60 * 60000;
|
||||||
export const accessTokenDuration = "10min";
|
export const accessTokenDuration = "10min";
|
||||||
export const refreshTokenDuration = "30d";
|
export const refreshTokenDuration = "30d";
|
||||||
@@ -76,8 +82,11 @@ const connectWiseApi = axios.create({
|
|||||||
clientId: `${process.env.CW_CLIENT_ID}`,
|
clientId: `${process.env.CW_CLIENT_ID}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
timeout: 30_000, // 30 s — prevents indefinite hangs on CW API
|
||||||
});
|
});
|
||||||
|
|
||||||
|
attachCwApiLogger(connectWiseApi);
|
||||||
|
|
||||||
export { connectWiseApi };
|
export { connectWiseApi };
|
||||||
|
|
||||||
// Unifi API Constants
|
// Unifi API Constants
|
||||||
|
|||||||
@@ -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 cwCatalogId: number;
|
||||||
public readonly identifier: string | null;
|
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 manufacturer: string | null;
|
||||||
public manufactureCwId: number | null;
|
public manufactureCwId: number | null;
|
||||||
public partNumber: string | null;
|
public partNumber: string | null;
|
||||||
@@ -55,6 +60,10 @@ export class CatalogItemController {
|
|||||||
this.internalNotes = itemData.internalNotes;
|
this.internalNotes = itemData.internalNotes;
|
||||||
this.cwCatalogId = itemData.cwCatalogId;
|
this.cwCatalogId = itemData.cwCatalogId;
|
||||||
this.identifier = itemData.identifier;
|
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.manufacturer = itemData.manufacturer;
|
||||||
this.manufactureCwId = itemData.manufactureCwId;
|
this.manufactureCwId = itemData.manufactureCwId;
|
||||||
this.partNumber = itemData.partNumber;
|
this.partNumber = itemData.partNumber;
|
||||||
@@ -196,6 +205,10 @@ export class CatalogItemController {
|
|||||||
description: this.description,
|
description: this.description,
|
||||||
customerDescription: this.customerDescription,
|
customerDescription: this.customerDescription,
|
||||||
internalNotes: this.internalNotes,
|
internalNotes: this.internalNotes,
|
||||||
|
category: this.category,
|
||||||
|
categoryCwId: this.categoryCwId,
|
||||||
|
subcategory: this.subcategory,
|
||||||
|
subcategoryCwId: this.subcategoryCwId,
|
||||||
manufacturer: this.manufacturer,
|
manufacturer: this.manufacturer,
|
||||||
manufactureCwId: this.manufactureCwId,
|
manufactureCwId: this.manufactureCwId,
|
||||||
partNumber: this.partNumber,
|
partNumber: this.partNumber,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Company } from "../../generated/prisma/client";
|
import { Company } from "../../generated/prisma/client";
|
||||||
|
import { connectWiseApi } from "../constants";
|
||||||
import { fetchCwCompanyById } from "../modules/cw-utils/fetchCompany";
|
import { fetchCwCompanyById } from "../modules/cw-utils/fetchCompany";
|
||||||
import { fetchCompanyConfigurations } from "../modules/cw-utils/configurations/fetchCompanyConfigurations";
|
import { fetchCompanyConfigurations } from "../modules/cw-utils/configurations/fetchCompanyConfigurations";
|
||||||
import { updateCwInternalCompany } from "../modules/cw-utils/updateCompany";
|
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";
|
import { Company as CWCompany, Contact } from "../types/ConnectWiseTypes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +22,7 @@ export class CompanyController {
|
|||||||
public name: string;
|
public name: string;
|
||||||
public readonly cw_Identifier: string;
|
public readonly cw_Identifier: string;
|
||||||
public readonly cw_CompanyId: number;
|
public readonly cw_CompanyId: number;
|
||||||
public readonly cw_Data?: {
|
public cw_Data?: {
|
||||||
company: CWCompany;
|
company: CWCompany;
|
||||||
defaultContact: Contact | null;
|
defaultContact: Contact | null;
|
||||||
allContacts: Contact[];
|
allContacts: Contact[];
|
||||||
@@ -30,6 +36,38 @@ export class CompanyController {
|
|||||||
this.cw_Data = cwData;
|
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
|
* Refresh Internal Company Data from ConnectWise
|
||||||
*
|
*
|
||||||
@@ -71,6 +109,30 @@ export class CompanyController {
|
|||||||
return data;
|
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?: {
|
public toJson(opts?: {
|
||||||
includeAddress: boolean;
|
includeAddress: boolean;
|
||||||
includePrimaryContact: 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 { prisma } from "../constants";
|
||||||
|
import { CompanyController } from "./CompanyController";
|
||||||
|
import { ActivityController } from "./ActivityController";
|
||||||
import { fetchOpportunity } from "../modules/cw-utils/opportunities/fetchOpportunity";
|
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
|
* Opportunity Controller
|
||||||
@@ -63,10 +93,47 @@ export class OpportunityController {
|
|||||||
public companyId: string | null;
|
public companyId: string | null;
|
||||||
public cwLastUpdated: Date | 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 readonly createdAt: Date;
|
||||||
public updatedAt: 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.id = data.id;
|
||||||
this.cwOpportunityId = data.cwOpportunityId;
|
this.cwOpportunityId = data.cwOpportunityId;
|
||||||
this.name = data.name;
|
this.name = data.name;
|
||||||
@@ -118,9 +185,43 @@ export class OpportunityController {
|
|||||||
|
|
||||||
this.companyId = data.companyId;
|
this.companyId = data.companyId;
|
||||||
this.cwLastUpdated = data.cwLastUpdated;
|
this.cwLastUpdated = data.cwLastUpdated;
|
||||||
|
this.productSequence = data.productSequence;
|
||||||
|
|
||||||
this.createdAt = data.createdAt;
|
this.createdAt = data.createdAt;
|
||||||
this.updatedAt = data.updatedAt;
|
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({
|
const updated = await prisma.opportunity.update({
|
||||||
where: { id: this.id },
|
where: { id: this.id },
|
||||||
data: mapped,
|
data: mapped,
|
||||||
|
include: { company: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return new OpportunityController(updated);
|
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
|
* To JSON
|
||||||
*
|
*
|
||||||
@@ -258,13 +903,23 @@ export class OpportunityController {
|
|||||||
name: this.secondarySalesRepName,
|
name: this.secondarySalesRepName,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
company: this.companyCwId
|
company: this._company
|
||||||
|
? this._company.toJson({
|
||||||
|
includeAllContacts: true,
|
||||||
|
includeAddress: true,
|
||||||
|
includePrimaryContact: false,
|
||||||
|
})
|
||||||
|
: this.companyCwId
|
||||||
? { id: this.companyCwId, name: this.companyName }
|
? { id: this.companyCwId, name: this.companyName }
|
||||||
: null,
|
: null,
|
||||||
contact: this.contactCwId
|
contact: this.contactCwId
|
||||||
? { id: this.contactCwId, name: this.contactName }
|
? { id: this.contactCwId, name: this.contactName }
|
||||||
: null,
|
: 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,
|
customerPO: this.customerPO,
|
||||||
totalSalesTax: this.totalSalesTax,
|
totalSalesTax: this.totalSalesTax,
|
||||||
location: this.locationCwId
|
location: this.locationCwId
|
||||||
@@ -283,8 +938,11 @@ export class OpportunityController {
|
|||||||
: null,
|
: null,
|
||||||
companyId: this.companyId,
|
companyId: this.companyId,
|
||||||
cwLastUpdated: this.cwLastUpdated,
|
cwLastUpdated: this.cwLastUpdated,
|
||||||
|
productSequence: this.productSequence,
|
||||||
createdAt: this.createdAt,
|
createdAt: this.createdAt,
|
||||||
updatedAt: this.updatedAt,
|
updatedAt: this.updatedAt,
|
||||||
|
customFields: this._customFields ?? [],
|
||||||
|
activities: this._activities?.map((a) => a.toJson()) ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export class RoleController {
|
|||||||
private _permissionsToken: string;
|
private _permissionsToken: string;
|
||||||
private _users: (User & { roles: Role[] })[];
|
private _users: (User & { roles: Role[] })[];
|
||||||
|
|
||||||
|
/** Cached result of JWT verification — avoids repeated RSA verify calls. */
|
||||||
|
private _cachedVerifiedPermissions: { permissions: string[] } | null = null;
|
||||||
|
|
||||||
public readonly createdAt: Date;
|
public readonly createdAt: Date;
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
|
|
||||||
@@ -62,6 +65,14 @@ export class RoleController {
|
|||||||
* @returns - Verified object with permissions in it.
|
* @returns - Verified object with permissions in it.
|
||||||
*/
|
*/
|
||||||
private _verifyPermissions(permissionsToken: string) {
|
private _verifyPermissions(permissionsToken: string) {
|
||||||
|
// Return cached result if the token hasn't changed
|
||||||
|
if (
|
||||||
|
this._cachedVerifiedPermissions &&
|
||||||
|
permissionsToken === this._permissionsToken
|
||||||
|
) {
|
||||||
|
return this._cachedVerifiedPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
let perms: DecodedPermissionsBlock;
|
let perms: DecodedPermissionsBlock;
|
||||||
try {
|
try {
|
||||||
perms = jwt.verify(permissionsToken, permissionsPrivateKey, {
|
perms = jwt.verify(permissionsToken, permissionsPrivateKey, {
|
||||||
@@ -82,7 +93,12 @@ export class RoleController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return perms as { permissions: string[] };
|
const result = perms as { permissions: string[] };
|
||||||
|
// Cache only if verifying the current token
|
||||||
|
if (permissionsToken === this._permissionsToken) {
|
||||||
|
this._cachedVerifiedPermissions = result;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,10 +19,18 @@ export default class UserController {
|
|||||||
public login: string;
|
public login: string;
|
||||||
public email: string;
|
public email: string;
|
||||||
public image: string | null;
|
public image: string | null;
|
||||||
|
public cwIdentifier: string | null;
|
||||||
|
|
||||||
private _roles: Collection<string, Role>;
|
private _roles: Collection<string, Role>;
|
||||||
private _permissions: string | null;
|
private _permissions: string | null;
|
||||||
|
|
||||||
|
/** Cached result of fetchRoles() — populated on first hasPermission call. */
|
||||||
|
private _resolvedRoleControllers: Collection<string, RoleController> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
/** Per-permission result cache — avoids repeated JWT verification + DB lookups. */
|
||||||
|
private _permissionCache: Map<string, boolean> = new Map();
|
||||||
|
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
public updatedAt: Date;
|
public updatedAt: Date;
|
||||||
constructor(userdata: User & { roles: Role[] }) {
|
constructor(userdata: User & { roles: Role[] }) {
|
||||||
@@ -31,6 +39,7 @@ export default class UserController {
|
|||||||
this.login = userdata.login;
|
this.login = userdata.login;
|
||||||
this.email = userdata.email;
|
this.email = userdata.email;
|
||||||
this.image = userdata.image;
|
this.image = userdata.image;
|
||||||
|
this.cwIdentifier = userdata.cwIdentifier ?? null;
|
||||||
this.updatedAt = userdata.updatedAt;
|
this.updatedAt = userdata.updatedAt;
|
||||||
this.createdAt = userdata.createdAt;
|
this.createdAt = userdata.createdAt;
|
||||||
this._permissions = userdata.permissions ?? null;
|
this._permissions = userdata.permissions ?? null;
|
||||||
@@ -57,6 +66,7 @@ export default class UserController {
|
|||||||
this.login = userdata.login;
|
this.login = userdata.login;
|
||||||
this.email = userdata.email;
|
this.email = userdata.email;
|
||||||
this.image = userdata.image;
|
this.image = userdata.image;
|
||||||
|
this.cwIdentifier = userdata.cwIdentifier ?? null;
|
||||||
this.updatedAt = userdata.updatedAt;
|
this.updatedAt = userdata.updatedAt;
|
||||||
this.createdAt = userdata.createdAt;
|
this.createdAt = userdata.createdAt;
|
||||||
}
|
}
|
||||||
@@ -124,6 +134,7 @@ export default class UserController {
|
|||||||
this._updateInternalValues(updatedUser);
|
this._updateInternalValues(updatedUser);
|
||||||
this._roles = new Collection<string, Role>();
|
this._roles = new Collection<string, Role>();
|
||||||
updatedUser.roles.map((v: any) => this._roles.set(v.id, v));
|
updatedUser.roles.map((v: any) => this._roles.set(v.id, v));
|
||||||
|
this.clearPermissionCache();
|
||||||
|
|
||||||
for (const role of resolvedRoles) {
|
for (const role of resolvedRoles) {
|
||||||
events.emit("user:role:assigned", { user: this, role });
|
events.emit("user:role:assigned", { user: this, role });
|
||||||
@@ -249,35 +260,34 @@ export default class UserController {
|
|||||||
* @returns {boolean} Does this user have the specified permission
|
* @returns {boolean} Does this user have the specified permission
|
||||||
*/
|
*/
|
||||||
public async hasPermission(permission: string) {
|
public async hasPermission(permission: string) {
|
||||||
let resources = await prisma.user.findFirst({
|
// Fast path: return cached result if we already resolved this permission
|
||||||
where: { id: this.id },
|
const cached = this._permissionCache.get(permission);
|
||||||
select: {
|
if (cached !== undefined) return cached;
|
||||||
sessions: {
|
|
||||||
select: { id: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resourceKeys: string[] = Object.keys(resources ?? {}) as string[];
|
// Resolve role controllers once and cache them for the lifetime of this
|
||||||
|
// controller instance (i.e. the current request).
|
||||||
|
if (!this._resolvedRoleControllers) {
|
||||||
|
this._resolvedRoleControllers = await this.fetchRoles();
|
||||||
|
}
|
||||||
|
|
||||||
const implicitPermissions = resources
|
const result = this._resolvedRoleControllers
|
||||||
? resourceKeys
|
.map((v) => v.checkPermission(permission))
|
||||||
// @ts-ignore
|
.includes(true);
|
||||||
.filter((v) => resources[v].length > 0)
|
|
||||||
.map(
|
|
||||||
(v) =>
|
|
||||||
//@ts-ignore
|
|
||||||
`resource.${v}.[${(resources![v] as { id: string }[])
|
|
||||||
.map((o) => o.id)
|
|
||||||
.join(",")}].user.${this.id}.implicit`,
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
let checks = [
|
this._permissionCache.set(permission, result);
|
||||||
(await this.fetchRoles()).map((v) => v.checkPermission(permission)),
|
return result;
|
||||||
].flatMap((v) => v);
|
}
|
||||||
|
|
||||||
return checks.includes(true);
|
/**
|
||||||
|
* Clear Permission Cache
|
||||||
|
*
|
||||||
|
* Invalidates the in-memory permission cache so the next
|
||||||
|
* `hasPermission` call re-fetches roles from the database.
|
||||||
|
* Call this after role or permission mutations on the user.
|
||||||
|
*/
|
||||||
|
public clearPermissionCache() {
|
||||||
|
this._resolvedRoleControllers = null;
|
||||||
|
this._permissionCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -314,6 +324,7 @@ export default class UserController {
|
|||||||
})(),
|
})(),
|
||||||
login: opts?.safeReturn ? undefined : this.login,
|
login: opts?.safeReturn ? undefined : this.login,
|
||||||
email: opts?.safeReturn ? undefined : this.email,
|
email: opts?.safeReturn ? undefined : this.email,
|
||||||
|
cwIdentifier: opts?.safeReturn ? undefined : this.cwIdentifier,
|
||||||
image: this.image,
|
image: this.image,
|
||||||
createdAt: this.createdAt,
|
createdAt: this.createdAt,
|
||||||
updatedAt: this.updatedAt,
|
updatedAt: this.updatedAt,
|
||||||
|
|||||||
+115
-29
@@ -13,6 +13,9 @@ import { refreshCompanies } from "./modules/cw-utils/refreshCompanies";
|
|||||||
import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog";
|
import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog";
|
||||||
import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory";
|
import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory";
|
||||||
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
|
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 { events, setupEventDebugger } from "./modules/globalEvents";
|
||||||
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
||||||
import { RoleController } from "./controllers/RoleController";
|
import { RoleController } from "./controllers/RoleController";
|
||||||
@@ -21,13 +24,61 @@ import cuid from "cuid";
|
|||||||
// Setup global event debugger in non-production environments
|
// Setup global event debugger in non-production environments
|
||||||
if (Bun.env.NODE_ENV == "development") setupEventDebugger();
|
if (Bun.env.NODE_ENV == "development") setupEventDebugger();
|
||||||
|
|
||||||
// Ensure administrator role exists
|
/** Concise error message for interval logs — avoids dumping full Axios error objects. */
|
||||||
const existingAdmin = await prisma.role.findFirst({
|
const briefErr = (err: any): string => {
|
||||||
where: { moniker: "administrator" },
|
if (err?.isAxiosError) {
|
||||||
include: { users: { include: { roles: true } } },
|
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);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existingAdmin) {
|
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 } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingAdmin) {
|
||||||
const id = cuid();
|
const id = cuid();
|
||||||
const created = await prisma.role.create({
|
const created = await prisma.role.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -43,50 +94,85 @@ if (!existingAdmin) {
|
|||||||
include: { users: { include: { roles: true } } },
|
include: { users: { include: { roles: true } } },
|
||||||
});
|
});
|
||||||
events.emit("role:created", new RoleController(created));
|
events.emit("role:created", new RoleController(created));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Refresh the internal list of companies every minute
|
// Refresh the internal list of companies every minute
|
||||||
await refreshCompanies();
|
await safeStartup("refreshCompanies", refreshCompanies);
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
return refreshCompanies();
|
return refreshCompanies().catch((err) =>
|
||||||
|
console.error(`[interval] refreshCompanies failed: ${briefErr(err)}`),
|
||||||
|
);
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
// Refresh the internal catalog every minute
|
// Refresh the internal catalog every minute
|
||||||
await refreshCatalog();
|
await safeStartup("refreshCatalog", refreshCatalog);
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
return refreshCatalog();
|
return refreshCatalog().catch((err) =>
|
||||||
|
console.error(`[interval] refreshCatalog failed: ${briefErr(err)}`),
|
||||||
|
);
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
// Refresh inventory on hand every 2 minutes
|
// Refresh inventory on hand every 2 minutes
|
||||||
await refreshInventory();
|
await safeStartup("refreshInventory", refreshInventory);
|
||||||
setInterval(
|
setInterval(
|
||||||
() => {
|
() => {
|
||||||
return refreshInventory();
|
return refreshInventory().catch((err) =>
|
||||||
|
console.error(`[interval] refreshInventory failed: ${briefErr(err)}`),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
2 * 60 * 1000,
|
2 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refresh opportunities every minute
|
// Refresh opportunities every minute
|
||||||
await refreshOpportunities();
|
await safeStartup("refreshOpportunities", refreshOpportunities);
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
return refreshOpportunities();
|
return refreshOpportunities().catch((err) =>
|
||||||
|
console.error(`[interval] refreshOpportunities failed: ${briefErr(err)}`),
|
||||||
|
);
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
await unifiSites.syncSites();
|
// 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(() => {
|
setInterval(() => {
|
||||||
return unifiSites.syncSites();
|
return refreshOpportunityCache().catch((err) => {
|
||||||
}, 60 * 1000);
|
console.error(
|
||||||
|
`[interval] refreshOpportunityCache failed: ${briefErr(err)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, 30 * 1000);
|
||||||
|
|
||||||
Bun.serve({
|
// Refresh User Defined Fields every 5 minutes
|
||||||
port: PORT,
|
await safeStartup("refreshUDFs", () => userDefinedFieldsCw.refresh());
|
||||||
websocket: engine.handler().websocket,
|
setInterval(
|
||||||
fetch: (req, server) => {
|
() => {
|
||||||
const url = new URL(req.url);
|
return userDefinedFieldsCw
|
||||||
|
.refresh()
|
||||||
if (url.pathname.startsWith("/socket.io/")) {
|
.catch((err) =>
|
||||||
return engine.handleRequest(req, server as any);
|
console.error(`[interval] refreshUDFs failed: ${briefErr(err)}`),
|
||||||
}
|
);
|
||||||
|
|
||||||
return app.fetch(req, server);
|
|
||||||
},
|
},
|
||||||
});
|
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: ${briefErr(err)}`),
|
||||||
|
);
|
||||||
|
}, 60 * 1000);
|
||||||
|
|||||||
@@ -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 { prisma } from "../constants";
|
||||||
|
import { ActivityController } from "../controllers/ActivityController";
|
||||||
|
import { CompanyController } from "../controllers/CompanyController";
|
||||||
import { OpportunityController } from "../controllers/OpportunityController";
|
import { OpportunityController } from "../controllers/OpportunityController";
|
||||||
import GenericError from "../Errors/GenericError";
|
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 = {
|
export const opportunities = {
|
||||||
/**
|
/**
|
||||||
* Fetch Opportunity
|
* Fetch Record (lightweight)
|
||||||
*
|
*
|
||||||
* Fetch an opportunity by its internal ID or ConnectWise opportunity ID
|
* Returns an OpportunityController backed only by the **database record**.
|
||||||
* and return an OpportunityController instance.
|
* 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>}
|
* @returns {Promise<OpportunityController>}
|
||||||
*/
|
*/
|
||||||
async fetchItem(identifier: string | number): Promise<OpportunityController> {
|
async fetchRecord(
|
||||||
|
identifier: string | number,
|
||||||
|
): Promise<OpportunityController> {
|
||||||
const isNumeric =
|
const isNumeric =
|
||||||
typeof identifier === "number" || /^\d+$/.test(String(identifier));
|
typeof identifier === "number" || /^\d+$/.test(String(identifier));
|
||||||
|
|
||||||
const item = await prisma.opportunity.findFirst({
|
const record = await prisma.opportunity.findFirst({
|
||||||
where: isNumeric
|
where: isNumeric
|
||||||
? { cwOpportunityId: Number(identifier) }
|
? { cwOpportunityId: Number(identifier) }
|
||||||
: { id: identifier as string },
|
: { id: identifier as string },
|
||||||
|
include: { company: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!item) {
|
if (!record) {
|
||||||
throw new GenericError({
|
throw new GenericError({
|
||||||
message: "Opportunity not found",
|
message: "Opportunity not found",
|
||||||
name: "OpportunityNotFound",
|
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)
|
* 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 page - Page number (1-based)
|
||||||
* @param rpp - Records per page
|
* @param rpp - Records per page
|
||||||
* @param opts - Optional filters
|
* @param opts - Optional filters
|
||||||
@@ -51,12 +303,26 @@ export const opportunities = {
|
|||||||
|
|
||||||
const items = await prisma.opportunity.findMany({
|
const items = await prisma.opportunity.findMany({
|
||||||
where: opts?.includeClosed ? undefined : { closedFlag: false },
|
where: opts?.includeClosed ? undefined : { closedFlag: false },
|
||||||
|
include: { company: true },
|
||||||
skip,
|
skip,
|
||||||
take: rpp,
|
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,
|
* Search opportunities by name, company name, contact name, notes,
|
||||||
* sales rep, or status with pagination support.
|
* sales rep, or status with pagination support.
|
||||||
*
|
*
|
||||||
|
* Uses the **cache-only** strategy (same as `fetchPages`).
|
||||||
|
*
|
||||||
* @param query - Search query string
|
* @param query - Search query string
|
||||||
* @param page - Page number (1-based)
|
* @param page - Page number (1-based)
|
||||||
* @param rpp - Records per page
|
* @param rpp - Records per page
|
||||||
@@ -78,6 +346,9 @@ export const opportunities = {
|
|||||||
opts?: { includeClosed?: boolean },
|
opts?: { includeClosed?: boolean },
|
||||||
): Promise<OpportunityController[]> {
|
): Promise<OpportunityController[]> {
|
||||||
const skip = (Math.max(page, 1) - 1) * rpp;
|
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||||
|
const numericQuery = /^\d+$/.test(query.trim())
|
||||||
|
? Number(query.trim())
|
||||||
|
: null;
|
||||||
|
|
||||||
const items = await prisma.opportunity.findMany({
|
const items = await prisma.opportunity.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -90,14 +361,31 @@ export const opportunities = {
|
|||||||
{ primarySalesRepName: { contains: query, mode: "insensitive" } },
|
{ primarySalesRepName: { contains: query, mode: "insensitive" } },
|
||||||
{ statusName: { contains: query, mode: "insensitive" } },
|
{ statusName: { contains: query, mode: "insensitive" } },
|
||||||
{ stageName: { contains: query, mode: "insensitive" } },
|
{ stageName: { contains: query, mode: "insensitive" } },
|
||||||
|
...(numericQuery !== null
|
||||||
|
? [{ cwOpportunityId: { equals: numericQuery } }]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
include: { company: true },
|
||||||
skip,
|
skip,
|
||||||
take: rpp,
|
take: rpp,
|
||||||
orderBy: { expectedCloseDate: "asc" },
|
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 Opportunities by Company
|
||||||
*
|
*
|
||||||
* Fetch all opportunities for a company by its internal company ID.
|
* 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 companyId - The internal company ID
|
||||||
* @param opts - Optional filters
|
* @param opts - Optional filters
|
||||||
* @returns {Promise<OpportunityController[]>}
|
* @returns {Promise<OpportunityController[]>}
|
||||||
@@ -130,9 +457,23 @@ export const opportunities = {
|
|||||||
companyId,
|
companyId,
|
||||||
...(opts?.includeClosed ? {} : { closedFlag: false }),
|
...(opts?.includeClosed ? {} : { closedFlag: false }),
|
||||||
},
|
},
|
||||||
|
include: { company: true },
|
||||||
orderBy: { expectedCloseDate: "asc" },
|
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 { prisma } from "../constants";
|
||||||
import { CatalogItemController } from "../controllers/CatalogItemController";
|
import { CatalogItemController } from "../controllers/CatalogItemController";
|
||||||
import GenericError from "../Errors/GenericError";
|
import GenericError from "../Errors/GenericError";
|
||||||
|
import {
|
||||||
|
getSubcategoriesForCategory,
|
||||||
|
getSubcategoriesForGroup,
|
||||||
|
ECOSYSTEM_TREE,
|
||||||
|
} from "../modules/catalog-categories/catalogCategories";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard include clause used by catalog item queries.
|
* Standard include clause used by catalog item queries.
|
||||||
@@ -10,6 +15,95 @@ const catalogItemInclude = {
|
|||||||
linkedItems: true,
|
linkedItems: true,
|
||||||
} as const;
|
} 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 = {
|
export const procurement = {
|
||||||
/**
|
/**
|
||||||
* Fetch Catalog Item
|
* Fetch Catalog Item
|
||||||
@@ -51,22 +145,23 @@ export const procurement = {
|
|||||||
/**
|
/**
|
||||||
* Fetch All Catalog Items (Paginated)
|
* 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 page - Page number (1-based)
|
||||||
* @param rpp - Records per page
|
* @param rpp - Records per page
|
||||||
|
* @param opts - Filter options
|
||||||
* @returns {Promise<CatalogItemController[]>} - Array of catalog item controllers
|
* @returns {Promise<CatalogItemController[]>} - Array of catalog item controllers
|
||||||
*/
|
*/
|
||||||
async fetchPages(
|
async fetchPages(
|
||||||
page: number,
|
page: number,
|
||||||
rpp: number,
|
rpp: number,
|
||||||
opts?: { includeInactive?: boolean },
|
opts?: CatalogFilterOpts,
|
||||||
): Promise<CatalogItemController[]> {
|
): Promise<CatalogItemController[]> {
|
||||||
const skip = (Math.max(page, 1) - 1) * rpp;
|
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||||
const take = rpp;
|
const take = rpp;
|
||||||
|
|
||||||
const items = await prisma.catalogItem.findMany({
|
const items = await prisma.catalogItem.findMany({
|
||||||
where: opts?.includeInactive ? undefined : { inactive: false },
|
where: buildFilterWhere(opts),
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
include: catalogItemInclude,
|
include: catalogItemInclude,
|
||||||
@@ -80,25 +175,28 @@ export const procurement = {
|
|||||||
* Search Catalog Items
|
* Search Catalog Items
|
||||||
*
|
*
|
||||||
* Search catalog items by name, description, part number, or vendor SKU
|
* 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 query - Search query string
|
||||||
* @param page - Page number (1-based)
|
* @param page - Page number (1-based)
|
||||||
* @param rpp - Records per page
|
* @param rpp - Records per page
|
||||||
|
* @param opts - Filter options
|
||||||
* @returns {Promise<CatalogItemController[]>} - Array of matching catalog item controllers
|
* @returns {Promise<CatalogItemController[]>} - Array of matching catalog item controllers
|
||||||
*/
|
*/
|
||||||
async search(
|
async search(
|
||||||
query: string,
|
query: string,
|
||||||
page: number,
|
page: number,
|
||||||
rpp: number,
|
rpp: number,
|
||||||
opts?: { includeInactive?: boolean },
|
opts?: CatalogFilterOpts,
|
||||||
): Promise<CatalogItemController[]> {
|
): Promise<CatalogItemController[]> {
|
||||||
const skip = (Math.max(page, 1) - 1) * rpp;
|
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||||
const take = rpp;
|
const take = rpp;
|
||||||
|
|
||||||
|
const filterWhere = buildFilterWhere(opts) ?? {};
|
||||||
|
|
||||||
const items = await prisma.catalogItem.findMany({
|
const items = await prisma.catalogItem.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(opts?.includeInactive ? {} : { inactive: false }),
|
...filterWhere,
|
||||||
OR: [
|
OR: [
|
||||||
{ identifier: { contains: query, mode: "insensitive" } },
|
{ identifier: { contains: query, mode: "insensitive" } },
|
||||||
{ name: { contains: query, mode: "insensitive" } },
|
{ name: { contains: query, mode: "insensitive" } },
|
||||||
@@ -120,17 +218,80 @@ export const procurement = {
|
|||||||
/**
|
/**
|
||||||
* Count Catalog Items
|
* 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
|
* @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({
|
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
|
* Link Catalog Items
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { prisma } from "../constants";
|
|||||||
import { SessionTokensObject } from "../controllers/SessionController";
|
import { SessionTokensObject } from "../controllers/SessionController";
|
||||||
import UserController from "../controllers/UserController";
|
import UserController from "../controllers/UserController";
|
||||||
import { fetchMicrosoftUser } from "../modules/fetchMicrosoftUser";
|
import { fetchMicrosoftUser } from "../modules/fetchMicrosoftUser";
|
||||||
|
import { findCwIdentifierByEmail } from "../modules/cw-utils/members/fetchAllMembers";
|
||||||
import { events } from "../modules/globalEvents";
|
import { events } from "../modules/globalEvents";
|
||||||
import { sessions } from "./sessions";
|
import { sessions } from "./sessions";
|
||||||
import * as msal from "@azure/msal-node";
|
import * as msal from "@azure/msal-node";
|
||||||
@@ -90,12 +91,18 @@ export const users = {
|
|||||||
async createUser(token: string): Promise<UserController> {
|
async createUser(token: string): Promise<UserController> {
|
||||||
const msData = await fetchMicrosoftUser(token);
|
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({
|
const newUser = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
userId: msData.id,
|
userId: msData.id,
|
||||||
email: msData.mail,
|
email: msData.mail,
|
||||||
name: `${msData.givenName} ${msData.surname}`,
|
name: `${msData.givenName} ${msData.surname}`,
|
||||||
login: msData.userPrincipalName,
|
login: msData.userPrincipalName,
|
||||||
|
cwIdentifier,
|
||||||
token,
|
token,
|
||||||
},
|
},
|
||||||
include: { roles: true },
|
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 { connectWiseApi } from "../../constants";
|
||||||
import { Company } from "../../types/ConnectWiseTypes";
|
import { Company } from "../../types/ConnectWiseTypes";
|
||||||
|
import { withCwRetry } from "./withCwRetry";
|
||||||
|
|
||||||
export const fetchCwCompanyById = async (
|
export const fetchCwCompanyById = async (
|
||||||
companyId: number,
|
companyId: number,
|
||||||
): Promise<Company | null> => {
|
): Promise<Company | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await connectWiseApi.get(
|
const response = await withCwRetry(
|
||||||
`/company/companies/${companyId}`,
|
() => connectWiseApi.get(`/company/companies/${companyId}`),
|
||||||
|
{
|
||||||
|
label: `fetchCompany#${companyId}`,
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelayMs: 1_500,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
CWOpportunity,
|
CWOpportunity,
|
||||||
CWOpportunitySummary,
|
CWOpportunitySummary,
|
||||||
|
CWForecast,
|
||||||
CWForecastItem,
|
CWForecastItem,
|
||||||
|
CWForecastItemCreate,
|
||||||
CWOpportunityNote,
|
CWOpportunityNote,
|
||||||
|
CWOpportunityNoteCreate,
|
||||||
|
CWOpportunityNoteUpdate,
|
||||||
CWOpportunityContact,
|
CWOpportunityContact,
|
||||||
} from "./opportunity.types";
|
} 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(
|
const response = await connectWiseApi.get(
|
||||||
`/sales/opportunities/${opportunityId}/forecast`,
|
`/sales/opportunities/${opportunityId}/forecast`,
|
||||||
);
|
);
|
||||||
return response.data;
|
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
|
* Fetch Opportunity Notes
|
||||||
*
|
*
|
||||||
@@ -129,6 +264,69 @@ export const opportunityCw = {
|
|||||||
return response.data;
|
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
|
* Fetch Opportunity Contacts
|
||||||
*
|
*
|
||||||
@@ -142,4 +340,20 @@ export const opportunityCw = {
|
|||||||
);
|
);
|
||||||
return response.data;
|
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>;
|
_info?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CWCustomField {
|
export interface CWCustomField {
|
||||||
id: number;
|
id: number;
|
||||||
caption: string;
|
caption: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -103,16 +103,72 @@ export interface CWOpportunityInfo {
|
|||||||
|
|
||||||
export interface CWForecastItem {
|
export interface CWForecastItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
forecastDescription: string;
|
||||||
opportunity: CWReference;
|
opportunity: CWReference;
|
||||||
forecastType: string;
|
quantity: number;
|
||||||
forecastMonth: string;
|
status: CWReference;
|
||||||
|
catalogItem?: {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
_info?: Record<string, string>;
|
||||||
|
};
|
||||||
|
productDescription: string;
|
||||||
|
productClass: string;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
forecastPercentage: number;
|
margin: number;
|
||||||
status: CWReference;
|
percentage: number;
|
||||||
includedFlag: boolean;
|
includeFlag: boolean;
|
||||||
linkedFlag: boolean;
|
quoteWerksQuantity: number;
|
||||||
|
forecastType: string;
|
||||||
|
linkFlag: boolean;
|
||||||
|
recurringRevenue: number;
|
||||||
|
recurringCost: number;
|
||||||
|
cycles: number;
|
||||||
recurringFlag: boolean;
|
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>;
|
_info?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +183,18 @@ export interface CWOpportunityNote {
|
|||||||
_info?: Record<string, string>;
|
_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 {
|
export interface CWOpportunityContact {
|
||||||
id: number;
|
id: number;
|
||||||
opportunity: CWReference;
|
opportunity: CWReference;
|
||||||
@@ -138,6 +206,26 @@ export interface CWOpportunityContact {
|
|||||||
_info?: Record<string, string>;
|
_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 {
|
export interface CWOpportunitySummary {
|
||||||
id: number;
|
id: number;
|
||||||
_info?: Record<string, string>;
|
_info?: Record<string, string>;
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ export const refreshCatalog = async () => {
|
|||||||
description: item.description,
|
description: item.description,
|
||||||
customerDescription: item.customerDescription,
|
customerDescription: item.customerDescription,
|
||||||
internalNotes: item.notes,
|
internalNotes: item.notes,
|
||||||
|
category: item.category?.name,
|
||||||
|
categoryCwId: item.category?.id,
|
||||||
|
subcategory: item.subcategory?.name,
|
||||||
|
subcategoryCwId: item.subcategory?.id,
|
||||||
manufacturer: item.manufacturer?.name,
|
manufacturer: item.manufacturer?.name,
|
||||||
manufactureCwId: item.manufacturer?.id,
|
manufactureCwId: item.manufacturer?.id,
|
||||||
partNumber: item.manufacturerPartNumber,
|
partNumber: item.manufacturerPartNumber,
|
||||||
@@ -115,6 +119,10 @@ export const refreshCatalog = async () => {
|
|||||||
description: item.description,
|
description: item.description,
|
||||||
customerDescription: item.customerDescription,
|
customerDescription: item.customerDescription,
|
||||||
internalNotes: item.notes,
|
internalNotes: item.notes,
|
||||||
|
category: item.category?.name,
|
||||||
|
categoryCwId: item.category?.id,
|
||||||
|
subcategory: item.subcategory?.name,
|
||||||
|
subcategoryCwId: item.subcategory?.id,
|
||||||
manufacturer: item.manufacturer?.name,
|
manufacturer: item.manufacturer?.name,
|
||||||
manufactureCwId: item.manufacturer?.id,
|
manufactureCwId: item.manufacturer?.id,
|
||||||
partNumber: item.manufacturerPartNumber,
|
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));
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user