Compare commits

...

11 Commits

Author SHA1 Message Date
HoloPanio 6d935e7180 feat: Redis opportunity cache, CW API retry/logging, adaptive TTLs
- Add Redis-backed opportunity cache with background refresh (30s interval)
- Fix concurrency bug: use lazy thunks instead of eager promises for batching
- Add withCwRetry utility with exponential backoff for transient CW errors
- Add adaptive TTL algorithms (primary, sub-resource, products) based on opportunity activity
- Add include query param on GET /sales/opportunities/:id (notes,contacts,products)
- Add opt-in CW API logger (LOG_CW_API env var) with timestamped files in cw-api-logs/
- Add debug-scripts/analyze-cw-calls.py for API call analysis
- Add computeSubResourceCacheTTL and computeProductsCacheTTL algorithms with tests
- Increase CW API timeout from 15s to 30s
- Unblock cache refresh from startup chain (remove await)
- Prioritize recently updated opportunities in refresh cycle
- Add CACHING.md documentation
- Update API_ROUTES.md with caching details and include param
- Update copilot instructions to require CACHING.md sync
- Add dev:log script for CW API call logging during development
2026-03-02 23:23:24 -06:00
HoloPanio fe71248e88 perf: cache-only strategy for list views, cache-then-cw for single fetch
- Add data-source hierarchy to opportunity manager (cache-only, cache-then-cw, cw-first)
- fetchPages/search/fetchByCompany use cache-only: Redis → DB (no CW calls)
- fetchItem uses cache-then-cw by default, cw-first when fresh=true
- Add idleTimeout: 255 to Bun.serve to prevent request timeouts
- Map CW status 57 (04. Confirmed Quote) to Active equivalency
- Add computeCacheTTL algorithm and opportunityCache module
2026-03-02 21:12:44 -06:00
HoloPanio 7411310083 fix: add migration for missing columns (cwIdentifier, catalog categories, productSequence) 2026-03-01 18:28:05 -06:00
HoloPanio 30b408e0db feat: add product to opportunity route, local product sequencing
- Add POST /v1/sales/opportunities/:identifier/products with field-level permission gating
- Add CWForecastItemCreate type for forecast item creation
- Store product display order locally (productSequence Int[] on Opportunity)
- Rewrite resequenceProducts to be local-only (no CW PUT, stable IDs)
- Remove reorderProducts CW util (PUT regenerated IDs & broke procurement)
- Update fetchProducts to apply local ordering with CW sequenceNumber fallback
- Add productSequence to OpportunityController.toJson()
- Update API_ROUTES.md, PERMISSIONS.md, PermissionNodes.ts
2026-03-01 18:01:02 -06:00
HoloPanio d7b374f8ab feat: sales activities, forecast products, catalog categories, member cache, procurement filters, and comprehensive tests
New features:
- ActivityController and manager for CW sales activities (CRUD)
- ForecastProductController for opportunity forecast/product lines
- CW member cache with dual-layer (in-memory + Redis) resolution
- Catalog category/subcategory/ecosystem taxonomy module
- Quote statuses type definitions with CW mapping
- User-defined fields (UDF) module with cache and event refresh
- Company sites CW module with serialization
- Procurement manager filters (category, ecosystem, manufacturer, price, stock)
- Opportunity notes CRUD and product line management via CW API
- Opportunity type definitions endpoint

Updates:
- OpportunityController: CW refresh, company hydration, activities, custom fields
- UserController: cwIdentifier field for CW member linking
- CatalogItemController: category/subcategory fields from CW
- PermissionNodes: sales note/product CRUD nodes, subCategories, collectPermissions
- API routes: procurement categories/filters, sales notes/products, opportunity types
- Global events: UDF and member refresh intervals on startup

Tests (414 passing):
- ActivityController, ForecastProductController, OpportunityController unit tests
- UserController cwIdentifier tests
- catalogCategories, companySites, memberCache, procurement module tests
- activityTypes, opportunityTypes, quoteStatuses type tests
- permissionNodes subCategories and getAllPermissionNodes tests
- Updated test setup with redis mock, API method mocks, and builder helpers
2026-03-01 13:19:00 -06:00
HoloPanio 883b648d5e fix: add identifier column migration and fix entrypoint resolve logic
- Add explicit migration for CatalogItem.identifier column
- Fix entrypoint script: resolve only migrations on 'Failed' lines (not all)
- Remove auto-diff generation (use committed migration files instead)
- Remove 2>/dev/null that swallowed migration errors
2026-02-27 17:44:08 -06:00
HoloPanio b787120461 fix: start HTTP server before background init to prevent bad gateway 2026-02-27 17:06:36 -06:00
HoloPanio 1326725995 fix: resolve failed migrations before deploying 2026-02-27 16:26:31 -06:00
HoloPanio 508fa39835 fix: crash loop recovery, auto-migrations, CI test pipeline
- Wrap startup syncs in safeStartup() to prevent crash on external service failure
- Add migrate-entrypoint.sh for auto-generating migrations from schema diff
- Update Dockerfile migration stage to use entrypoint script
- Add test job to build-and-publish workflow (runs before build)
- Add tests.yaml workflow to run tests on every push
- Fix test setup to use real RSA key pair instead of plain strings
- Add test script to package.json
2026-02-27 16:11:28 -06:00
HoloPanio b1f6462ac3 Fix UserController permission serialization and include current updates 2026-02-27 14:38:22 -06:00
HoloPanio 51eb36f4a6 fix: resolve type errors across test suite 2026-02-26 12:49:04 -06:00
165 changed files with 21249 additions and 152 deletions
+16 -10
View File
@@ -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.
--- ---
+20
View File
@@ -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
+25
View File
@@ -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
+2
View File
@@ -1,6 +1,8 @@
# Logs # Logs
logs logs
*.log *.log
*.jsonl
cw-api-logs/
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
+5
View File
@@ -0,0 +1,5 @@
{
"chat.tools.terminal.autoApprove": {
"bun": true
}
}
+1493
View File
File diff suppressed because it is too large Load Diff
+297
View File
@@ -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
View File
@@ -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"]
+148
View File
@@ -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
+222
View File
@@ -115,6 +115,57 @@ Admin-specific UI permissions that control visibility and data loading for admin
- **Combine with API permissions**: A user with an admin UI permission should also have the corresponding API permission (e.g., `role.list`) to actually load data. - **Combine with API permissions**: A user with an admin UI permission should also have the corresponding API permission (e.g., `role.list`) to actually load data.
- **Use wildcards for flexibility**: Grant `ui.navigation.*.view` to allow all navigation sections. - **Use wildcards for flexibility**: Grant `ui.navigation.*.view` to allow all navigation sections.
### Procurement Permissions
| Permission Node | Description | Used In | Dependencies |
| --------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
| `procurement.catalog.fetch` | Fetch a single catalog item | [src/api/procurement/[id]/fetch.ts](src/api/procurement/[id]/fetch.ts) | |
| `procurement.catalog.fetch.many` | Fetch multiple catalog items, count, categories/ecosystems, or filter values | [src/api/procurement/fetchAll.ts](src/api/procurement/fetchAll.ts), [src/api/procurement/count.ts](src/api/procurement/count.ts), [src/api/procurement/categories.ts](src/api/procurement/categories.ts), [src/api/procurement/filters.ts](src/api/procurement/filters.ts) | |
| `procurement.catalog.inventory.refresh` | Refresh on-hand inventory for a catalog item from ConnectWise | [src/api/procurement/[id]/refreshInventory.ts](src/api/procurement/[id]/refreshInventory.ts) | `procurement.catalog.fetch` |
| `procurement.catalog.link` | Link or unlink catalog items to each other | [src/api/procurement/[id]/link.ts](src/api/procurement/[id]/link.ts), [src/api/procurement/[id]/unlink.ts](src/api/procurement/[id]/unlink.ts) | `procurement.catalog.fetch` |
### Sales Permissions
Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (products, notes, contacts) are fetched live from CW.
| Permission Node | Description | Used In | Dependencies |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/products.ts](src/api/sales/[id]/products.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/fetchNote.ts](src/api/sales/[id]/fetchNote.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | |
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.ts) | |
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/[id]/createNote.ts](src/api/sales/[id]/createNote.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/[id]/updateNote.ts](src/api/sales/[id]/updateNote.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<field>` permissions. | [src/api/sales/[id]/addProduct.ts](src/api/sales/[id]/addProduct.ts) | `sales.opportunity.fetch` |
<details>
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
Each submitted field is gated by a `sales.opportunity.product.field.<field>` permission node. Only fields the user has permission for are forwarded to ConnectWise.
| Field Permission Node | Description |
| ----------------------------------------------------- | -------------------------------------------------------- |
| `sales.opportunity.product.field.catalogItem` | Set the catalog item reference |
| `sales.opportunity.product.field.forecastDescription` | Set the forecast description |
| `sales.opportunity.product.field.productDescription` | Set the product description |
| `sales.opportunity.product.field.quantity` | Set the quantity |
| `sales.opportunity.product.field.status` | Set the status reference |
| `sales.opportunity.product.field.productClass` | Set the product class (e.g. Product, Service, Agreement) |
| `sales.opportunity.product.field.forecastType` | Set the forecast type |
| `sales.opportunity.product.field.revenue` | Set the revenue amount |
| `sales.opportunity.product.field.cost` | Set the cost amount |
| `sales.opportunity.product.field.includeFlag` | Set the include flag |
| `sales.opportunity.product.field.linkFlag` | Set the link flag |
| `sales.opportunity.product.field.recurringFlag` | Set the recurring flag |
| `sales.opportunity.product.field.taxableFlag` | Set the taxable flag |
| `sales.opportunity.product.field.recurringRevenue` | Set the recurring revenue amount |
| `sales.opportunity.product.field.recurringCost` | Set the recurring cost amount |
| `sales.opportunity.product.field.cycles` | Set the number of recurring cycles |
| `sales.opportunity.product.field.sequenceNumber` | Set the sequence number (display order) |
</details>
### UniFi Permissions ### UniFi Permissions
Permissions for accessing and managing UniFi network infrastructure. The `unifi.access` permission is a gate permission required for **all** UniFi routes. Permissions for accessing and managing UniFi network infrastructure. The `unifi.access` permission is a gate permission required for **all** UniFi routes.
@@ -152,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:
+17
View File
@@ -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=="],
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./tests/setup.ts"]
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""
Analyze ConnectWise API call logs.
Looks for the most recent log file in cw-api-logs/ by default,
or accepts an explicit path as an argument.
Usage:
python3 analyze-cw-calls.py # latest file in cw-api-logs/
python3 analyze-cw-calls.py cw-api-logs/specific.jsonl
"""
import json
import sys
import os
import glob
import statistics
from collections import Counter, defaultdict
from datetime import datetime, timedelta
# ── Colours ──────────────────────────────────────────────────────────────────
RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
def colour_duration(ms: float) -> str:
if ms >= 10_000:
return f"{RED}{ms:,.0f}ms{RESET}"
if ms >= 5_000:
return f"{YELLOW}{ms:,.0f}ms{RESET}"
return f"{GREEN}{ms:,.0f}ms{RESET}"
def header(title: str) -> str:
return f"\n{BOLD}{CYAN}{'' * 60}\n {title}\n{'' * 60}{RESET}"
# ── Resolve log file ────────────────────────────────────────────────────────
def find_latest_log() -> str:
"""Find the most recent .jsonl file in cw-api-logs/."""
log_dir = os.path.join(os.getcwd(), "cw-api-logs")
files = sorted(glob.glob(os.path.join(log_dir, "*.jsonl")))
if not files:
print(f"{RED}No log files found in cw-api-logs/{RESET}")
print(f"Run {BOLD}bun run dev:log{RESET} to start logging CW API calls.")
sys.exit(1)
return files[-1]
if len(sys.argv) > 1:
log_path = sys.argv[1]
else:
log_path = find_latest_log()
print(f"{DIM}Reading: {log_path}{RESET}")
entries = []
parse_errors = 0
with open(log_path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
parse_errors += 1
if not entries:
print("No entries found. Check the log file path.")
sys.exit(1)
# ── Derived fields ───────────────────────────────────────────────────────────
durations = [e["durationMs"] for e in entries]
errors = [e for e in entries if e.get("error")]
successes = [e for e in entries if not e.get("error")]
timestamps = [datetime.fromisoformat(e["timestamp"].replace("Z", "+00:00")) for e in entries]
time_span = (timestamps[-1] - timestamps[0]) if len(timestamps) > 1 else timedelta(0)
# Normalise the URL to a route pattern for grouping
def normalise_url(url: str) -> str:
parts = url.split("?")[0].rstrip("/").split("/")
normalised = []
for p in parts:
if p.isdigit():
normalised.append(":id")
else:
normalised.append(p)
return "/".join(normalised)
# ── 1. Overview ──────────────────────────────────────────────────────────────
print(header("OVERVIEW"))
print(f" Log file : {log_path}")
print(f" Total calls : {BOLD}{len(entries):,}{RESET}")
print(f" Successes : {GREEN}{len(successes):,}{RESET}")
print(f" Failures : {RED}{len(errors):,}{RESET} ({len(errors)/len(entries)*100:.1f}%)")
print(f" Time span : {time_span}")
if time_span.total_seconds() > 0:
rps = len(entries) / time_span.total_seconds()
print(f" Avg req/sec : {rps:.2f}")
if parse_errors:
print(f" Parse errors : {YELLOW}{parse_errors}{RESET}")
# ── 2. Duration stats ───────────────────────────────────────────────────────
print(header("DURATION STATS (all calls)"))
sorted_dur = sorted(durations)
p50 = sorted_dur[len(sorted_dur) * 50 // 100]
p90 = sorted_dur[len(sorted_dur) * 90 // 100]
p95 = sorted_dur[len(sorted_dur) * 95 // 100]
p99 = sorted_dur[len(sorted_dur) * 99 // 100]
print(f" Min : {colour_duration(min(durations))}")
print(f" Max : {colour_duration(max(durations))}")
print(f" Mean : {colour_duration(statistics.mean(durations))}")
print(f" Median (p50) : {colour_duration(p50)}")
print(f" p90 : {colour_duration(p90)}")
print(f" p95 : {colour_duration(p95)}")
print(f" p99 : {colour_duration(p99)}")
print(f" Std dev : {statistics.stdev(durations):,.0f}ms" if len(durations) > 1 else "")
# Duration buckets
buckets = {"<500ms": 0, "500ms-1s": 0, "1-3s": 0, "3-5s": 0, "5-10s": 0, "10-20s": 0, "20s+": 0}
for d in durations:
if d < 500: buckets["<500ms"] += 1
elif d < 1000: buckets["500ms-1s"] += 1
elif d < 3000: buckets["1-3s"] += 1
elif d < 5000: buckets["3-5s"] += 1
elif d < 10000: buckets["5-10s"] += 1
elif d < 20000: buckets["10-20s"] += 1
else: buckets["20s+"] += 1
print(f"\n {BOLD}Distribution:{RESET}")
max_bar = 40
max_count = max(buckets.values()) if buckets else 1
for label, count in buckets.items():
bar_len = int(count / max_count * max_bar) if max_count else 0
pct = count / len(durations) * 100
bar = "" * bar_len
clr = GREEN if "500" in label or "<" in label else (YELLOW if "1-3" in label or "3-5" in label else RED)
print(f" {label:>10s} {clr}{bar}{RESET} {count:>5,} ({pct:5.1f}%)")
# ── 3. Errors breakdown ─────────────────────────────────────────────────────
print(header("ERROR BREAKDOWN"))
if not errors:
print(f" {GREEN}No errors! 🎉{RESET}")
else:
error_codes = Counter()
for e in errors:
err_str = e.get("error", "unknown")
code = err_str.split(":")[0] if ":" in err_str else err_str
error_codes[code] += 1
for code, count in error_codes.most_common():
print(f" {RED}{code:<30s}{RESET} {count:>5,} ({count/len(entries)*100:.1f}%)")
# Errored URLs
errored_urls = Counter(normalise_url(e["url"]) for e in errors)
print(f"\n {BOLD}Top errored endpoints:{RESET}")
for url, count in errored_urls.most_common(10):
print(f" {count:>5,} {url}")
# ── 4. Slowest individual calls ─────────────────────────────────────────────
print(header("TOP 20 SLOWEST CALLS"))
slowest = sorted(entries, key=lambda e: e["durationMs"], reverse=True)[:20]
for i, e in enumerate(slowest, 1):
status = e.get("status") or f"{RED}ERR{RESET}"
err_tag = f" {RED}[{e['error'].split(':')[0]}]{RESET}" if e.get("error") else ""
print(f" {i:>2}. {colour_duration(e['durationMs']):>20s} {e['method']:>4s} {e['url'][:60]:<60s} {DIM}{status}{RESET}{err_tag}")
# ── 5. Per-endpoint stats ───────────────────────────────────────────────────
print(header("PER-ENDPOINT STATS (by route pattern)"))
by_route = defaultdict(list)
for e in entries:
route = normalise_url(e["url"])
by_route[route].append(e)
# Sort by total time spent descending (most impactful)
route_stats = []
for route, calls in by_route.items():
durs = [c["durationMs"] for c in calls]
errs = sum(1 for c in calls if c.get("error"))
sorted_d = sorted(durs)
route_stats.append({
"route": route,
"count": len(calls),
"errors": errs,
"total_ms": sum(durs),
"mean": statistics.mean(durs),
"p50": sorted_d[len(sorted_d) * 50 // 100],
"p95": sorted_d[len(sorted_d) * 95 // 100],
"max": max(durs),
})
route_stats.sort(key=lambda r: r["total_ms"], reverse=True)
print(f" {'Route':<55s} {'Count':>6s} {'Errs':>5s} {'Mean':>8s} {'p50':>8s} {'p95':>8s} {'Max':>8s} {'Total':>10s}")
print(f" {'' * 55} {'' * 6} {'' * 5} {'' * 8} {'' * 8} {'' * 8} {'' * 8} {'' * 10}")
for r in route_stats[:25]:
err_str = f"{RED}{r['errors']}{RESET}" if r['errors'] else f"{DIM}0{RESET}"
print(
f" {r['route']:<55s} {r['count']:>6,} {err_str:>14s} "
f"{r['mean']:>7,.0f}ms {r['p50']:>7,.0f}ms {r['p95']:>7,.0f}ms "
f"{r['max']:>7,.0f}ms {r['total_ms']/1000:>8,.1f}s"
)
# ── 6. HTTP method breakdown ────────────────────────────────────────────────
print(header("BY HTTP METHOD"))
by_method = defaultdict(list)
for e in entries:
by_method[e["method"]].append(e["durationMs"])
print(f" {'Method':<8s} {'Count':>7s} {'Mean':>9s} {'p95':>9s} {'Max':>9s}")
print(f" {'' * 8} {'' * 7} {'' * 9} {'' * 9} {'' * 9}")
for method in sorted(by_method.keys()):
durs = by_method[method]
sd = sorted(durs)
print(
f" {method:<8s} {len(durs):>7,} "
f"{statistics.mean(durs):>8,.0f}ms "
f"{sd[len(sd)*95//100]:>8,.0f}ms "
f"{max(durs):>8,.0f}ms"
)
# ── 7. Timeline (calls per minute) ──────────────────────────────────────────
if time_span.total_seconds() > 60:
print(header("TIMELINE (per-minute throughput & errors)"))
by_minute = defaultdict(lambda: {"count": 0, "errors": 0, "dur_sum": 0})
for e in entries:
ts = e["timestamp"][:16] # YYYY-MM-DDTHH:MM
by_minute[ts]["count"] += 1
by_minute[ts]["dur_sum"] += e["durationMs"]
if e.get("error"):
by_minute[ts]["errors"] += 1
for minute in sorted(by_minute.keys()):
m = by_minute[minute]
avg = m["dur_sum"] / m["count"] if m["count"] else 0
err_part = f" {RED}({m['errors']} errs){RESET}" if m["errors"] else ""
bar = "" * min(m["count"] // 5, 50)
avg_clr = colour_duration(avg)
print(f" {minute} {m['count']:>5,} reqs avg {avg_clr:>20s} {bar}{err_part}")
# ── 8. Concurrency hotspots ─────────────────────────────────────────────────
print(header("CONCURRENCY HOTSPOTS (calls starting within 100ms of each other)"))
ts_ms = [int(t.timestamp() * 1000) for t in timestamps]
bursts = []
i = 0
while i < len(ts_ms):
j = i
while j < len(ts_ms) - 1 and ts_ms[j + 1] - ts_ms[j] < 100:
j += 1
burst_size = j - i + 1
if burst_size >= 5:
burst_entries = entries[i:j + 1]
avg_dur = statistics.mean(e["durationMs"] for e in burst_entries)
bursts.append((burst_size, entries[i]["timestamp"], avg_dur, burst_entries))
i = j + 1
bursts.sort(key=lambda b: b[0], reverse=True)
if bursts:
print(f" Found {len(bursts)} burst(s) of ≥5 concurrent requests\n")
for size, ts, avg, _ in bursts[:10]:
print(f" {YELLOW}{size:>3} concurrent{RESET} at {ts} avg {colour_duration(avg)}")
else:
print(f" {GREEN}No major concurrency bursts detected.{RESET}")
# ── 9. Summary / recommendations ────────────────────────────────────────────
print(header("SUMMARY"))
err_rate = len(errors) / len(entries) * 100
slow_5s = sum(1 for d in durations if d >= 5000)
slow_pct = slow_5s / len(entries) * 100
if err_rate > 5:
print(f" {RED}⚠ Error rate is {err_rate:.1f}% — CW API is struggling{RESET}")
elif err_rate > 1:
print(f" {YELLOW}⚠ Error rate is {err_rate:.1f}% — some instability{RESET}")
else:
print(f" {GREEN}✓ Error rate is {err_rate:.1f}% — acceptable{RESET}")
if slow_pct > 10:
print(f" {RED}{slow_5s:,} calls ({slow_pct:.1f}%) took >5s — CW is slow or rate-limiting{RESET}")
elif slow_pct > 2:
print(f" {YELLOW}{slow_5s:,} calls ({slow_pct:.1f}%) took >5s{RESET}")
else:
print(f" {GREEN}✓ Only {slow_5s:,} calls ({slow_pct:.1f}%) over 5s{RESET}")
if bursts:
max_burst = max(b[0] for b in bursts)
print(f" {YELLOW}⚠ Max concurrency burst: {max_burst} simultaneous requests — consider lowering CONCURRENCY{RESET}")
total_time_s = sum(durations) / 1000
print(f"\n Total wall-clock time spent waiting on CW: {BOLD}{total_time_s:,.1f}s{RESET} ({total_time_s/60:,.1f} min)")
print()
+5
View File
@@ -47,6 +47,11 @@ export type Company = Prisma.CompanyModel
* *
*/ */
export type CatalogItem = Prisma.CatalogItemModel export type CatalogItem = Prisma.CatalogItemModel
/**
* Model Opportunity
*
*/
export type Opportunity = Prisma.OpportunityModel
/** /**
* Model CredentialType * Model CredentialType
* *
+5
View File
@@ -69,6 +69,11 @@ export type Company = Prisma.CompanyModel
* *
*/ */
export type CatalogItem = Prisma.CatalogItemModel export type CatalogItem = Prisma.CatalogItemModel
/**
* Model Opportunity
*
*/
export type Opportunity = Prisma.OpportunityModel
/** /**
* Model CredentialType * Model CredentialType
* *
File diff suppressed because one or more lines are too long
+136 -1
View File
@@ -390,6 +390,7 @@ export const ModelName = {
UnifiSite: 'UnifiSite', UnifiSite: 'UnifiSite',
Company: 'Company', Company: 'Company',
CatalogItem: 'CatalogItem', CatalogItem: 'CatalogItem',
Opportunity: 'Opportunity',
CredentialType: 'CredentialType', CredentialType: 'CredentialType',
SecureValue: 'SecureValue', SecureValue: 'SecureValue',
Credential: 'Credential' Credential: 'Credential'
@@ -408,7 +409,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { meta: {
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "credentialType" | "secureValue" | "credential" modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential"
txIsolationLevel: TransactionIsolationLevel txIsolationLevel: TransactionIsolationLevel
} }
model: { model: {
@@ -856,6 +857,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
} }
} }
} }
Opportunity: {
payload: Prisma.$OpportunityPayload<ExtArgs>
fields: Prisma.OpportunityFieldRefs
operations: {
findUnique: {
args: Prisma.OpportunityFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload> | null
}
findUniqueOrThrow: {
args: Prisma.OpportunityFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>
}
findFirst: {
args: Prisma.OpportunityFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload> | null
}
findFirstOrThrow: {
args: Prisma.OpportunityFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>
}
findMany: {
args: Prisma.OpportunityFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>[]
}
create: {
args: Prisma.OpportunityCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>
}
createMany: {
args: Prisma.OpportunityCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.OpportunityCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>[]
}
delete: {
args: Prisma.OpportunityDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>
}
update: {
args: Prisma.OpportunityUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>
}
deleteMany: {
args: Prisma.OpportunityDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.OpportunityUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.OpportunityUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>[]
}
upsert: {
args: Prisma.OpportunityUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>
}
aggregate: {
args: Prisma.OpportunityAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateOpportunity>
}
groupBy: {
args: Prisma.OpportunityGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.OpportunityGroupByOutputType>[]
}
count: {
args: Prisma.OpportunityCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.OpportunityCountAggregateOutputType> | number
}
}
}
CredentialType: { CredentialType: {
payload: Prisma.$CredentialTypePayload<ExtArgs> payload: Prisma.$CredentialTypePayload<ExtArgs>
fields: Prisma.CredentialTypeFieldRefs fields: Prisma.CredentialTypeFieldRefs
@@ -1138,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',
@@ -1186,10 +1262,15 @@ export type CompanyScalarFieldEnum = (typeof CompanyScalarFieldEnum)[keyof typeo
export const CatalogItemScalarFieldEnum = { export const CatalogItemScalarFieldEnum = {
id: 'id', id: 'id',
cwCatalogId: 'cwCatalogId', cwCatalogId: 'cwCatalogId',
identifier: 'identifier',
name: 'name', name: 'name',
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',
@@ -1209,6 +1290,59 @@ export const CatalogItemScalarFieldEnum = {
export type CatalogItemScalarFieldEnum = (typeof CatalogItemScalarFieldEnum)[keyof typeof CatalogItemScalarFieldEnum] export type CatalogItemScalarFieldEnum = (typeof CatalogItemScalarFieldEnum)[keyof typeof CatalogItemScalarFieldEnum]
export const OpportunityScalarFieldEnum = {
id: 'id',
cwOpportunityId: 'cwOpportunityId',
name: 'name',
notes: 'notes',
typeName: 'typeName',
typeCwId: 'typeCwId',
stageName: 'stageName',
stageCwId: 'stageCwId',
statusName: 'statusName',
statusCwId: 'statusCwId',
priorityName: 'priorityName',
priorityCwId: 'priorityCwId',
ratingName: 'ratingName',
ratingCwId: 'ratingCwId',
source: 'source',
campaignName: 'campaignName',
campaignCwId: 'campaignCwId',
primarySalesRepName: 'primarySalesRepName',
primarySalesRepIdentifier: 'primarySalesRepIdentifier',
primarySalesRepCwId: 'primarySalesRepCwId',
secondarySalesRepName: 'secondarySalesRepName',
secondarySalesRepIdentifier: 'secondarySalesRepIdentifier',
secondarySalesRepCwId: 'secondarySalesRepCwId',
companyCwId: 'companyCwId',
companyName: 'companyName',
contactCwId: 'contactCwId',
contactName: 'contactName',
siteCwId: 'siteCwId',
siteName: 'siteName',
customerPO: 'customerPO',
totalSalesTax: 'totalSalesTax',
locationName: 'locationName',
locationCwId: 'locationCwId',
departmentName: 'departmentName',
departmentCwId: 'departmentCwId',
expectedCloseDate: 'expectedCloseDate',
pipelineChangeDate: 'pipelineChangeDate',
dateBecameLead: 'dateBecameLead',
closedDate: 'closedDate',
closedFlag: 'closedFlag',
closedByName: 'closedByName',
closedByCwId: 'closedByCwId',
companyId: 'companyId',
productSequence: 'productSequence',
cwLastUpdated: 'cwLastUpdated',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type OpportunityScalarFieldEnum = (typeof OpportunityScalarFieldEnum)[keyof typeof OpportunityScalarFieldEnum]
export const CredentialTypeScalarFieldEnum = { export const CredentialTypeScalarFieldEnum = {
id: 'id', id: 'id',
name: 'name', name: 'name',
@@ -1473,6 +1607,7 @@ export type GlobalOmitConfig = {
unifiSite?: Prisma.UnifiSiteOmit unifiSite?: Prisma.UnifiSiteOmit
company?: Prisma.CompanyOmit company?: Prisma.CompanyOmit
catalogItem?: Prisma.CatalogItemOmit catalogItem?: Prisma.CatalogItemOmit
opportunity?: Prisma.OpportunityOmit
credentialType?: Prisma.CredentialTypeOmit credentialType?: Prisma.CredentialTypeOmit
secureValue?: Prisma.SecureValueOmit secureValue?: Prisma.SecureValueOmit
credential?: Prisma.CredentialOmit credential?: Prisma.CredentialOmit
@@ -57,6 +57,7 @@ export const ModelName = {
UnifiSite: 'UnifiSite', UnifiSite: 'UnifiSite',
Company: 'Company', Company: 'Company',
CatalogItem: 'CatalogItem', CatalogItem: 'CatalogItem',
Opportunity: 'Opportunity',
CredentialType: 'CredentialType', CredentialType: 'CredentialType',
SecureValue: 'SecureValue', SecureValue: 'SecureValue',
Credential: 'Credential' Credential: 'Credential'
@@ -99,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',
@@ -147,10 +149,15 @@ export type CompanyScalarFieldEnum = (typeof CompanyScalarFieldEnum)[keyof typeo
export const CatalogItemScalarFieldEnum = { export const CatalogItemScalarFieldEnum = {
id: 'id', id: 'id',
cwCatalogId: 'cwCatalogId', cwCatalogId: 'cwCatalogId',
identifier: 'identifier',
name: 'name', name: 'name',
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',
@@ -170,6 +177,59 @@ export const CatalogItemScalarFieldEnum = {
export type CatalogItemScalarFieldEnum = (typeof CatalogItemScalarFieldEnum)[keyof typeof CatalogItemScalarFieldEnum] export type CatalogItemScalarFieldEnum = (typeof CatalogItemScalarFieldEnum)[keyof typeof CatalogItemScalarFieldEnum]
export const OpportunityScalarFieldEnum = {
id: 'id',
cwOpportunityId: 'cwOpportunityId',
name: 'name',
notes: 'notes',
typeName: 'typeName',
typeCwId: 'typeCwId',
stageName: 'stageName',
stageCwId: 'stageCwId',
statusName: 'statusName',
statusCwId: 'statusCwId',
priorityName: 'priorityName',
priorityCwId: 'priorityCwId',
ratingName: 'ratingName',
ratingCwId: 'ratingCwId',
source: 'source',
campaignName: 'campaignName',
campaignCwId: 'campaignCwId',
primarySalesRepName: 'primarySalesRepName',
primarySalesRepIdentifier: 'primarySalesRepIdentifier',
primarySalesRepCwId: 'primarySalesRepCwId',
secondarySalesRepName: 'secondarySalesRepName',
secondarySalesRepIdentifier: 'secondarySalesRepIdentifier',
secondarySalesRepCwId: 'secondarySalesRepCwId',
companyCwId: 'companyCwId',
companyName: 'companyName',
contactCwId: 'contactCwId',
contactName: 'contactName',
siteCwId: 'siteCwId',
siteName: 'siteName',
customerPO: 'customerPO',
totalSalesTax: 'totalSalesTax',
locationName: 'locationName',
locationCwId: 'locationCwId',
departmentName: 'departmentName',
departmentCwId: 'departmentCwId',
expectedCloseDate: 'expectedCloseDate',
pipelineChangeDate: 'pipelineChangeDate',
dateBecameLead: 'dateBecameLead',
closedDate: 'closedDate',
closedFlag: 'closedFlag',
closedByName: 'closedByName',
closedByCwId: 'closedByCwId',
companyId: 'companyId',
productSequence: 'productSequence',
cwLastUpdated: 'cwLastUpdated',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type OpportunityScalarFieldEnum = (typeof OpportunityScalarFieldEnum)[keyof typeof OpportunityScalarFieldEnum]
export const CredentialTypeScalarFieldEnum = { export const CredentialTypeScalarFieldEnum = {
id: 'id', id: 'id',
name: 'name', name: 'name',
+1
View File
@@ -14,6 +14,7 @@ export type * from './models/Role.ts'
export type * from './models/UnifiSite.ts' export type * from './models/UnifiSite.ts'
export type * from './models/Company.ts' export type * from './models/Company.ts'
export type * from './models/CatalogItem.ts' export type * from './models/CatalogItem.ts'
export type * from './models/Opportunity.ts'
export type * from './models/CredentialType.ts' export type * from './models/CredentialType.ts'
export type * from './models/SecureValue.ts' export type * from './models/SecureValue.ts'
export type * from './models/Credential.ts' export type * from './models/Credential.ts'
+209 -2
View File
@@ -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
@@ -47,10 +51,15 @@ export type CatalogItemSumAggregateOutputType = {
export type CatalogItemMinAggregateOutputType = { export type CatalogItemMinAggregateOutputType = {
id: string | null id: string | null
cwCatalogId: number | null cwCatalogId: number | null
identifier: string | null
name: string | null name: string | null
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
@@ -70,10 +79,15 @@ export type CatalogItemMinAggregateOutputType = {
export type CatalogItemMaxAggregateOutputType = { export type CatalogItemMaxAggregateOutputType = {
id: string | null id: string | null
cwCatalogId: number | null cwCatalogId: number | null
identifier: string | null
name: string | null name: string | null
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
@@ -93,10 +107,15 @@ export type CatalogItemMaxAggregateOutputType = {
export type CatalogItemCountAggregateOutputType = { export type CatalogItemCountAggregateOutputType = {
id: number id: number
cwCatalogId: number cwCatalogId: number
identifier: number
name: number name: number
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
@@ -117,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
@@ -126,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
@@ -136,10 +159,15 @@ export type CatalogItemSumAggregateInputType = {
export type CatalogItemMinAggregateInputType = { export type CatalogItemMinAggregateInputType = {
id?: true id?: true
cwCatalogId?: true cwCatalogId?: true
identifier?: true
name?: true name?: true
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
@@ -159,10 +187,15 @@ export type CatalogItemMinAggregateInputType = {
export type CatalogItemMaxAggregateInputType = { export type CatalogItemMaxAggregateInputType = {
id?: true id?: true
cwCatalogId?: true cwCatalogId?: true
identifier?: true
name?: true name?: true
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
@@ -182,10 +215,15 @@ export type CatalogItemMaxAggregateInputType = {
export type CatalogItemCountAggregateInputType = { export type CatalogItemCountAggregateInputType = {
id?: true id?: true
cwCatalogId?: true cwCatalogId?: true
identifier?: true
name?: true name?: true
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
@@ -292,10 +330,15 @@ export type CatalogItemGroupByArgs<ExtArgs extends runtime.Types.Extensions.Inte
export type CatalogItemGroupByOutputType = { export type CatalogItemGroupByOutputType = {
id: string id: string
cwCatalogId: number cwCatalogId: number
identifier: string | null
name: string name: string
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
@@ -338,10 +381,15 @@ export type CatalogItemWhereInput = {
NOT?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[] NOT?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[]
id?: Prisma.StringFilter<"CatalogItem"> | string id?: Prisma.StringFilter<"CatalogItem"> | string
cwCatalogId?: Prisma.IntFilter<"CatalogItem"> | number cwCatalogId?: Prisma.IntFilter<"CatalogItem"> | number
identifier?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
name?: Prisma.StringFilter<"CatalogItem"> | string name?: Prisma.StringFilter<"CatalogItem"> | string
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
@@ -363,10 +411,15 @@ export type CatalogItemWhereInput = {
export type CatalogItemOrderByWithRelationInput = { export type CatalogItemOrderByWithRelationInput = {
id?: Prisma.SortOrder id?: Prisma.SortOrder
cwCatalogId?: Prisma.SortOrder cwCatalogId?: Prisma.SortOrder
identifier?: Prisma.SortOrderInput | Prisma.SortOrder
name?: Prisma.SortOrder name?: Prisma.SortOrder
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
@@ -388,6 +441,7 @@ export type CatalogItemOrderByWithRelationInput = {
export type CatalogItemWhereUniqueInput = Prisma.AtLeast<{ export type CatalogItemWhereUniqueInput = Prisma.AtLeast<{
id?: string id?: string
cwCatalogId?: number cwCatalogId?: number
identifier?: string
AND?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[] AND?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[]
OR?: Prisma.CatalogItemWhereInput[] OR?: Prisma.CatalogItemWhereInput[]
NOT?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[] NOT?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[]
@@ -395,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
@@ -411,15 +469,20 @@ export type CatalogItemWhereUniqueInput = Prisma.AtLeast<{
updatedAt?: Prisma.DateTimeFilter<"CatalogItem"> | Date | string updatedAt?: Prisma.DateTimeFilter<"CatalogItem"> | Date | string
linkedItems?: Prisma.CatalogItemListRelationFilter linkedItems?: Prisma.CatalogItemListRelationFilter
linkedTo?: Prisma.CatalogItemListRelationFilter linkedTo?: Prisma.CatalogItemListRelationFilter
}, "id" | "cwCatalogId"> }, "id" | "cwCatalogId" | "identifier">
export type CatalogItemOrderByWithAggregationInput = { export type CatalogItemOrderByWithAggregationInput = {
id?: Prisma.SortOrder id?: Prisma.SortOrder
cwCatalogId?: Prisma.SortOrder cwCatalogId?: Prisma.SortOrder
identifier?: Prisma.SortOrderInput | Prisma.SortOrder
name?: Prisma.SortOrder name?: Prisma.SortOrder
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
@@ -447,10 +510,15 @@ export type CatalogItemScalarWhereWithAggregatesInput = {
NOT?: Prisma.CatalogItemScalarWhereWithAggregatesInput | Prisma.CatalogItemScalarWhereWithAggregatesInput[] NOT?: Prisma.CatalogItemScalarWhereWithAggregatesInput | Prisma.CatalogItemScalarWhereWithAggregatesInput[]
id?: Prisma.StringWithAggregatesFilter<"CatalogItem"> | string id?: Prisma.StringWithAggregatesFilter<"CatalogItem"> | string
cwCatalogId?: Prisma.IntWithAggregatesFilter<"CatalogItem"> | number cwCatalogId?: Prisma.IntWithAggregatesFilter<"CatalogItem"> | number
identifier?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
name?: Prisma.StringWithAggregatesFilter<"CatalogItem"> | string name?: Prisma.StringWithAggregatesFilter<"CatalogItem"> | string
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
@@ -470,10 +538,15 @@ export type CatalogItemScalarWhereWithAggregatesInput = {
export type CatalogItemCreateInput = { export type CatalogItemCreateInput = {
id?: string id?: string
cwCatalogId: number cwCatalogId: number
identifier?: string | null
name: string name: string
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
@@ -495,10 +568,15 @@ export type CatalogItemCreateInput = {
export type CatalogItemUncheckedCreateInput = { export type CatalogItemUncheckedCreateInput = {
id?: string id?: string
cwCatalogId: number cwCatalogId: number
identifier?: string | null
name: string name: string
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
@@ -520,10 +598,15 @@ export type CatalogItemUncheckedCreateInput = {
export type CatalogItemUpdateInput = { export type CatalogItemUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string id?: Prisma.StringFieldUpdateOperationsInput | string
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -545,10 +628,15 @@ export type CatalogItemUpdateInput = {
export type CatalogItemUncheckedUpdateInput = { export type CatalogItemUncheckedUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string id?: Prisma.StringFieldUpdateOperationsInput | string
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -570,10 +658,15 @@ export type CatalogItemUncheckedUpdateInput = {
export type CatalogItemCreateManyInput = { export type CatalogItemCreateManyInput = {
id?: string id?: string
cwCatalogId: number cwCatalogId: number
identifier?: string | null
name: string name: string
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
@@ -593,10 +686,15 @@ export type CatalogItemCreateManyInput = {
export type CatalogItemUpdateManyMutationInput = { export type CatalogItemUpdateManyMutationInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string id?: Prisma.StringFieldUpdateOperationsInput | string
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -616,10 +714,15 @@ export type CatalogItemUpdateManyMutationInput = {
export type CatalogItemUncheckedUpdateManyInput = { export type CatalogItemUncheckedUpdateManyInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string id?: Prisma.StringFieldUpdateOperationsInput | string
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -649,10 +752,15 @@ export type CatalogItemOrderByRelationAggregateInput = {
export type CatalogItemCountOrderByAggregateInput = { export type CatalogItemCountOrderByAggregateInput = {
id?: Prisma.SortOrder id?: Prisma.SortOrder
cwCatalogId?: Prisma.SortOrder cwCatalogId?: Prisma.SortOrder
identifier?: Prisma.SortOrder
name?: Prisma.SortOrder name?: Prisma.SortOrder
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
@@ -671,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
@@ -681,10 +791,15 @@ export type CatalogItemAvgOrderByAggregateInput = {
export type CatalogItemMaxOrderByAggregateInput = { export type CatalogItemMaxOrderByAggregateInput = {
id?: Prisma.SortOrder id?: Prisma.SortOrder
cwCatalogId?: Prisma.SortOrder cwCatalogId?: Prisma.SortOrder
identifier?: Prisma.SortOrder
name?: Prisma.SortOrder name?: Prisma.SortOrder
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
@@ -704,10 +819,15 @@ export type CatalogItemMaxOrderByAggregateInput = {
export type CatalogItemMinOrderByAggregateInput = { export type CatalogItemMinOrderByAggregateInput = {
id?: Prisma.SortOrder id?: Prisma.SortOrder
cwCatalogId?: Prisma.SortOrder cwCatalogId?: Prisma.SortOrder
identifier?: Prisma.SortOrder
name?: Prisma.SortOrder name?: Prisma.SortOrder
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
@@ -726,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
@@ -828,10 +950,15 @@ export type CatalogItemUncheckedUpdateManyWithoutLinkedItemsNestedInput = {
export type CatalogItemCreateWithoutLinkedToInput = { export type CatalogItemCreateWithoutLinkedToInput = {
id?: string id?: string
cwCatalogId: number cwCatalogId: number
identifier?: string | null
name: string name: string
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
@@ -852,10 +979,15 @@ export type CatalogItemCreateWithoutLinkedToInput = {
export type CatalogItemUncheckedCreateWithoutLinkedToInput = { export type CatalogItemUncheckedCreateWithoutLinkedToInput = {
id?: string id?: string
cwCatalogId: number cwCatalogId: number
identifier?: string | null
name: string name: string
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
@@ -881,10 +1013,15 @@ export type CatalogItemCreateOrConnectWithoutLinkedToInput = {
export type CatalogItemCreateWithoutLinkedItemsInput = { export type CatalogItemCreateWithoutLinkedItemsInput = {
id?: string id?: string
cwCatalogId: number cwCatalogId: number
identifier?: string | null
name: string name: string
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
@@ -905,10 +1042,15 @@ export type CatalogItemCreateWithoutLinkedItemsInput = {
export type CatalogItemUncheckedCreateWithoutLinkedItemsInput = { export type CatalogItemUncheckedCreateWithoutLinkedItemsInput = {
id?: string id?: string
cwCatalogId: number cwCatalogId: number
identifier?: string | null
name: string name: string
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
@@ -953,10 +1095,15 @@ export type CatalogItemScalarWhereInput = {
NOT?: Prisma.CatalogItemScalarWhereInput | Prisma.CatalogItemScalarWhereInput[] NOT?: Prisma.CatalogItemScalarWhereInput | Prisma.CatalogItemScalarWhereInput[]
id?: Prisma.StringFilter<"CatalogItem"> | string id?: Prisma.StringFilter<"CatalogItem"> | string
cwCatalogId?: Prisma.IntFilter<"CatalogItem"> | number cwCatalogId?: Prisma.IntFilter<"CatalogItem"> | number
identifier?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
name?: Prisma.StringFilter<"CatalogItem"> | string name?: Prisma.StringFilter<"CatalogItem"> | string
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
@@ -992,10 +1139,15 @@ export type CatalogItemUpdateManyWithWhereWithoutLinkedItemsInput = {
export type CatalogItemUpdateWithoutLinkedToInput = { export type CatalogItemUpdateWithoutLinkedToInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string id?: Prisma.StringFieldUpdateOperationsInput | string
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -1016,10 +1168,15 @@ export type CatalogItemUpdateWithoutLinkedToInput = {
export type CatalogItemUncheckedUpdateWithoutLinkedToInput = { export type CatalogItemUncheckedUpdateWithoutLinkedToInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string id?: Prisma.StringFieldUpdateOperationsInput | string
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -1040,10 +1197,15 @@ export type CatalogItemUncheckedUpdateWithoutLinkedToInput = {
export type CatalogItemUncheckedUpdateManyWithoutLinkedToInput = { export type CatalogItemUncheckedUpdateManyWithoutLinkedToInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string id?: Prisma.StringFieldUpdateOperationsInput | string
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -1063,10 +1225,15 @@ export type CatalogItemUncheckedUpdateManyWithoutLinkedToInput = {
export type CatalogItemUpdateWithoutLinkedItemsInput = { export type CatalogItemUpdateWithoutLinkedItemsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string id?: Prisma.StringFieldUpdateOperationsInput | string
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -1087,10 +1254,15 @@ export type CatalogItemUpdateWithoutLinkedItemsInput = {
export type CatalogItemUncheckedUpdateWithoutLinkedItemsInput = { export type CatalogItemUncheckedUpdateWithoutLinkedItemsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string id?: Prisma.StringFieldUpdateOperationsInput | string
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -1111,10 +1283,15 @@ export type CatalogItemUncheckedUpdateWithoutLinkedItemsInput = {
export type CatalogItemUncheckedUpdateManyWithoutLinkedItemsInput = { export type CatalogItemUncheckedUpdateManyWithoutLinkedItemsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string id?: Prisma.StringFieldUpdateOperationsInput | string
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
name?: Prisma.StringFieldUpdateOperationsInput | string name?: Prisma.StringFieldUpdateOperationsInput | string
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
@@ -1174,10 +1351,15 @@ export type CatalogItemCountOutputTypeCountLinkedToArgs<ExtArgs extends runtime.
export type CatalogItemSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type CatalogItemSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
cwCatalogId?: boolean cwCatalogId?: boolean
identifier?: boolean
name?: boolean name?: boolean
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
@@ -1200,10 +1382,15 @@ export type CatalogItemSelect<ExtArgs extends runtime.Types.Extensions.InternalA
export type CatalogItemSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type CatalogItemSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
cwCatalogId?: boolean cwCatalogId?: boolean
identifier?: boolean
name?: boolean name?: boolean
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
@@ -1223,10 +1410,15 @@ export type CatalogItemSelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
export type CatalogItemSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type CatalogItemSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
cwCatalogId?: boolean cwCatalogId?: boolean
identifier?: boolean
name?: boolean name?: boolean
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
@@ -1246,10 +1438,15 @@ export type CatalogItemSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
export type CatalogItemSelectScalar = { export type CatalogItemSelectScalar = {
id?: boolean id?: boolean
cwCatalogId?: boolean cwCatalogId?: boolean
identifier?: boolean
name?: boolean name?: boolean
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
@@ -1266,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" | "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>
@@ -1284,10 +1481,15 @@ export type $CatalogItemPayload<ExtArgs extends runtime.Types.Extensions.Interna
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
cwCatalogId: number cwCatalogId: number
identifier: string | null
name: string name: string
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
@@ -1729,10 +1931,15 @@ export interface Prisma__CatalogItemClient<T, Null = never, ExtArgs extends runt
export interface CatalogItemFieldRefs { export interface CatalogItemFieldRefs {
readonly id: Prisma.FieldRef<"CatalogItem", 'String'> readonly id: Prisma.FieldRef<"CatalogItem", 'String'>
readonly cwCatalogId: Prisma.FieldRef<"CatalogItem", 'Int'> readonly cwCatalogId: Prisma.FieldRef<"CatalogItem", 'Int'>
readonly identifier: Prisma.FieldRef<"CatalogItem", 'String'>
readonly name: Prisma.FieldRef<"CatalogItem", 'String'> readonly name: Prisma.FieldRef<"CatalogItem", 'String'>
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'>
+128
View File
@@ -226,6 +226,7 @@ export type CompanyWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"Company"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Company"> | Date | string
credentials?: Prisma.CredentialListRelationFilter credentials?: Prisma.CredentialListRelationFilter
unifiSites?: Prisma.UnifiSiteListRelationFilter unifiSites?: Prisma.UnifiSiteListRelationFilter
opportunities?: Prisma.OpportunityListRelationFilter
} }
export type CompanyOrderByWithRelationInput = { export type CompanyOrderByWithRelationInput = {
@@ -237,6 +238,7 @@ export type CompanyOrderByWithRelationInput = {
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
credentials?: Prisma.CredentialOrderByRelationAggregateInput credentials?: Prisma.CredentialOrderByRelationAggregateInput
unifiSites?: Prisma.UnifiSiteOrderByRelationAggregateInput unifiSites?: Prisma.UnifiSiteOrderByRelationAggregateInput
opportunities?: Prisma.OpportunityOrderByRelationAggregateInput
} }
export type CompanyWhereUniqueInput = Prisma.AtLeast<{ export type CompanyWhereUniqueInput = Prisma.AtLeast<{
@@ -251,6 +253,7 @@ export type CompanyWhereUniqueInput = Prisma.AtLeast<{
updatedAt?: Prisma.DateTimeFilter<"Company"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Company"> | Date | string
credentials?: Prisma.CredentialListRelationFilter credentials?: Prisma.CredentialListRelationFilter
unifiSites?: Prisma.UnifiSiteListRelationFilter unifiSites?: Prisma.UnifiSiteListRelationFilter
opportunities?: Prisma.OpportunityListRelationFilter
}, "id" | "cw_CompanyId" | "cw_Identifier"> }, "id" | "cw_CompanyId" | "cw_Identifier">
export type CompanyOrderByWithAggregationInput = { export type CompanyOrderByWithAggregationInput = {
@@ -288,6 +291,7 @@ export type CompanyCreateInput = {
updatedAt?: Date | string updatedAt?: Date | string
credentials?: Prisma.CredentialCreateNestedManyWithoutCompanyInput credentials?: Prisma.CredentialCreateNestedManyWithoutCompanyInput
unifiSites?: Prisma.UnifiSiteCreateNestedManyWithoutCompanyInput unifiSites?: Prisma.UnifiSiteCreateNestedManyWithoutCompanyInput
opportunities?: Prisma.OpportunityCreateNestedManyWithoutCompanyInput
} }
export type CompanyUncheckedCreateInput = { export type CompanyUncheckedCreateInput = {
@@ -299,6 +303,7 @@ export type CompanyUncheckedCreateInput = {
updatedAt?: Date | string updatedAt?: Date | string
credentials?: Prisma.CredentialUncheckedCreateNestedManyWithoutCompanyInput credentials?: Prisma.CredentialUncheckedCreateNestedManyWithoutCompanyInput
unifiSites?: Prisma.UnifiSiteUncheckedCreateNestedManyWithoutCompanyInput unifiSites?: Prisma.UnifiSiteUncheckedCreateNestedManyWithoutCompanyInput
opportunities?: Prisma.OpportunityUncheckedCreateNestedManyWithoutCompanyInput
} }
export type CompanyUpdateInput = { export type CompanyUpdateInput = {
@@ -310,6 +315,7 @@ export type CompanyUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
credentials?: Prisma.CredentialUpdateManyWithoutCompanyNestedInput credentials?: Prisma.CredentialUpdateManyWithoutCompanyNestedInput
unifiSites?: Prisma.UnifiSiteUpdateManyWithoutCompanyNestedInput unifiSites?: Prisma.UnifiSiteUpdateManyWithoutCompanyNestedInput
opportunities?: Prisma.OpportunityUpdateManyWithoutCompanyNestedInput
} }
export type CompanyUncheckedUpdateInput = { export type CompanyUncheckedUpdateInput = {
@@ -321,6 +327,7 @@ export type CompanyUncheckedUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
credentials?: Prisma.CredentialUncheckedUpdateManyWithoutCompanyNestedInput credentials?: Prisma.CredentialUncheckedUpdateManyWithoutCompanyNestedInput
unifiSites?: Prisma.UnifiSiteUncheckedUpdateManyWithoutCompanyNestedInput unifiSites?: Prisma.UnifiSiteUncheckedUpdateManyWithoutCompanyNestedInput
opportunities?: Prisma.OpportunityUncheckedUpdateManyWithoutCompanyNestedInput
} }
export type CompanyCreateManyInput = { export type CompanyCreateManyInput = {
@@ -419,6 +426,22 @@ export type IntFieldUpdateOperationsInput = {
divide?: number divide?: number
} }
export type CompanyCreateNestedOneWithoutOpportunitiesInput = {
create?: Prisma.XOR<Prisma.CompanyCreateWithoutOpportunitiesInput, Prisma.CompanyUncheckedCreateWithoutOpportunitiesInput>
connectOrCreate?: Prisma.CompanyCreateOrConnectWithoutOpportunitiesInput
connect?: Prisma.CompanyWhereUniqueInput
}
export type CompanyUpdateOneWithoutOpportunitiesNestedInput = {
create?: Prisma.XOR<Prisma.CompanyCreateWithoutOpportunitiesInput, Prisma.CompanyUncheckedCreateWithoutOpportunitiesInput>
connectOrCreate?: Prisma.CompanyCreateOrConnectWithoutOpportunitiesInput
upsert?: Prisma.CompanyUpsertWithoutOpportunitiesInput
disconnect?: Prisma.CompanyWhereInput | boolean
delete?: Prisma.CompanyWhereInput | boolean
connect?: Prisma.CompanyWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.CompanyUpdateToOneWithWhereWithoutOpportunitiesInput, Prisma.CompanyUpdateWithoutOpportunitiesInput>, Prisma.CompanyUncheckedUpdateWithoutOpportunitiesInput>
}
export type CompanyCreateNestedOneWithoutCredentialsInput = { export type CompanyCreateNestedOneWithoutCredentialsInput = {
create?: Prisma.XOR<Prisma.CompanyCreateWithoutCredentialsInput, Prisma.CompanyUncheckedCreateWithoutCredentialsInput> create?: Prisma.XOR<Prisma.CompanyCreateWithoutCredentialsInput, Prisma.CompanyUncheckedCreateWithoutCredentialsInput>
connectOrCreate?: Prisma.CompanyCreateOrConnectWithoutCredentialsInput connectOrCreate?: Prisma.CompanyCreateOrConnectWithoutCredentialsInput
@@ -441,6 +464,7 @@ export type CompanyCreateWithoutUnifiSitesInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
credentials?: Prisma.CredentialCreateNestedManyWithoutCompanyInput credentials?: Prisma.CredentialCreateNestedManyWithoutCompanyInput
opportunities?: Prisma.OpportunityCreateNestedManyWithoutCompanyInput
} }
export type CompanyUncheckedCreateWithoutUnifiSitesInput = { export type CompanyUncheckedCreateWithoutUnifiSitesInput = {
@@ -451,6 +475,7 @@ export type CompanyUncheckedCreateWithoutUnifiSitesInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
credentials?: Prisma.CredentialUncheckedCreateNestedManyWithoutCompanyInput credentials?: Prisma.CredentialUncheckedCreateNestedManyWithoutCompanyInput
opportunities?: Prisma.OpportunityUncheckedCreateNestedManyWithoutCompanyInput
} }
export type CompanyCreateOrConnectWithoutUnifiSitesInput = { export type CompanyCreateOrConnectWithoutUnifiSitesInput = {
@@ -477,6 +502,7 @@ export type CompanyUpdateWithoutUnifiSitesInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
credentials?: Prisma.CredentialUpdateManyWithoutCompanyNestedInput credentials?: Prisma.CredentialUpdateManyWithoutCompanyNestedInput
opportunities?: Prisma.OpportunityUpdateManyWithoutCompanyNestedInput
} }
export type CompanyUncheckedUpdateWithoutUnifiSitesInput = { export type CompanyUncheckedUpdateWithoutUnifiSitesInput = {
@@ -487,6 +513,67 @@ export type CompanyUncheckedUpdateWithoutUnifiSitesInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
credentials?: Prisma.CredentialUncheckedUpdateManyWithoutCompanyNestedInput credentials?: Prisma.CredentialUncheckedUpdateManyWithoutCompanyNestedInput
opportunities?: Prisma.OpportunityUncheckedUpdateManyWithoutCompanyNestedInput
}
export type CompanyCreateWithoutOpportunitiesInput = {
id?: string
name: string
cw_CompanyId: number
cw_Identifier: string
createdAt?: Date | string
updatedAt?: Date | string
credentials?: Prisma.CredentialCreateNestedManyWithoutCompanyInput
unifiSites?: Prisma.UnifiSiteCreateNestedManyWithoutCompanyInput
}
export type CompanyUncheckedCreateWithoutOpportunitiesInput = {
id?: string
name: string
cw_CompanyId: number
cw_Identifier: string
createdAt?: Date | string
updatedAt?: Date | string
credentials?: Prisma.CredentialUncheckedCreateNestedManyWithoutCompanyInput
unifiSites?: Prisma.UnifiSiteUncheckedCreateNestedManyWithoutCompanyInput
}
export type CompanyCreateOrConnectWithoutOpportunitiesInput = {
where: Prisma.CompanyWhereUniqueInput
create: Prisma.XOR<Prisma.CompanyCreateWithoutOpportunitiesInput, Prisma.CompanyUncheckedCreateWithoutOpportunitiesInput>
}
export type CompanyUpsertWithoutOpportunitiesInput = {
update: Prisma.XOR<Prisma.CompanyUpdateWithoutOpportunitiesInput, Prisma.CompanyUncheckedUpdateWithoutOpportunitiesInput>
create: Prisma.XOR<Prisma.CompanyCreateWithoutOpportunitiesInput, Prisma.CompanyUncheckedCreateWithoutOpportunitiesInput>
where?: Prisma.CompanyWhereInput
}
export type CompanyUpdateToOneWithWhereWithoutOpportunitiesInput = {
where?: Prisma.CompanyWhereInput
data: Prisma.XOR<Prisma.CompanyUpdateWithoutOpportunitiesInput, Prisma.CompanyUncheckedUpdateWithoutOpportunitiesInput>
}
export type CompanyUpdateWithoutOpportunitiesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
cw_CompanyId?: Prisma.IntFieldUpdateOperationsInput | number
cw_Identifier?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
credentials?: Prisma.CredentialUpdateManyWithoutCompanyNestedInput
unifiSites?: Prisma.UnifiSiteUpdateManyWithoutCompanyNestedInput
}
export type CompanyUncheckedUpdateWithoutOpportunitiesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
cw_CompanyId?: Prisma.IntFieldUpdateOperationsInput | number
cw_Identifier?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
credentials?: Prisma.CredentialUncheckedUpdateManyWithoutCompanyNestedInput
unifiSites?: Prisma.UnifiSiteUncheckedUpdateManyWithoutCompanyNestedInput
} }
export type CompanyCreateWithoutCredentialsInput = { export type CompanyCreateWithoutCredentialsInput = {
@@ -497,6 +584,7 @@ export type CompanyCreateWithoutCredentialsInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
unifiSites?: Prisma.UnifiSiteCreateNestedManyWithoutCompanyInput unifiSites?: Prisma.UnifiSiteCreateNestedManyWithoutCompanyInput
opportunities?: Prisma.OpportunityCreateNestedManyWithoutCompanyInput
} }
export type CompanyUncheckedCreateWithoutCredentialsInput = { export type CompanyUncheckedCreateWithoutCredentialsInput = {
@@ -507,6 +595,7 @@ export type CompanyUncheckedCreateWithoutCredentialsInput = {
createdAt?: Date | string createdAt?: Date | string
updatedAt?: Date | string updatedAt?: Date | string
unifiSites?: Prisma.UnifiSiteUncheckedCreateNestedManyWithoutCompanyInput unifiSites?: Prisma.UnifiSiteUncheckedCreateNestedManyWithoutCompanyInput
opportunities?: Prisma.OpportunityUncheckedCreateNestedManyWithoutCompanyInput
} }
export type CompanyCreateOrConnectWithoutCredentialsInput = { export type CompanyCreateOrConnectWithoutCredentialsInput = {
@@ -533,6 +622,7 @@ export type CompanyUpdateWithoutCredentialsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
unifiSites?: Prisma.UnifiSiteUpdateManyWithoutCompanyNestedInput unifiSites?: Prisma.UnifiSiteUpdateManyWithoutCompanyNestedInput
opportunities?: Prisma.OpportunityUpdateManyWithoutCompanyNestedInput
} }
export type CompanyUncheckedUpdateWithoutCredentialsInput = { export type CompanyUncheckedUpdateWithoutCredentialsInput = {
@@ -543,6 +633,7 @@ export type CompanyUncheckedUpdateWithoutCredentialsInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
unifiSites?: Prisma.UnifiSiteUncheckedUpdateManyWithoutCompanyNestedInput unifiSites?: Prisma.UnifiSiteUncheckedUpdateManyWithoutCompanyNestedInput
opportunities?: Prisma.OpportunityUncheckedUpdateManyWithoutCompanyNestedInput
} }
@@ -553,11 +644,13 @@ export type CompanyUncheckedUpdateWithoutCredentialsInput = {
export type CompanyCountOutputType = { export type CompanyCountOutputType = {
credentials: number credentials: number
unifiSites: number unifiSites: number
opportunities: number
} }
export type CompanyCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type CompanyCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
credentials?: boolean | CompanyCountOutputTypeCountCredentialsArgs credentials?: boolean | CompanyCountOutputTypeCountCredentialsArgs
unifiSites?: boolean | CompanyCountOutputTypeCountUnifiSitesArgs unifiSites?: boolean | CompanyCountOutputTypeCountUnifiSitesArgs
opportunities?: boolean | CompanyCountOutputTypeCountOpportunitiesArgs
} }
/** /**
@@ -584,6 +677,13 @@ export type CompanyCountOutputTypeCountUnifiSitesArgs<ExtArgs extends runtime.Ty
where?: Prisma.UnifiSiteWhereInput where?: Prisma.UnifiSiteWhereInput
} }
/**
* CompanyCountOutputType without action
*/
export type CompanyCountOutputTypeCountOpportunitiesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.OpportunityWhereInput
}
export type CompanySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type CompanySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -594,6 +694,7 @@ export type CompanySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
updatedAt?: boolean updatedAt?: boolean
credentials?: boolean | Prisma.Company$credentialsArgs<ExtArgs> credentials?: boolean | Prisma.Company$credentialsArgs<ExtArgs>
unifiSites?: boolean | Prisma.Company$unifiSitesArgs<ExtArgs> unifiSites?: boolean | Prisma.Company$unifiSitesArgs<ExtArgs>
opportunities?: boolean | Prisma.Company$opportunitiesArgs<ExtArgs>
_count?: boolean | Prisma.CompanyCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.CompanyCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["company"]> }, ExtArgs["result"]["company"]>
@@ -628,6 +729,7 @@ export type CompanyOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs =
export type CompanyInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type CompanyInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
credentials?: boolean | Prisma.Company$credentialsArgs<ExtArgs> credentials?: boolean | Prisma.Company$credentialsArgs<ExtArgs>
unifiSites?: boolean | Prisma.Company$unifiSitesArgs<ExtArgs> unifiSites?: boolean | Prisma.Company$unifiSitesArgs<ExtArgs>
opportunities?: boolean | Prisma.Company$opportunitiesArgs<ExtArgs>
_count?: boolean | Prisma.CompanyCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.CompanyCountOutputTypeDefaultArgs<ExtArgs>
} }
export type CompanyIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {} export type CompanyIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
@@ -638,6 +740,7 @@ export type $CompanyPayload<ExtArgs extends runtime.Types.Extensions.InternalArg
objects: { objects: {
credentials: Prisma.$CredentialPayload<ExtArgs>[] credentials: Prisma.$CredentialPayload<ExtArgs>[]
unifiSites: Prisma.$UnifiSitePayload<ExtArgs>[] unifiSites: Prisma.$UnifiSitePayload<ExtArgs>[]
opportunities: Prisma.$OpportunityPayload<ExtArgs>[]
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
@@ -1042,6 +1145,7 @@ export interface Prisma__CompanyClient<T, Null = never, ExtArgs extends runtime.
readonly [Symbol.toStringTag]: "PrismaPromise" readonly [Symbol.toStringTag]: "PrismaPromise"
credentials<T extends Prisma.Company$credentialsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Company$credentialsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CredentialPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> credentials<T extends Prisma.Company$credentialsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Company$credentialsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CredentialPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
unifiSites<T extends Prisma.Company$unifiSitesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Company$unifiSitesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UnifiSitePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> unifiSites<T extends Prisma.Company$unifiSitesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Company$unifiSitesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UnifiSitePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
opportunities<T extends Prisma.Company$opportunitiesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Company$opportunitiesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$OpportunityPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/** /**
* Attaches callbacks for the resolution and/or rejection of the Promise. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1512,6 +1616,30 @@ export type Company$unifiSitesArgs<ExtArgs extends runtime.Types.Extensions.Inte
distinct?: Prisma.UnifiSiteScalarFieldEnum | Prisma.UnifiSiteScalarFieldEnum[] distinct?: Prisma.UnifiSiteScalarFieldEnum | Prisma.UnifiSiteScalarFieldEnum[]
} }
/**
* Company.opportunities
*/
export type Company$opportunitiesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Opportunity
*/
select?: Prisma.OpportunitySelect<ExtArgs> | null
/**
* Omit specific fields from the Opportunity
*/
omit?: Prisma.OpportunityOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.OpportunityInclude<ExtArgs> | null
where?: Prisma.OpportunityWhereInput
orderBy?: Prisma.OpportunityOrderByWithRelationInput | Prisma.OpportunityOrderByWithRelationInput[]
cursor?: Prisma.OpportunityWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.OpportunityScalarFieldEnum | Prisma.OpportunityScalarFieldEnum[]
}
/** /**
* Company without action * Company without action
*/ */
File diff suppressed because it is too large Load Diff
+39 -1
View File
@@ -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'>
+4
View File
@@ -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",
+23
View File
@@ -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,57 @@
-- CreateTable
CREATE TABLE "Opportunity" (
"id" TEXT NOT NULL,
"cwOpportunityId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"notes" TEXT,
"typeName" TEXT,
"typeCwId" INTEGER,
"stageName" TEXT,
"stageCwId" INTEGER,
"statusName" TEXT,
"statusCwId" INTEGER,
"priorityName" TEXT,
"priorityCwId" INTEGER,
"ratingName" TEXT,
"ratingCwId" INTEGER,
"source" TEXT,
"campaignName" TEXT,
"campaignCwId" INTEGER,
"primarySalesRepName" TEXT,
"primarySalesRepIdentifier" TEXT,
"primarySalesRepCwId" INTEGER,
"secondarySalesRepName" TEXT,
"secondarySalesRepIdentifier" TEXT,
"secondarySalesRepCwId" INTEGER,
"companyCwId" INTEGER,
"companyName" TEXT,
"contactCwId" INTEGER,
"contactName" TEXT,
"siteCwId" INTEGER,
"siteName" TEXT,
"customerPO" TEXT,
"totalSalesTax" DOUBLE PRECISION NOT NULL DEFAULT 0,
"locationName" TEXT,
"locationCwId" INTEGER,
"departmentName" TEXT,
"departmentCwId" INTEGER,
"expectedCloseDate" TIMESTAMP(3),
"pipelineChangeDate" TIMESTAMP(3),
"dateBecameLead" TIMESTAMP(3),
"closedDate" TIMESTAMP(3),
"closedFlag" BOOLEAN NOT NULL DEFAULT false,
"closedByName" TEXT,
"closedByCwId" INTEGER,
"companyId" TEXT,
"cwLastUpdated" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Opportunity_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Opportunity_cwOpportunityId_key" ON "Opportunity"("cwOpportunityId");
-- AddForeignKey
ALTER TABLE "Opportunity" ADD CONSTRAINT "Opportunity_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -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[];
+82 -2
View File
@@ -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?
@@ -75,8 +77,9 @@ model Company {
cw_CompanyId Int @unique cw_CompanyId Int @unique
cw_Identifier String @unique cw_Identifier String @unique
credentials Credential[] credentials Credential[]
unifiSites UnifiSite[] unifiSites UnifiSite[]
opportunities Opportunity[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -85,6 +88,7 @@ model Company {
model CatalogItem { model CatalogItem {
id String @id @default(cuid()) id String @id @default(cuid())
cwCatalogId Int @unique cwCatalogId Int @unique
identifier String? @unique
name String name String
description String? description String?
customerDescription String? customerDescription String?
@@ -93,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?
@@ -115,6 +124,77 @@ model CatalogItem {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Opportunity {
id String @id @default(cuid())
cwOpportunityId Int @unique
name String
notes String?
// Stage / status / priority / type / rating stored as JSON references
// so we don't need separate lookup tables for CW enums
typeName String?
typeCwId Int?
stageName String?
stageCwId Int?
statusName String?
statusCwId Int?
priorityName String?
priorityCwId Int?
ratingName String?
ratingCwId Int?
source String?
campaignName String?
campaignCwId Int?
// Sales rep references
primarySalesRepName String?
primarySalesRepIdentifier String?
primarySalesRepCwId Int?
secondarySalesRepName String?
secondarySalesRepIdentifier String?
secondarySalesRepCwId Int?
// Company / contact / site
companyCwId Int?
companyName String?
contactCwId Int?
contactName String?
siteCwId Int?
siteName String?
customerPO String?
// Financials
totalSalesTax Float @default(0)
// Location / department
locationName String?
locationCwId Int?
departmentName String?
departmentCwId Int?
// Dates
expectedCloseDate DateTime?
pipelineChangeDate DateTime?
dateBecameLead DateTime?
closedDate DateTime?
closedFlag Boolean @default(false)
closedByName String?
closedByCwId Int?
// Internal relation to Company (optional, linked by cwCompanyId)
companyId String?
company Company? @relation(fields: [companyId], references: [id])
// Local product sequence — array of CW forecast item IDs in display order.
// When present, fetchProducts() uses this order instead of CW sequenceNumber.
productSequence Int[] @default([])
cwLastUpdated DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CredentialType { model CredentialType {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique
+13 -5
View File
@@ -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 companyData = company.toJson({
includeAddress,
includePrimaryContact,
includeAllContacts,
});
const gatedData = await processObjectValuePerms(
companyData,
"obj.company",
c.get("user"),
);
const response = apiResponse.successful( const response = apiResponse.successful(
"Company Fetched Successfully!", "Company Fetched Successfully!",
company.toJson({ gatedData,
includeAddress,
includePrimaryContact,
includeAllContacts,
}),
); );
return c.json(response, response.status as ContentfulStatusCode); return c.json(response, response.status as ContentfulStatusCode);
}, },
+9 -1
View File
@@ -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);
}, },
+8 -1
View File
@@ -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
+8 -1
View File
@@ -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);
}, },
+12 -3
View File
@@ -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);
}, },
+8 -1
View File
@@ -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);
}, },
+7 -1
View File
@@ -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);
}, },
+8 -1
View File
@@ -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);
}, },
+8 -1
View File
@@ -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);
}, },
+32
View File
@@ -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";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* /v1/procurement/items/:identifier */
export default createRoute(
"get",
["/items/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const includeLinkedItems = c.req.query("includeLinkedItems") === "true";
const item = await procurement.fetchItem(identifier);
const gatedData = await processObjectValuePerms(
item.toJson({ includeLinkedItems }),
"obj.catalogItem",
c.get("user"),
);
const response = apiResponse.successful(
"Catalog item fetched successfully!",
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["procurement.catalog.fetch"] }),
);
+32
View File
@@ -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";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* GET /v1/procurement/items/:identifier/linked */
export default createRoute(
"get",
["/items/:identifier/linked"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await procurement.fetchItem(identifier);
const linkedItems = item.getLinkedItems().map((linked) => linked.toJson());
const gatedData = await Promise.all(
linkedItems.map((linked) =>
processObjectValuePerms(linked, "obj.catalogItem", c.get("user")),
),
);
const response = apiResponse.successful(
"Linked catalog items fetched successfully!",
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["procurement.catalog.fetch"] }),
);
+28
View File
@@ -0,0 +1,28 @@
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";
import { z } from "zod";
/* POST /v1/procurement/items/:identifier/link */
export default createRoute(
"post",
["/items/:identifier/link"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const schema = z.object({ targetId: z.string() }).strict();
const { targetId } = schema.parse(body);
const item = await procurement.linkItems(identifier, targetId);
const response = apiResponse.successful(
"Catalog item linked successfully!",
item.toJson({ includeLinkedItems: true }),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["procurement.catalog.link"] }),
);
@@ -0,0 +1,25 @@
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/items/:identifier/refresh-inventory */
export default createRoute(
"post",
["/items/:identifier/refresh-inventory"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await procurement.fetchItem(identifier);
await item.refreshInventory();
const response = apiResponse.successful(
"Inventory refreshed successfully!",
item.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["procurement.catalog.inventory.refresh"] }),
);
+28
View File
@@ -0,0 +1,28 @@
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";
import { z } from "zod";
/* POST /v1/procurement/items/:identifier/unlink */
export default createRoute(
"post",
["/items/:identifier/unlink"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const schema = z.object({ targetId: z.string() }).strict();
const { targetId } = schema.parse(body);
const item = await procurement.unlinkItems(identifier, targetId);
const response = apiResponse.successful(
"Catalog item unlinked successfully!",
item.toJson({ includeLinkedItems: true }),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["procurement.catalog.link"] }),
);
+26
View File
@@ -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"] }),
);
+24
View File
@@ -0,0 +1,24 @@
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/count */
export default createRoute(
"get",
["/count"],
async (c) => {
const activeOnly = c.req.query("activeOnly") === "true";
const count = await procurement.count({ activeOnly });
const response = apiResponse.successful(
"Catalog item count fetched successfully!",
{ count },
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
);
+80
View File
@@ -0,0 +1,80 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { procurement, CatalogFilterOpts } from "../../managers/procurement";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* /v1/procurement/items */
export default createRoute(
"get",
["/items"],
async (c) => {
const page = Number(c.req.query("page") ?? 1);
const rpp = Number(c.req.query("rpp") ?? 30);
const search = c.req.query("search") as string;
const includeInactive = c.req.query("includeInactive") === "true";
// Category / filter params
const category = c.req.query("category") as string | undefined;
const subcategory = c.req.query("subcategory") as string | undefined;
const group = c.req.query("group") as string | undefined;
const manufacturer = c.req.query("manufacturer") as string | undefined;
const ecosystem = c.req.query("ecosystem") as string | undefined;
const inStock = c.req.query("inStock") === "true" ? true : undefined;
const minPrice = c.req.query("minPrice")
? Number(c.req.query("minPrice"))
: undefined;
const maxPrice = c.req.query("maxPrice")
? Number(c.req.query("maxPrice"))
: undefined;
const filterOpts: CatalogFilterOpts = {
includeInactive,
category,
subcategory,
group,
manufacturer,
ecosystem,
inStock,
minPrice,
maxPrice,
};
const data = search
? await procurement.search(search, page, rpp, filterOpts)
: await procurement.fetchPages(page, rpp, filterOpts);
const totalRecords = search
? await procurement.countSearch(search, filterOpts)
: await procurement.count(filterOpts);
const gatedData = await Promise.all(
data.map((item) =>
processObjectValuePerms(
item.toJson(),
"obj.catalogItem",
c.get("user"),
),
),
);
const response = apiResponse.successful(
"Catalog items fetched successfully!",
gatedData,
{
pagination: {
previousPage: page <= 1 ? null : page - 1,
currentPage: page,
nextPage: page >= totalRecords / rpp ? null : page + 1,
totalPages: Math.ceil(totalRecords / rpp),
totalRecords,
listedRecords: rpp,
},
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
);
+32
View File
@@ -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"] }),
);
+21
View File
@@ -0,0 +1,21 @@
import { default as fetchAll } from "./fetchAll";
import { default as fetch } from "./[id]/fetch";
import { default as refreshInventory } from "./[id]/refreshInventory";
import { default as link } from "./[id]/link";
import { default as unlink } from "./[id]/unlink";
import { default as fetchLinked } from "./[id]/fetchLinked";
import { default as count } from "./count";
import { default as categories } from "./categories";
import { default as filters } from "./filters";
export {
categories,
count,
fetch,
fetchAll,
fetchLinked,
filters,
link,
refreshInventory,
unlink,
};
+8 -1
View File
@@ -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);
}, },
+10 -3
View File
@@ -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(
role.toJson({ viewPermissions: true }), allRoles.map((role) =>
processObjectValuePerms(
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);
}, },
+7 -2
View File
@@ -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);
}, },
+7
View File
@@ -0,0 +1,7 @@
import { Hono } from "hono";
import * as procurementRoutes from "../procurement";
const procurementRouter = new Hono();
Object.values(procurementRoutes).map((r) => procurementRouter.route("/", r));
export default procurementRouter;
+7
View File
@@ -0,0 +1,7 @@
import { Hono } from "hono";
import * as salesRoutes from "../sales";
const salesRouter = new Hono();
Object.values(salesRoutes).map((r) => salesRouter.route("/", r));
export default salesRouter;
+69
View File
@@ -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"] }),
);
+25
View File
@@ -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/contacts */
export default createRoute(
"get",
["/opportunities/:identifier/contacts"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchRecord(identifier);
const data = await item.fetchContacts();
const response = apiResponse.successful(
"Opportunity contacts fetched successfully!",
data,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
+47
View File
@@ -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"] }),
);
+33
View File
@@ -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"] }),
);
+65
View File
@@ -0,0 +1,65 @@
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";
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products */
export default createRoute(
"get",
["/opportunities/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const includeParam = c.req.query("include") ?? "";
const includes = new Set(
includeParam
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
const item = await opportunities.fetchItem(identifier);
// Eagerly load site data so toJson() includes full site info
await item.fetchSite();
const gatedData = await processObjectValuePerms(
item.toJson(),
"obj.opportunity",
c.get("user"),
);
// Fetch requested sub-resources in parallel
const subResourcePromises: Record<string, Promise<any>> = {};
if (includes.has("notes")) {
subResourcePromises.notes = item.fetchNotes();
}
if (includes.has("contacts")) {
subResourcePromises.contacts = item.fetchContacts();
}
if (includes.has("products")) {
subResourcePromises.products = item
.fetchProducts()
.then((products) => products.map((p) => p.toJson()));
}
const keys = Object.keys(subResourcePromises);
if (keys.length > 0) {
const results = await Promise.all(
keys.map((k) => subResourcePromises[k]),
);
keys.forEach((k, i) => {
(gatedData as any)[k] = results[i];
});
}
const response = apiResponse.successful(
"Opportunity fetched successfully!",
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
+34
View File
@@ -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"] }),
);
+25
View File
@@ -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/notes */
export default createRoute(
"get",
["/opportunities/:identifier/notes"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchRecord(identifier);
const data = await item.fetchNotes();
const response = apiResponse.successful(
"Opportunity notes fetched successfully!",
data,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
+25
View File
@@ -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"] }),
);
+25
View File
@@ -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";
/* POST /v1/sales/opportunities/:identifier/refresh */
export default createRoute(
"post",
["/opportunities/:identifier/refresh"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchItem(identifier);
const refreshed = await item.refreshFromCW();
const response = apiResponse.successful(
"Opportunity refreshed from ConnectWise successfully!",
refreshed.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.refresh"] }),
);
+37
View File
@@ -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"] }),
);
+57
View File
@@ -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"] }),
);
+24
View File
@@ -0,0 +1,24 @@
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/count */
export default createRoute(
"get",
["/opportunities/count"],
async (c) => {
const openOnly = c.req.query("openOnly") === "true";
const count = await opportunities.count({ openOnly });
const response = apiResponse.successful(
"Opportunity count fetched successfully!",
{ count },
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
);
+54
View File
@@ -0,0 +1,54 @@
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";
/* GET /v1/sales/opportunities */
export default createRoute(
"get",
["/opportunities"],
async (c) => {
const page = Number(c.req.query("page") ?? 1);
const rpp = Number(c.req.query("rpp") ?? 30);
const search = c.req.query("search") as string;
const includeClosed = c.req.query("includeClosed") === "true";
const data = search
? await opportunities.search(search, page, rpp, { includeClosed })
: await opportunities.fetchPages(page, rpp, { includeClosed });
const totalRecords = search
? await opportunities.searchCount(search, { includeClosed })
: await opportunities.count({ openOnly: !includeClosed });
const gatedData = await Promise.all(
data.map((item) =>
processObjectValuePerms(
item.toJson(),
"obj.opportunity",
c.get("user"),
),
),
);
const response = apiResponse.successful(
"Opportunities fetched successfully!",
gatedData,
{
pagination: {
previousPage: page <= 1 ? null : page - 1,
currentPage: page,
nextPage: page >= totalRecords / rpp ? null : page + 1,
totalPages: Math.ceil(totalRecords / rpp),
totalRecords,
listedRecords: rpp,
},
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
);
+20
View File
@@ -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"] }),
);
+31
View File
@@ -0,0 +1,31 @@
import { default as fetchAll } from "./fetchAll";
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
import { default as count } from "./count";
import { default as fetch } from "./[id]/fetch";
import { default as refresh } from "./[id]/refresh";
import { default as products } from "./[id]/products";
import { default as addProduct } from "./[id]/addProduct";
import { default as resequenceProducts } from "./[id]/resequenceProducts";
import { default as notes } from "./[id]/notes";
import { default as fetchNote } from "./[id]/fetchNote";
import { default as createNote } from "./[id]/createNote";
import { default as updateNote } from "./[id]/updateNote";
import { default as deleteNote } from "./[id]/deleteNote";
import { default as contacts } from "./[id]/contacts";
export {
addProduct,
count,
fetch,
fetchAll,
fetchOpportunityTypes,
products,
resequenceProducts,
notes,
fetchNote,
createNote,
updateNote,
deleteNote,
contacts,
refresh,
};
+2
View File
@@ -55,6 +55,8 @@ v1.route("/credential-type", require("./routers/credentialTypeRouter").default);
v1.route("/role", require("./routers/roleRouter").default); v1.route("/role", require("./routers/roleRouter").default);
v1.route("/permissions", require("./routers/permissionRouter").default); v1.route("/permissions", require("./routers/permissionRouter").default);
v1.route("/unifi", require("./routers/unifiRouter").default); v1.route("/unifi", require("./routers/unifiRouter").default);
v1.route("/procurement", require("./routers/procurementRouter").default);
v1.route("/sales", require("./routers/salesRouter").default);
app.route("/v1", v1); app.route("/v1", v1);
export default app; export default app;
+9 -1
View File
@@ -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);
}, },
+9 -1
View File
@@ -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);
}, },
+7 -3
View File
@@ -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"] }),
+8 -1
View File
@@ -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);
}, },
+8 -2
View File
@@ -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);
}, },
+12 -2
View File
@@ -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);
}, },
+9
View File
@@ -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
+252
View File
@@ -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,
};
}
}
+231
View File
@@ -0,0 +1,231 @@
import { CatalogItem } from "../../generated/prisma/client";
import { prisma } from "../constants";
import { catalogCw } from "../modules/cw-utils/procurement/catalog";
import { CatalogItem as CWCatalogItem } from "../modules/cw-utils/procurement/catalog.types";
import GenericError from "../Errors/GenericError";
/**
* Catalog Item Controller
*
* This class encapsulates a catalog item entity and provides domain methods
* for accessing, refreshing, and serializing catalog item data. It bridges
* the internal database representation with ConnectWise catalog data.
*/
export class CatalogItemController {
public readonly id: string;
public name: string;
public description: string | null;
public customerDescription: string | null;
public internalNotes: string | null;
public readonly cwCatalogId: number;
public readonly identifier: string | null;
public category: string | null;
public categoryCwId: number | null;
public subcategory: string | null;
public subcategoryCwId: number | null;
public manufacturer: string | null;
public manufactureCwId: number | null;
public partNumber: string | null;
public vendorName: string | null;
public vendorSku: string | null;
public vendorCwId: number | null;
public price: number;
public cost: number;
public inactive: boolean;
public salesTaxable: boolean;
public onHand: number;
public cwLastUpdated: Date | null;
private _linkedItems: CatalogItemController[];
public readonly createdAt: Date;
public updatedAt: Date;
constructor(
itemData: CatalogItem & {
linkedItems?: CatalogItem[];
},
) {
this.id = itemData.id;
this.name = itemData.name;
this.description = itemData.description;
this.customerDescription = itemData.customerDescription;
this.internalNotes = itemData.internalNotes;
this.cwCatalogId = itemData.cwCatalogId;
this.identifier = itemData.identifier;
this.category = itemData.category;
this.categoryCwId = itemData.categoryCwId;
this.subcategory = itemData.subcategory;
this.subcategoryCwId = itemData.subcategoryCwId;
this.manufacturer = itemData.manufacturer;
this.manufactureCwId = itemData.manufactureCwId;
this.partNumber = itemData.partNumber;
this.vendorName = itemData.vendorName;
this.vendorSku = itemData.vendorSku;
this.vendorCwId = itemData.vendorCwId;
this.price = itemData.price;
this.cost = itemData.cost;
this.inactive = itemData.inactive;
this.salesTaxable = itemData.salesTaxable;
this.onHand = itemData.onHand;
this.cwLastUpdated = itemData.cwLastUpdated;
this.createdAt = itemData.createdAt;
this.updatedAt = itemData.updatedAt;
this._linkedItems = (itemData.linkedItems ?? []).map(
(linked) => new CatalogItemController(linked),
);
}
/**
* Refresh Inventory
*
* Fetches the latest on-hand inventory count from ConnectWise
* and updates both the controller state and the database.
*
* @returns {Promise<CatalogItemController>} - The updated controller
*/
public async refreshInventory(): Promise<CatalogItemController> {
const onHand = await catalogCw.fetchInventoryOnHand(this.cwCatalogId);
if (onHand !== this.onHand) {
await prisma.catalogItem.update({
where: { id: this.id },
data: { onHand },
});
this.onHand = onHand;
}
return this;
}
/**
* Fetch Linked Items
*
* Returns the linked catalog items as an array of controllers.
*
* @returns {CatalogItemController[]} - Array of linked item controllers
*/
public getLinkedItems(): CatalogItemController[] {
return this._linkedItems;
}
/**
* Link Item
*
* Links another catalog item to this item. The relationship is bidirectional
* via the Prisma implicit many-to-many.
*
* @param targetId - The internal ID of the catalog item to link
* @returns {Promise<CatalogItemController>} - The updated controller
*/
public async linkItem(targetId: string): Promise<CatalogItemController> {
if (targetId === this.id) {
throw new GenericError({
message: "Cannot link a catalog item to itself",
name: "InvalidLinkTarget",
cause: `Item '${this.id}' cannot be linked to itself`,
status: 400,
});
}
const target = await prisma.catalogItem.findFirst({
where: { id: targetId },
});
if (!target) {
throw new GenericError({
message: "Target catalog item not found",
name: "CatalogItemNotFound",
cause: `No catalog item exists with ID '${targetId}'`,
status: 404,
});
}
const updated = await prisma.catalogItem.update({
where: { id: this.id },
data: {
linkedItems: { connect: { id: targetId } },
},
include: { linkedItems: true },
});
this._linkedItems = (updated.linkedItems ?? []).map(
(linked) => new CatalogItemController(linked),
);
return this;
}
/**
* Unlink Item
*
* Removes the link between this catalog item and another.
*
* @param targetId - The internal ID of the catalog item to unlink
* @returns {Promise<CatalogItemController>} - The updated controller
*/
public async unlinkItem(targetId: string): Promise<CatalogItemController> {
const updated = await prisma.catalogItem.update({
where: { id: this.id },
data: {
linkedItems: { disconnect: { id: targetId } },
},
include: { linkedItems: true },
});
this._linkedItems = (updated.linkedItems ?? []).map(
(linked) => new CatalogItemController(linked),
);
return this;
}
/**
* To JSON
*
* Serializes the catalog item into a safe, API-friendly object.
*
* @param opts - Options to control output
* @returns - A JSON-safe representation of the catalog item
*/
public toJson(opts?: { includeLinkedItems?: boolean }): Record<string, any> {
return {
id: this.id,
cwCatalogId: this.cwCatalogId,
identifier: this.identifier,
name: this.name,
description: this.description,
customerDescription: this.customerDescription,
internalNotes: this.internalNotes,
category: this.category,
categoryCwId: this.categoryCwId,
subcategory: this.subcategory,
subcategoryCwId: this.subcategoryCwId,
manufacturer: this.manufacturer,
manufactureCwId: this.manufactureCwId,
partNumber: this.partNumber,
vendorName: this.vendorName,
vendorSku: this.vendorSku,
vendorCwId: this.vendorCwId,
price: this.price,
cost: this.cost,
inactive: this.inactive,
salesTaxable: this.salesTaxable,
onHand: this.onHand,
cwLastUpdated: this.cwLastUpdated,
linkedItems: opts?.includeLinkedItems
? this._linkedItems.map((item) => item.toJson())
: undefined,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}
+83 -19
View File
@@ -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,9 +22,9 @@ 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; 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;
@@ -96,23 +158,25 @@ export class CompanyController {
}, },
primaryContact: !opts?.includePrimaryContact primaryContact: !opts?.includePrimaryContact
? undefined ? undefined
: { : this.cw_Data?.defaultContact
firstName: this.cw_Data?.defaultContact.firstName, ? {
lastName: this.cw_Data?.defaultContact.lastName, firstName: this.cw_Data.defaultContact.firstName,
cwId: this.cw_Data?.defaultContact.id, lastName: this.cw_Data.defaultContact.lastName,
inactive: this.cw_Data?.defaultContact.inactiveFlag, cwId: this.cw_Data.defaultContact.id,
title: this.cw_Data?.defaultContact.title, inactive: this.cw_Data.defaultContact.inactiveFlag,
phone: this.cw_Data?.defaultContact.defaultPhoneNbr, title: this.cw_Data.defaultContact.title,
email: (() => { phone: this.cw_Data.defaultContact.defaultPhoneNbr,
if (!this.cw_Data?.defaultContact.communicationItems) email: (() => {
return null; if (!this.cw_Data?.defaultContact?.communicationItems)
return ( return null;
this.cw_Data?.defaultContact.communicationItems.find( return (
(v) => v.type.name === "Email", this.cw_Data.defaultContact.communicationItems.find(
)?.value ?? null (v) => v.type.name === "Email",
); )?.value ?? null
})(), );
}, })(),
}
: null,
allContacts: !opts?.includeAllContacts allContacts: !opts?.includeAllContacts
? undefined ? undefined
: this.cw_Data?.allContacts.map((contact) => ({ : this.cw_Data?.allContacts.map((contact) => ({
@@ -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,
};
}
}
+948
View File
@@ -0,0 +1,948 @@
import { Company, Opportunity } from "../../generated/prisma/client";
import { prisma } from "../constants";
import { CompanyController } from "./CompanyController";
import { ActivityController } from "./ActivityController";
import { fetchOpportunity } from "../modules/cw-utils/opportunities/fetchOpportunity";
import { 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
*
* Domain model class that encapsulates an Opportunity entity and provides
* methods for accessing, refreshing from ConnectWise, and serializing
* opportunity data.
*/
export class OpportunityController {
public readonly id: string;
public readonly cwOpportunityId: number;
public name: string;
public notes: string | null;
public typeName: string | null;
public typeCwId: number | null;
public stageName: string | null;
public stageCwId: number | null;
public statusName: string | null;
public statusCwId: number | null;
public priorityName: string | null;
public priorityCwId: number | null;
public ratingName: string | null;
public ratingCwId: number | null;
public source: string | null;
public campaignName: string | null;
public campaignCwId: number | null;
public primarySalesRepName: string | null;
public primarySalesRepIdentifier: string | null;
public primarySalesRepCwId: number | null;
public secondarySalesRepName: string | null;
public secondarySalesRepIdentifier: string | null;
public secondarySalesRepCwId: number | null;
public companyCwId: number | null;
public companyName: string | null;
public contactCwId: number | null;
public contactName: string | null;
public siteCwId: number | null;
public siteName: string | null;
public customerPO: string | null;
public totalSalesTax: number;
public locationName: string | null;
public locationCwId: number | null;
public departmentName: string | null;
public departmentCwId: number | null;
public expectedCloseDate: Date | null;
public pipelineChangeDate: Date | null;
public dateBecameLead: Date | null;
public closedDate: Date | null;
public closedFlag: boolean;
public closedByName: string | null;
public closedByCwId: number | null;
public companyId: string | null;
public cwLastUpdated: Date | null;
// Local product display order — array of CW forecast item IDs.
// When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
public productSequence: number[];
public readonly createdAt: Date;
public updatedAt: Date;
private _company: CompanyController | null = null;
private _siteData: ReturnType<typeof serializeCwSite> | null = null;
private _customFields: CWCustomField[] | null = null;
private _activities: ActivityController[] | null = null;
/** Compute the sub-resource cache TTL from this opportunity's fields. */
private _subResourceTTL(): number | null {
return computeSubResourceCacheTTL({
closedFlag: this.closedFlag,
closedDate: this.closedDate,
expectedCloseDate: this.expectedCloseDate,
lastUpdated: this.cwLastUpdated,
});
}
/** Compute the products-specific cache TTL from this opportunity's fields. */
private _productsTTL(): number | null {
return computeProductsCacheTTL({
closedFlag: this.closedFlag,
closedDate: this.closedDate,
expectedCloseDate: this.expectedCloseDate,
lastUpdated: this.cwLastUpdated,
statusCwId: this.statusCwId,
});
}
constructor(
data: Opportunity & { company?: Company | null },
opts?: {
company?: CompanyController;
customFields?: CWCustomField[];
activities?: ActivityController[];
},
) {
this.id = data.id;
this.cwOpportunityId = data.cwOpportunityId;
this.name = data.name;
this.notes = data.notes;
this.typeName = data.typeName;
this.typeCwId = data.typeCwId;
this.stageName = data.stageName;
this.stageCwId = data.stageCwId;
this.statusName = data.statusName;
this.statusCwId = data.statusCwId;
this.priorityName = data.priorityName;
this.priorityCwId = data.priorityCwId;
this.ratingName = data.ratingName;
this.ratingCwId = data.ratingCwId;
this.source = data.source;
this.campaignName = data.campaignName;
this.campaignCwId = data.campaignCwId;
this.primarySalesRepName = data.primarySalesRepName;
this.primarySalesRepIdentifier = data.primarySalesRepIdentifier;
this.primarySalesRepCwId = data.primarySalesRepCwId;
this.secondarySalesRepName = data.secondarySalesRepName;
this.secondarySalesRepIdentifier = data.secondarySalesRepIdentifier;
this.secondarySalesRepCwId = data.secondarySalesRepCwId;
this.companyCwId = data.companyCwId;
this.companyName = data.companyName;
this.contactCwId = data.contactCwId;
this.contactName = data.contactName;
this.siteCwId = data.siteCwId;
this.siteName = data.siteName;
this.customerPO = data.customerPO;
this.totalSalesTax = data.totalSalesTax;
this.locationName = data.locationName;
this.locationCwId = data.locationCwId;
this.departmentName = data.departmentName;
this.departmentCwId = data.departmentCwId;
this.expectedCloseDate = data.expectedCloseDate;
this.pipelineChangeDate = data.pipelineChangeDate;
this.dateBecameLead = data.dateBecameLead;
this.closedDate = data.closedDate;
this.closedFlag = data.closedFlag;
this.closedByName = data.closedByName;
this.closedByCwId = data.closedByCwId;
this.companyId = data.companyId;
this.cwLastUpdated = data.cwLastUpdated;
this.productSequence = data.productSequence;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
this._company =
opts?.company ??
(data.company ? new CompanyController(data.company) : null);
this._customFields = opts?.customFields ?? null;
this._activities = opts?.activities ?? null;
}
/**
* Fetch Company
*
* Lazily loads the associated CompanyController from the database
* if not already loaded via the Prisma include.
*
* @returns {Promise<CompanyController | null>}
*/
public async fetchCompany(): Promise<CompanyController | null> {
if (this._company) {
await this._company.hydrateCwData();
return this._company;
}
if (!this.companyId) return null;
const companyData = await prisma.company.findUnique({
where: { id: this.companyId },
});
if (!companyData) return null;
this._company = new CompanyController(companyData);
await this._company.hydrateCwData();
return this._company;
}
/**
* Refresh from ConnectWise
*
* Fetches the latest opportunity data from CW and updates
* the local database record and controller state.
*/
public async refreshFromCW(): Promise<OpportunityController> {
const cwData = await fetchOpportunity(this.cwOpportunityId);
const mapped = OpportunityController.mapCwToDb(cwData);
const updated = await prisma.opportunity.update({
where: { id: this.id },
data: mapped,
include: { company: true },
});
return new OpportunityController(updated);
}
/**
* Fetch raw CW data
*
* Returns the raw ConnectWise opportunity object without updating the DB.
*/
public async fetchCwData(): Promise<CWOpportunity> {
return fetchOpportunity(this.cwOpportunityId);
}
/**
* Map CW Opportunity Prisma create/update payload
*
* Static helper used by both the controller and the refresh sync.
*/
public static mapCwToDb(item: CWOpportunity) {
return {
name: item.name,
notes: item.notes ?? null,
typeName: item.type?.name ?? null,
typeCwId: item.type?.id ?? null,
stageName: item.stage?.name ?? null,
stageCwId: item.stage?.id ?? null,
statusName: item.status?.name ?? null,
statusCwId: item.status?.id ?? null,
priorityName: item.priority?.name ?? null,
priorityCwId: item.priority?.id ?? null,
ratingName: item.rating?.name ?? null,
ratingCwId: item.rating?.id ?? null,
source: item.source ?? null,
campaignName: item.campaign?.name ?? null,
campaignCwId: item.campaign?.id ?? null,
primarySalesRepName: item.primarySalesRep?.name ?? null,
primarySalesRepIdentifier: item.primarySalesRep?.identifier ?? null,
primarySalesRepCwId: item.primarySalesRep?.id ?? null,
secondarySalesRepName: item.secondarySalesRep?.name ?? null,
secondarySalesRepIdentifier: item.secondarySalesRep?.identifier ?? null,
secondarySalesRepCwId: item.secondarySalesRep?.id ?? null,
companyCwId: item.company?.id ?? null,
companyName: item.company?.name ?? null,
contactCwId: item.contact?.id ?? null,
contactName: item.contact?.name ?? null,
siteCwId: item.site?.id ?? null,
siteName: item.site?.name ?? null,
customerPO: item.customerPO ?? null,
totalSalesTax: item.totalSalesTax ?? 0,
locationName: item.location?.name ?? null,
locationCwId: item.location?.id ?? null,
departmentName: item.department?.name ?? null,
departmentCwId: item.department?.id ?? null,
expectedCloseDate: item.expectedCloseDate
? new Date(item.expectedCloseDate)
: null,
pipelineChangeDate: item.pipelineChangeDate
? new Date(item.pipelineChangeDate)
: null,
dateBecameLead: item.dateBecameLead
? new Date(item.dateBecameLead)
: null,
closedDate: item.closedDate ? new Date(item.closedDate) : null,
closedFlag: item.closedFlag ?? false,
closedByName: item.closedBy?.name ?? null,
closedByCwId: item.closedBy?.id ?? null,
cwLastUpdated: item._info?.lastUpdated
? new Date(item._info.lastUpdated)
: new Date(),
};
}
/**
* 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
*
* Serializes the opportunity into a safe, API-friendly object.
*/
public toJson(): Record<string, any> {
return {
id: this.id,
cwOpportunityId: this.cwOpportunityId,
name: this.name,
notes: this.notes,
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
stage: this.stageCwId
? { id: this.stageCwId, name: this.stageName }
: null,
status: this.statusCwId
? { id: this.statusCwId, name: this.statusName }
: null,
priority: this.priorityCwId
? { id: this.priorityCwId, name: this.priorityName }
: null,
rating: this.ratingCwId
? { id: this.ratingCwId, name: this.ratingName }
: null,
source: this.source,
campaign: this.campaignCwId
? { id: this.campaignCwId, name: this.campaignName }
: null,
primarySalesRep: this.primarySalesRepCwId
? {
id: this.primarySalesRepCwId,
identifier: this.primarySalesRepIdentifier,
name: this.primarySalesRepName,
}
: null,
secondarySalesRep: this.secondarySalesRepCwId
? {
id: this.secondarySalesRepCwId,
identifier: this.secondarySalesRepIdentifier,
name: this.secondarySalesRepName,
}
: null,
company: this._company
? this._company.toJson({
includeAllContacts: true,
includeAddress: true,
includePrimaryContact: false,
})
: this.companyCwId
? { id: this.companyCwId, name: this.companyName }
: null,
contact: this.contactCwId
? { id: this.contactCwId, name: this.contactName }
: null,
site: this._siteData
? this._siteData
: this.siteCwId
? { id: this.siteCwId, name: this.siteName }
: null,
customerPO: this.customerPO,
totalSalesTax: this.totalSalesTax,
location: this.locationCwId
? { id: this.locationCwId, name: this.locationName }
: null,
department: this.departmentCwId
? { id: this.departmentCwId, name: this.departmentName }
: null,
expectedCloseDate: this.expectedCloseDate,
pipelineChangeDate: this.pipelineChangeDate,
dateBecameLead: this.dateBecameLead,
closedDate: this.closedDate,
closedFlag: this.closedFlag,
closedBy: this.closedByCwId
? { id: this.closedByCwId, name: this.closedByName }
: null,
companyId: this.companyId,
cwLastUpdated: this.cwLastUpdated,
productSequence: this.productSequence,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
customFields: this._customFields ?? [],
activities: this._activities?.map((a) => a.toJson()) ?? [],
};
}
}
+17 -1
View File
@@ -33,6 +33,9 @@ export class RoleController {
private _permissionsToken: string; private _permissionsToken: string;
private _users: (User & { roles: Role[] })[]; private _users: (User & { roles: Role[] })[];
/** Cached result of JWT verification — avoids repeated RSA verify calls. */
private _cachedVerifiedPermissions: { permissions: string[] } | null = null;
public readonly createdAt: Date; public readonly createdAt: Date;
public updatedAt: Date; public updatedAt: Date;
@@ -62,6 +65,14 @@ export class RoleController {
* @returns - Verified object with permissions in it. * @returns - Verified object with permissions in it.
*/ */
private _verifyPermissions(permissionsToken: string) { private _verifyPermissions(permissionsToken: string) {
// Return cached result if the token hasn't changed
if (
this._cachedVerifiedPermissions &&
permissionsToken === this._permissionsToken
) {
return this._cachedVerifiedPermissions;
}
let perms: DecodedPermissionsBlock; let perms: DecodedPermissionsBlock;
try { try {
perms = jwt.verify(permissionsToken, permissionsPrivateKey, { perms = jwt.verify(permissionsToken, permissionsPrivateKey, {
@@ -82,7 +93,12 @@ export class RoleController {
); );
} }
return perms as { permissions: string[] }; const result = perms as { permissions: string[] };
// Cache only if verifying the current token
if (permissionsToken === this._permissionsToken) {
this._cachedVerifiedPermissions = result;
}
return result;
} }
/** /**
+86 -26
View File
@@ -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 });
@@ -178,6 +189,46 @@ export default class UserController {
return decoded.permissions; return decoded.permissions;
} }
/**
* Read Role Permissions
*
* Verifies and decodes a role permissions JWT and returns the permission nodes.
* Returns an empty array if verification fails.
*
* @param role - Role record containing the signed permissions token
* @returns {string[]} The role permission nodes
*/
private _readRolePermissions(role: Role): string[] {
try {
const decoded = jwt.verify(role.permissions, permissionsPrivateKey, {
algorithms: ["RS256"],
issuer: "roles",
subject: role.id,
}) as DecodedPermissionsBlock;
return decoded.permissions;
} catch {
return [];
}
}
/**
* Read All Permissions
*
* Aggregates the user's direct permissions and all permissions from their assigned roles
* into a single deduplicated array.
*
* @returns {Promise<string[]>} Combined array of all permission nodes
*/
public async readAllPermissions(): Promise<string[]> {
const directPermissions = this.readPermissions();
const rolePermissions = this._roles
.map((role) => this._readRolePermissions(role))
.flatMap((permissions) => permissions);
return [...new Set([...directPermissions, ...rolePermissions])];
}
/** /**
* Fetch Roles * Fetch Roles
* *
@@ -209,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();
} }
/** /**
@@ -262,9 +312,19 @@ export default class UserController {
: this._roles.size > 0 : this._roles.size > 0
? this._roles.map((v) => v.moniker) ? this._roles.map((v) => v.moniker)
: undefined, : undefined,
permissions: opts?.safeReturn ? undefined : this.readPermissions(), permissions: opts?.safeReturn
? undefined
: (() => {
const directPermissions = this.readPermissions();
const rolePermissions = this._roles
.map((role) => this._readRolePermissions(role))
.flatMap((permissions) => permissions);
return [...new Set([...directPermissions, ...rolePermissions])];
})(),
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,
+141 -48
View File
@@ -12,6 +12,10 @@ import { unifiSites } from "./managers/unifiSites";
import { refreshCompanies } from "./modules/cw-utils/refreshCompanies"; import { refreshCompanies } from "./modules/cw-utils/refreshCompanies";
import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog"; import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog";
import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory"; import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory";
import { 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";
@@ -20,58 +24,35 @@ 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);
};
if (!existingAdmin) { // Helper to run a startup sync safely — failures are logged but never crash the process.
const id = cuid(); const safeStartup = async (label: string, fn: () => Promise<void>) => {
const created = await prisma.role.create({ try {
data: { await fn();
id, } catch (err) {
moniker: "administrator", console.error(
title: "Admin", `[startup] ${label} failed — will retry on next interval`,
permissions: signPermissions({ err,
issuer: "roles", );
subject: id, }
permissions: ["*"], };
}),
},
include: { users: { include: { roles: true } } },
});
events.emit("role:created", new RoleController(created));
}
// Refresh the internal list of companies every minute
await refreshCompanies();
setInterval(() => {
return refreshCompanies();
}, 60 * 1000);
// Refresh the internal catalog every minute
await refreshCatalog();
setInterval(() => {
return refreshCatalog();
}, 60 * 1000);
// Refresh inventory on hand every 2 minutes
await refreshInventory();
setInterval(
() => {
return refreshInventory();
},
2 * 60 * 1000,
);
await unifiSites.syncSites();
setInterval(() => {
return unifiSites.syncSites();
}, 60 * 1000);
// ---------------------------------------------------------------------------
// Start the HTTP server FIRST so the pod is reachable immediately.
// All data-sync tasks run afterwards and are non-blocking.
// ---------------------------------------------------------------------------
Bun.serve({ Bun.serve({
port: PORT, port: PORT,
idleTimeout: 255,
websocket: engine.handler().websocket, websocket: engine.handler().websocket,
fetch: (req, server) => { fetch: (req, server) => {
const url = new URL(req.url); const url = new URL(req.url);
@@ -83,3 +64,115 @@ Bun.serve({
return app.fetch(req, server); return app.fetch(req, server);
}, },
}); });
console.log(`[startup] Server listening on port ${PORT}`);
// ---------------------------------------------------------------------------
// Background initialisation — none of this blocks the server.
// ---------------------------------------------------------------------------
// Ensure administrator role exists
await safeStartup("ensureAdminRole", async () => {
const existingAdmin = await prisma.role.findFirst({
where: { moniker: "administrator" },
include: { users: { include: { roles: true } } },
});
if (!existingAdmin) {
const id = cuid();
const created = await prisma.role.create({
data: {
id,
moniker: "administrator",
title: "Admin",
permissions: signPermissions({
issuer: "roles",
subject: id,
permissions: ["*"],
}),
},
include: { users: { include: { roles: true } } },
});
events.emit("role:created", new RoleController(created));
}
});
// Refresh the internal list of companies every minute
await safeStartup("refreshCompanies", refreshCompanies);
setInterval(() => {
return refreshCompanies().catch((err) =>
console.error(`[interval] refreshCompanies failed: ${briefErr(err)}`),
);
}, 60 * 1000);
// Refresh the internal catalog every minute
await safeStartup("refreshCatalog", refreshCatalog);
setInterval(() => {
return refreshCatalog().catch((err) =>
console.error(`[interval] refreshCatalog failed: ${briefErr(err)}`),
);
}, 60 * 1000);
// Refresh inventory on hand every 2 minutes
await safeStartup("refreshInventory", refreshInventory);
setInterval(
() => {
return refreshInventory().catch((err) =>
console.error(`[interval] refreshInventory failed: ${briefErr(err)}`),
);
},
2 * 60 * 1000,
);
// Refresh opportunities every minute
await safeStartup("refreshOpportunities", refreshOpportunities);
setInterval(() => {
return refreshOpportunities().catch((err) =>
console.error(`[interval] refreshOpportunities failed: ${briefErr(err)}`),
);
}, 60 * 1000);
// Refresh opportunity CW cache every 30 seconds (activities + company hydration)
// NOTE: Do NOT await — register the interval immediately so the cache refresh
// is never blocked by a slow/stuck startup task above.
safeStartup("refreshOpportunityCache", refreshOpportunityCache);
setInterval(() => {
return refreshOpportunityCache().catch((err) => {
console.error(
`[interval] refreshOpportunityCache failed: ${briefErr(err)}`,
);
});
}, 30 * 1000);
// Refresh User Defined Fields every 5 minutes
await safeStartup("refreshUDFs", () => userDefinedFieldsCw.refresh());
setInterval(
() => {
return userDefinedFieldsCw
.refresh()
.catch((err) =>
console.error(`[interval] refreshUDFs failed: ${briefErr(err)}`),
);
},
5 * 60 * 1000,
);
// Refresh CW identifiers for all users every 30 minutes
await safeStartup("refreshCwIdentifiers", refreshCwIdentifiers);
setInterval(
() => {
return refreshCwIdentifiers().catch((err) =>
console.error(`[interval] refreshCwIdentifiers failed: ${briefErr(err)}`),
);
},
30 * 60 * 1000,
);
await safeStartup("syncSites", () => unifiSites.syncSites());
setInterval(() => {
return unifiSites
.syncSites()
.catch((err) =>
console.error(`[interval] syncSites failed: ${briefErr(err)}`),
);
}, 60 * 1000);
+211
View File
@@ -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,
});
}
},
};
+7 -4
View File
@@ -15,16 +15,19 @@ export const companies = {
const freshCwData: { data: Company } = await connectWiseApi.get( const freshCwData: { data: Company } = await connectWiseApi.get(
`/company/companies/${search.cw_CompanyId}`, `/company/companies/${search.cw_CompanyId}`,
); );
const defaultContactData = await connectWiseApi.get(
(freshCwData.data as Company).defaultContact._info.contact_href, const contactHref = freshCwData.data.defaultContact?._info?.contact_href;
); const defaultContactData = contactHref
? await connectWiseApi.get(contactHref)
: undefined;
const allContactsData = await connectWiseApi.get( const allContactsData = await connectWiseApi.get(
`${freshCwData.data._info.contacts_href}&pageSize=1000`, `${freshCwData.data._info.contacts_href}&pageSize=1000`,
); );
return new CompanyController(search, { return new CompanyController(search, {
company: freshCwData.data, company: freshCwData.data,
defaultContact: defaultContactData.data, defaultContact: defaultContactData?.data ?? null,
allContacts: allContactsData.data, allContacts: allContactsData.data,
}); });
}, },
+479
View File
@@ -0,0 +1,479 @@
import { Company } from "../../generated/prisma/client";
import { prisma } from "../constants";
import { ActivityController } from "../controllers/ActivityController";
import { CompanyController } from "../controllers/CompanyController";
import { OpportunityController } from "../controllers/OpportunityController";
import GenericError from "../Errors/GenericError";
import { activityCw } from "../modules/cw-utils/activities/activities";
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
import {
getCachedActivities,
getCachedCompanyCwData,
getCachedOppCwData,
fetchAndCacheActivities,
fetchAndCacheCompanyCwData,
fetchAndCacheOppCwData,
} from "../modules/cache/opportunityCache";
// ---------------------------------------------------------------------------
// Data-source hierarchy helpers
// ---------------------------------------------------------------------------
/**
* Build a CompanyController with hydrated CW data from a Prisma Company record.
*
* Data-source hierarchy (controlled by `strategy`):
*
* - `"cache-only"` Redis cache bare DB record (no CW call).
* Ideal for list views where latency matters and the background
* refresh job is responsible for keeping the cache warm.
*
* - `"cache-then-cw"` (default) Redis cache CW API cache result.
* On a cold cache, calls CW to ensure the caller gets full data.
*
* - `"cw-first"` CW API (always) cache result.
* Forces a fresh fetch regardless of cache state.
*/
async function buildCompanyController(
company: Company,
opts?: {
strategy?: "cache-only" | "cache-then-cw" | "cw-first";
ttlMs?: number;
},
): Promise<CompanyController> {
const strategy = opts?.strategy ?? "cache-then-cw";
const ctrl = new CompanyController(company);
// ── cw-first: always fetch from CW ──────────────────────────────────
if (strategy === "cw-first") {
await ctrl.hydrateCwData();
if (ctrl.cw_Data && opts?.ttlMs) {
await fetchAndCacheCompanyCwData(company.cw_CompanyId, opts.ttlMs).catch(
() => {},
);
}
return ctrl;
}
// ── cache-only / cache-then-cw: try Redis first ─────────────────────
const cached = await getCachedCompanyCwData(company.cw_CompanyId);
if (cached) {
ctrl.cw_Data = cached;
return ctrl;
}
// cache-only stops here — return the bare DB-backed controller
if (strategy === "cache-only") return ctrl;
// cache-then-cw: cache miss — fall through to CW
await ctrl.hydrateCwData();
if (ctrl.cw_Data && opts?.ttlMs) {
await fetchAndCacheCompanyCwData(company.cw_CompanyId, opts.ttlMs).catch(
() => {},
);
}
return ctrl;
}
/**
* Fetch ActivityController[] for an opportunity.
*
* Same three strategies as {@link buildCompanyController}:
*
* - `"cache-only"` Redis empty array (no CW call).
* - `"cache-then-cw"` (default) Redis CW API cache result.
* - `"cw-first"` CW API (always) cache result.
*/
async function buildActivities(
cwOpportunityId: number,
opts?: {
strategy?: "cache-only" | "cache-then-cw" | "cw-first";
ttlMs?: number;
},
): Promise<ActivityController[]> {
const strategy = opts?.strategy ?? "cache-then-cw";
// ── cw-first: always fetch from CW ──────────────────────────────────
if (strategy === "cw-first") {
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
const arr = collection.map((item) => item);
if (opts?.ttlMs) {
await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs).catch(
() => {},
);
}
return arr.map((item) => new ActivityController(item));
}
// ── cache-only / cache-then-cw: try Redis first ─────────────────────
const cached = await getCachedActivities(cwOpportunityId);
if (cached) {
return cached.map((item) => new ActivityController(item));
}
// cache-only stops here — return empty (background job will fill it)
if (strategy === "cache-only") return [];
// cache-then-cw: cache miss — fall through to CW
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
const arr = collection.map((item) => item);
if (opts?.ttlMs) {
await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs).catch(() => {});
}
return arr.map((item) => new ActivityController(item));
}
export const opportunities = {
/**
* Fetch Record (lightweight)
*
* Returns an OpportunityController backed only by the **database record**.
* No ConnectWise API calls, no Redis lookups, no activity/company hydration.
*
* Use this when you only need the controller instance to call a sub-resource
* method (e.g. `fetchNotes()`, `fetchContacts()`, `fetchProducts()`,
* `fetchSite()`).
*
* @param identifier - Internal ID (string) or CW opportunity ID (number)
* @returns {Promise<OpportunityController>}
*/
async fetchRecord(
identifier: string | number,
): Promise<OpportunityController> {
const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier));
const record = await prisma.opportunity.findFirst({
where: isNumeric
? { cwOpportunityId: Number(identifier) }
: { id: identifier as string },
include: { company: true },
});
if (!record) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
return new OpportunityController(record, {
company: record.company
? new CompanyController(record.company)
: undefined,
});
},
/**
* Fetch Opportunity
*
* Fetch an opportunity by its internal ID or ConnectWise opportunity ID
* and return an OpportunityController instance.
*
* **Data-source strategy:**
* - `fresh: true` `"cw-first"` always fetches from CW, updates DB, caches result.
* - `fresh: false` (default) `"cache-then-cw"` tries Redis cache for the
* CW opportunity response first, falls back to CW on miss.
*
* The CW opportunity response is cached in Redis with the same TTL as
* activities/company. The background refresh keeps this warm so most
* detail-view loads skip the CW roundtrip entirely.
*
* @param identifier - The internal ID (string) or CW opportunity ID (number)
* @param opts - Optional flags
* @param opts.fresh - When `true`, bypass the cache and pull directly from CW.
* @returns {Promise<OpportunityController>}
*/
async fetchItem(
identifier: string | number,
opts?: { fresh?: boolean },
): Promise<OpportunityController> {
const strategy: "cache-only" | "cache-then-cw" | "cw-first" = opts?.fresh
? "cw-first"
: "cache-then-cw";
const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier));
// Look up the existing DB record (full, with company)
const existing = await prisma.opportunity.findFirst({
where: isNumeric
? { cwOpportunityId: Number(identifier) }
: { id: identifier as string },
include: { company: true },
});
if (!existing) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
// Compute TTL from the current DB state (used for cache and hydration)
const ttlMs =
computeCacheTTL({
closedFlag: existing.closedFlag,
closedDate: existing.closedDate,
expectedCloseDate: existing.expectedCloseDate,
lastUpdated: existing.cwLastUpdated,
}) ?? undefined;
// ── Resolve CW opportunity data (cache-aware) ──────────────────────
let cwData: any;
let record = existing; // default: use the existing DB record as-is
if (!opts?.fresh) {
// Try the Redis cache first
cwData = await getCachedOppCwData(existing.cwOpportunityId);
}
if (!cwData) {
// Cache miss or forced fresh — fetch from CW and cache
cwData = ttlMs
? await fetchAndCacheOppCwData(existing.cwOpportunityId, ttlMs)
: await opportunityCw.fetch(existing.cwOpportunityId);
if (!cwData) {
throw new GenericError({
message: "Opportunity not found in ConnectWise",
name: "OpportunityNotFound",
cause: `CW returned 404 for opportunity ${existing.cwOpportunityId}`,
status: 404,
});
}
// Map and update the DB record (only on cache miss/fresh)
const mapped = OpportunityController.mapCwToDb(cwData);
const companyId = cwData.company?.id
? ((
await prisma.company.findFirst({
where: { cw_CompanyId: cwData.company.id },
select: { id: true },
})
)?.id ?? null)
: null;
record = await prisma.opportunity.update({
where: { id: existing.id },
data: { ...mapped, companyId },
include: { company: true },
});
}
// Hydrate activities and company in parallel
const [activities, company] = await Promise.all([
buildActivities(record.cwOpportunityId, { strategy, ttlMs }),
record.company
? buildCompanyController(record.company, { strategy, ttlMs })
: Promise.resolve(undefined),
]);
return new OpportunityController(record, {
company,
customFields: cwData?.customFields ?? [],
activities,
});
},
/**
* Fetch All Opportunities (Paginated)
*
* Uses the **cache-only** strategy: Redis bare DB data.
* Activities and company hydration come from the Redis cache if
* available; otherwise the controller is returned with DB-only data.
* The background refresh job is responsible for keeping Redis warm.
*
* @param page - Page number (1-based)
* @param rpp - Records per page
* @param opts - Optional filters
* @returns {Promise<OpportunityController[]>}
*/
async fetchPages(
page: number,
rpp: number,
opts?: { includeClosed?: boolean },
): Promise<OpportunityController[]> {
const skip = (Math.max(page, 1) - 1) * rpp;
const items = await prisma.opportunity.findMany({
where: opts?.includeClosed ? undefined : { closedFlag: false },
include: { company: true },
skip,
take: rpp,
orderBy: { createdAt: "desc" },
});
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",
}),
});
}),
);
},
/**
* Search Opportunities
*
* Search opportunities by name, company name, contact name, notes,
* sales rep, or status with pagination support.
*
* Uses the **cache-only** strategy (same as `fetchPages`).
*
* @param query - Search query string
* @param page - Page number (1-based)
* @param rpp - Records per page
* @param opts - Optional filters
* @returns {Promise<OpportunityController[]>}
*/
async search(
query: string,
page: number,
rpp: number,
opts?: { includeClosed?: boolean },
): Promise<OpportunityController[]> {
const skip = (Math.max(page, 1) - 1) * rpp;
const numericQuery = /^\d+$/.test(query.trim())
? Number(query.trim())
: null;
const items = await prisma.opportunity.findMany({
where: {
...(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 } }]
: []),
],
},
include: { company: true },
skip,
take: rpp,
orderBy: { expectedCloseDate: "asc" },
});
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",
}),
});
}),
);
},
/**
* Count Opportunities
*
* @param opts - Optional filters
* @returns {Promise<number>}
*/
async count(opts?: { openOnly?: boolean }): Promise<number> {
return prisma.opportunity.count({
where: opts?.openOnly ? { closedFlag: false } : undefined,
});
},
/**
* Count Search Results
*
* Returns the total number of opportunities matching a search query,
* using the same filter logic as `search()`.
*
* @param query - Search query string
* @param opts - Optional filters
* @returns {Promise<number>}
*/
async searchCount(
query: string,
opts?: { includeClosed?: boolean },
): Promise<number> {
const numericQuery = /^\d+$/.test(query.trim())
? Number(query.trim())
: null;
return prisma.opportunity.count({
where: {
...(opts?.includeClosed ? {} : { closedFlag: false }),
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ companyName: { contains: query, mode: "insensitive" } },
{ contactName: { contains: query, mode: "insensitive" } },
{ notes: { contains: query, mode: "insensitive" } },
{ primarySalesRepName: { contains: query, mode: "insensitive" } },
{ statusName: { contains: query, mode: "insensitive" } },
{ stageName: { contains: query, mode: "insensitive" } },
...(numericQuery !== null
? [{ cwOpportunityId: { equals: numericQuery } }]
: []),
],
},
});
},
/**
* Fetch Opportunities by Company
*
* Fetch all opportunities for a company by its internal company ID.
*
* Uses the **cache-only** strategy (same as `fetchPages`).
*
* @param companyId - The internal company ID
* @param opts - Optional filters
* @returns {Promise<OpportunityController[]>}
*/
async fetchByCompany(
companyId: string,
opts?: { includeClosed?: boolean },
): Promise<OpportunityController[]> {
const items = await prisma.opportunity.findMany({
where: {
companyId,
...(opts?.includeClosed ? {} : { closedFlag: false }),
},
include: { company: true },
orderBy: { expectedCloseDate: "asc" },
});
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",
}),
});
}),
);
},
};
+332
View File
@@ -0,0 +1,332 @@
import { prisma } from "../constants";
import { CatalogItemController } from "../controllers/CatalogItemController";
import GenericError from "../Errors/GenericError";
import {
getSubcategoriesForCategory,
getSubcategoriesForGroup,
ECOSYSTEM_TREE,
} from "../modules/catalog-categories/catalogCategories";
/**
* Standard include clause used by catalog item queries.
* Includes one level of linked items.
*/
const catalogItemInclude = {
linkedItems: true,
} as const;
/**
* Filter options for catalog item queries.
*/
export interface CatalogFilterOpts {
includeInactive?: boolean;
category?: string;
subcategory?: string;
group?: string;
manufacturer?: string;
ecosystem?: string;
inStock?: boolean;
minPrice?: number;
maxPrice?: number;
}
/**
* Builds a Prisma `where` clause from filter options.
*/
function buildFilterWhere(opts: CatalogFilterOpts = {}) {
const conditions: Record<string, unknown>[] = [];
if (!opts.includeInactive) {
conditions.push({ inactive: false });
}
if (opts.category) {
conditions.push({ category: opts.category });
}
if (opts.subcategory) {
conditions.push({ subcategory: opts.subcategory });
}
if (opts.group && opts.category) {
const subcats = getSubcategoriesForGroup(opts.category, opts.group);
if (subcats.length > 0) {
conditions.push({ subcategory: { in: subcats } });
}
} else if (opts.group && !opts.category) {
// Try to find the group in any category
const {
CATEGORY_TREE,
isCategoryGroup,
} = require("../modules/catalog-categories/catalogCategories");
for (const cat of CATEGORY_TREE) {
const subcats = getSubcategoriesForGroup(cat.name, opts.group);
if (subcats.length > 0) {
conditions.push({ category: cat.name, subcategory: { in: subcats } });
break;
}
}
}
if (opts.manufacturer) {
conditions.push({
manufacturer: { contains: opts.manufacturer, mode: "insensitive" },
});
}
if (opts.ecosystem) {
const eco = ECOSYSTEM_TREE.find(
(e) => e.name.toLowerCase() === opts.ecosystem!.toLowerCase(),
);
if (eco && eco.manufacturers.length > 0) {
conditions.push({
OR: eco.manufacturers.map((m) => ({
manufacturer: { contains: m.name, mode: "insensitive" as const },
subcategory: { startsWith: m.subcategoryPrefix },
category: m.category,
})),
});
}
}
if (opts.inStock) {
conditions.push({ onHand: { gt: 0 } });
}
if (opts.minPrice !== undefined) {
conditions.push({ price: { gte: opts.minPrice } });
}
if (opts.maxPrice !== undefined) {
conditions.push({ price: { lte: opts.maxPrice } });
}
return conditions.length > 0 ? { AND: conditions } : undefined;
}
export const procurement = {
/**
* Fetch Catalog Item
*
* Fetch a catalog item by its internal ID or ConnectWise catalog ID
* and return a CatalogItemController instance.
*
* @param identifier - The internal ID (string) or CW catalog ID (number)
* @returns {Promise<CatalogItemController>} - The catalog item controller
*/
async fetchItem(identifier: string | number): Promise<CatalogItemController> {
const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier));
const item = await prisma.catalogItem.findFirst({
where: isNumeric
? { cwCatalogId: Number(identifier) }
: {
OR: [
{ id: identifier as string },
{ identifier: identifier as string },
],
},
include: catalogItemInclude,
});
if (!item) {
throw new GenericError({
message: "Catalog item not found",
name: "CatalogItemNotFound",
cause: `No catalog item exists with identifier '${identifier}'`,
status: 404,
});
}
return new CatalogItemController(item);
},
/**
* Fetch All Catalog Items (Paginated)
*
* Fetch pages of catalog items for pagination with optional filtering.
*
* @param page - Page number (1-based)
* @param rpp - Records per page
* @param opts - Filter options
* @returns {Promise<CatalogItemController[]>} - Array of catalog item controllers
*/
async fetchPages(
page: number,
rpp: number,
opts?: CatalogFilterOpts,
): Promise<CatalogItemController[]> {
const skip = (Math.max(page, 1) - 1) * rpp;
const take = rpp;
const items = await prisma.catalogItem.findMany({
where: buildFilterWhere(opts),
skip,
take,
include: catalogItemInclude,
orderBy: { name: "asc" },
});
return items.map((item) => new CatalogItemController(item));
},
/**
* Search Catalog Items
*
* Search catalog items by name, description, part number, or vendor SKU
* with pagination support and optional category/subcategory/ecosystem filters.
*
* @param query - Search query string
* @param page - Page number (1-based)
* @param rpp - Records per page
* @param opts - Filter options
* @returns {Promise<CatalogItemController[]>} - Array of matching catalog item controllers
*/
async search(
query: string,
page: number,
rpp: number,
opts?: CatalogFilterOpts,
): Promise<CatalogItemController[]> {
const skip = (Math.max(page, 1) - 1) * rpp;
const take = rpp;
const filterWhere = buildFilterWhere(opts) ?? {};
const items = await prisma.catalogItem.findMany({
where: {
...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" } },
],
},
skip,
take,
include: catalogItemInclude,
orderBy: { name: "asc" },
});
return items.map((item) => new CatalogItemController(item));
},
/**
* Count Catalog Items
*
* Returns the total number of catalog items matching the given filters.
*
* @param opts - Filter options
* @returns {Promise<number>} - Total count
*/
async count(
opts?: CatalogFilterOpts & { activeOnly?: boolean },
): Promise<number> {
// Support legacy `activeOnly` flag by mapping it to `includeInactive`
const filterOpts: CatalogFilterOpts = {
...opts,
includeInactive:
opts?.includeInactive ?? (opts?.activeOnly ? false : true),
};
if (opts?.activeOnly) filterOpts.includeInactive = false;
return prisma.catalogItem.count({
where: 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
*
* Links a target catalog item to a source catalog item.
*
* @param sourceIdentifier - The source item's internal ID, identifier, or CW catalog ID
* @param targetIdentifier - The target item's internal ID, identifier, or CW catalog ID
* @returns {Promise<CatalogItemController>} - The updated source controller with linked items
*/
async linkItems(
sourceIdentifier: string | number,
targetIdentifier: string | number,
): Promise<CatalogItemController> {
const source = await procurement.fetchItem(sourceIdentifier);
const target = await procurement.fetchItem(targetIdentifier);
return source.linkItem(target.id);
},
/**
* Unlink Catalog Items
*
* Removes the link between a source catalog item and a target catalog item.
*
* @param sourceIdentifier - The source item's internal ID, identifier, or CW catalog ID
* @param targetIdentifier - The target item's internal ID, identifier, or CW catalog ID
* @returns {Promise<CatalogItemController>} - The updated source controller
*/
async unlinkItems(
sourceIdentifier: string | number,
targetIdentifier: string | number,
): Promise<CatalogItemController> {
const source = await procurement.fetchItem(sourceIdentifier);
const target = await procurement.fetchItem(targetIdentifier);
return source.unlinkItem(target.id);
},
};
+7
View File
@@ -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 },
+166
View File
@@ -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
View File
@@ -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,
});
}
};
+15
View File
@@ -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";

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