Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ce1eda606 | |||
| 6c310ed753 | |||
| 1907bb433b | |||
| 4efca6cc53 | |||
| d5c22c8eff | |||
| a048e1e824 | |||
| 6d935e7180 | |||
| fe71248e88 | |||
| 7411310083 | |||
| 30b408e0db | |||
| d7b374f8ab | |||
| 883b648d5e | |||
| b787120461 | |||
| 1326725995 |
@@ -24,9 +24,10 @@ Keep each layer focused:
|
||||
|
||||
## Runtime / tooling
|
||||
|
||||
The project runs on **Bun**. DB tooling uses **Prisma**; the generated client lives under `generated/prisma` (do NOT edit generated files). Key scripts in `package.json`:
|
||||
The project runs on **Bun** exclusively — **always use `bun` commands, never `npm`, `npx`, or `yarn`**. DB tooling uses **Prisma**; the generated client lives under `generated/prisma` (do NOT edit generated files). Test preloads are configured in `bunfig.toml` so bare `bun test` works. Key scripts in `package.json`:
|
||||
|
||||
- `dev` — `NODE_ENV=development bun --watch src/index.ts` (start dev server with hot reload)
|
||||
- `test` — `bun test` (runs all tests with preload from `bunfig.toml`)
|
||||
- `db:gen` — `prisma generate`
|
||||
- `db:push` — `prisma migrate dev --skip-generate`
|
||||
- `utils:dev` — `docker compose -f .docker/docker-compose.yml up --build`
|
||||
@@ -36,7 +37,7 @@ The project runs on **Bun**. DB tooling uses **Prisma**; the generated client li
|
||||
|
||||
## Data layer
|
||||
|
||||
Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` for browser type contexts). The shared `prisma` instance is exported from `src/constants.ts`. Always run `npm run db:gen` after updating `schema.prisma`.
|
||||
Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` for browser type contexts). The shared `prisma` instance is exported from `src/constants.ts`. Always run `bun run db:gen` after updating `schema.prisma`.
|
||||
|
||||
## Shared constants (`src/constants.ts`)
|
||||
|
||||
@@ -174,13 +175,14 @@ The `UnifiClient` class in `src/modules/unifi-api/UnifiClient.ts` wraps all UniF
|
||||
|
||||
## Local dev / quick checks
|
||||
|
||||
- Start dev server: `npm run dev`
|
||||
- Regenerate Prisma client: `npm run db:gen`
|
||||
- Apply DB migrations locally: `npm run db:push`
|
||||
- Docker dev utilities: `npm run utils:dev`
|
||||
- Generate private keys: `npm run utils:gen_private_keys`
|
||||
- Create admin role: `npm run utils:create_admin_role`
|
||||
- Assign user role: `npm run utils:assign_user_role`
|
||||
- Start dev server: `bun run dev`
|
||||
- Run tests: `bun test`
|
||||
- Regenerate Prisma client: `bun run db:gen`
|
||||
- Apply DB migrations locally: `bun run db:push`
|
||||
- Docker dev utilities: `bun run utils:dev`
|
||||
- Generate private keys: `bun run utils:gen_private_keys`
|
||||
- Create admin role: `bun run utils:create_admin_role`
|
||||
- Assign user role: `bun run utils:assign_user_role`
|
||||
|
||||
## When editing generated or infra files
|
||||
|
||||
@@ -196,7 +198,11 @@ Whenever you add, remove, or modify API routes or permission nodes, you **must**
|
||||
2. `PERMISSIONS.md` — human-readable documentation of all permission nodes; must strictly reflect the data in `PermissionNodes.ts`.
|
||||
3. `API_ROUTES.md` — comprehensive documentation of all API routes, including method, path, auth requirements, permissions, request/response examples.
|
||||
|
||||
Always verify that new routes have their required permissions listed in `PermissionNodes.ts`, that `PERMISSIONS.md` tables match the TS file exactly, and that `API_ROUTES.md` includes full documentation for every mounted route. Run through all three files at the end of any route or permission change to catch discrepancies.
|
||||
Additionally, whenever you add, remove, or modify **caching logic** (TTL algorithms, cache key patterns, background refresh mechanics, retry settings, or invalidation behavior), you **must** update:
|
||||
|
||||
4. `CACHING.md` — comprehensive documentation of the Redis-backed opportunity cache, TTL algorithms, background refresh mechanics, retry logic, and debugging tools.
|
||||
|
||||
Always verify that new routes have their required permissions listed in `PermissionNodes.ts`, that `PERMISSIONS.md` tables match the TS file exactly, that `API_ROUTES.md` includes full documentation for every mounted route, and that `CACHING.md` accurately reflects any caching changes. Run through all relevant files at the end of any route, permission, or caching change to catch discrepancies.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
*.jsonl
|
||||
cw-api-logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
+1649
-28
File diff suppressed because it is too large
Load Diff
+348
@@ -0,0 +1,348 @@
|
||||
# Caching Architecture
|
||||
|
||||
This document describes the caching layer used in the Optima API, covering the Redis-backed opportunity cache, TTL algorithms, background refresh mechanics, retry logic, and debugging tools.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The API caches expensive ConnectWise (CW) API responses in **Redis** to reduce latency and avoid CW rate limits. The primary cache layer is the **opportunity cache** (`src/modules/cache/opportunityCache.ts`), which proactively warms data for all non-closed opportunities on a background interval.
|
||||
|
||||
### Key design principles
|
||||
|
||||
- **Adaptive TTLs** — cache durations are computed dynamically based on how "hot" an opportunity is (recently updated = shorter TTL = fresher data).
|
||||
- **Background refresh** — a 20-minute interval scans all open opportunities and re-fetches only expired cache keys.
|
||||
- **Bounded concurrency** — CW API calls are throttled via thunk-based batching to prevent overwhelming the upstream API.
|
||||
- **Graceful degradation** — transient CW errors (timeouts, network failures) are caught, logged, and retried on the next cycle rather than crashing the process.
|
||||
- **Priority ordering** — most recently updated opportunities are refreshed first so active deals get fresh data before stale ones.
|
||||
|
||||
---
|
||||
|
||||
## What is cached
|
||||
|
||||
Each non-closed opportunity can have up to 7 cached payloads in Redis:
|
||||
|
||||
| Cache Key Pattern | Data | Source |
|
||||
| ----------------------------------- | ------------------------------------ | --------------------------------------------------------------------- |
|
||||
| `opp:cw-data:{cwOpportunityId}` | Raw CW opportunity response | `GET /sales/opportunities/:id` |
|
||||
| `opp:activities:{cwOpportunityId}` | CW activities array | `GET /sales/activities?conditions=opportunity/id=:id` |
|
||||
| `opp:notes:{cwOpportunityId}` | CW notes array | `GET /sales/opportunities/:id/notes` |
|
||||
| `opp:contacts:{cwOpportunityId}` | CW contacts array | `GET /sales/opportunities/:id/contacts` |
|
||||
| `opp:products:{cwOpportunityId}` | Forecast + procurement products blob | `GET /sales/opportunities/:id/forecast` + `GET /procurement/products` |
|
||||
| `opp:company-cw:{cw_CompanyId}` | Hydrated company + contacts blob | `GET /company/companies/:id` + contacts endpoints |
|
||||
| `opp:site:{cwCompanyId}:{cwSiteId}` | Company site data | `GET /company/companies/:id/sites/:siteId` |
|
||||
|
||||
Inventory-adjustment-driven catalog sync adds a targeted product cache:
|
||||
|
||||
| Cache Key Pattern | Data | Source |
|
||||
| ------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `catalog:item:cw:{cwId}` | Full CW catalog item + computed `onHand` + DB row snapshot | `GET /procurement/adjustments` + `GET /procurement/catalog/:id` + catalog inventory endpoint |
|
||||
|
||||
---
|
||||
|
||||
## TTL Algorithms
|
||||
|
||||
Three algorithms compute cache TTLs. All share the same input signals:
|
||||
|
||||
- `closedFlag` — whether the opportunity is closed
|
||||
- `closedDate` — when it was closed
|
||||
- `expectedCloseDate` — projected close date (forward-looking signal)
|
||||
- `lastUpdated` — last CW modification date (backward-looking signal)
|
||||
|
||||
### Primary TTL (`computeCacheTTL`)
|
||||
|
||||
**File:** `src/modules/algorithms/computeCacheTTL.ts`
|
||||
|
||||
Used for: opportunity CW data, activities, company CW data.
|
||||
|
||||
| # | Condition | TTL | Human |
|
||||
| --- | ------------------------------------------------------- | ---------- | ------------ |
|
||||
| 1a | Closed > 30 days ago | `null` | Do not cache |
|
||||
| 1b | Closed within 30 days | 900,000 ms | 15 minutes |
|
||||
| 2 | `expectedCloseDate` or `lastUpdated` within **5 days** | 30,000 ms | 30 seconds |
|
||||
| 3 | `expectedCloseDate` or `lastUpdated` within **14 days** | 60,000 ms | 60 seconds |
|
||||
| 4 | Everything else | 900,000 ms | 15 minutes |
|
||||
|
||||
Rules are evaluated top-to-bottom; first match wins.
|
||||
|
||||
### Sub-Resource TTL (`computeSubResourceCacheTTL`)
|
||||
|
||||
**File:** `src/modules/algorithms/computeSubResourceCacheTTL.ts`
|
||||
|
||||
Used for: notes, contacts.
|
||||
|
||||
| # | Condition | TTL | Human |
|
||||
| --- | --------------------- | ---------- | ------------ |
|
||||
| 1a | Closed > 30 days ago | `null` | Do not cache |
|
||||
| 1b | Closed within 30 days | 300,000 ms | 5 minutes |
|
||||
| 2 | Within **5 days** | 60,000 ms | 60 seconds |
|
||||
| 3 | Within **14 days** | 120,000 ms | 2 minutes |
|
||||
| 4 | Everything else | 300,000 ms | 5 minutes |
|
||||
|
||||
### Products TTL (`computeProductsCacheTTL`)
|
||||
|
||||
**File:** `src/modules/algorithms/computeProductsCacheTTL.ts`
|
||||
|
||||
Used for: forecast + procurement products.
|
||||
|
||||
| # | Condition | TTL | Human |
|
||||
| --- | ------------------------------------------- | ------------ | ---------- |
|
||||
| 1 | Status is Won/Lost/Pending Won/Pending Lost | `null` | No cache |
|
||||
| 2 | Main cache TTL is `null` | `null` | No cache |
|
||||
| 3 | `lastUpdated` within **3 days** | 15,000 ms | 15 seconds |
|
||||
| 4 | Everything else | 1,200,000 ms | 20 minutes |
|
||||
|
||||
Products on terminal-status opportunities are never proactively cached. Non-hot products use a **lazy on-demand** cache — they're fetched when requested and cached for 20 minutes.
|
||||
|
||||
### Site TTL
|
||||
|
||||
Sites use a fixed TTL of **20 minutes** (1,200,000 ms). Site/address data rarely changes. Sites are **not** proactively warmed by the background refresh — they are populated lazily on the first detail-view request.
|
||||
|
||||
---
|
||||
|
||||
## Background Refresh
|
||||
|
||||
**Function:** `refreshOpportunityCache()` in `src/modules/cache/opportunityCache.ts`
|
||||
|
||||
**Interval:** Every 20 minutes, triggered from `src/index.ts`.
|
||||
|
||||
### Refresh cycle
|
||||
|
||||
1. **Query DB** — fetch all non-closed opportunities + recently closed (within 30 days), ordered by `cwLastUpdated DESC` (most recently active first).
|
||||
2. **Batch EXISTS check** — use a single Redis pipeline to check which cache keys already exist (5 EXISTS commands per opportunity: oppCwData, activities, notes, contacts, products).
|
||||
3. **Build thunk list** — for each opportunity with missing keys, push a **thunk** (lazy function) into the task list. No HTTP requests fire at this point.
|
||||
4. **Execute with bounded concurrency** — process thunks in batches of `CONCURRENCY` (currently **6**), with a `BATCH_DELAY_MS` (currently **250ms**) pause between batches. Each thunk is only invoked inside the batch loop.
|
||||
5. **Emit events** — `cache:opportunities:refresh:started` and `cache:opportunities:refresh:completed` events are emitted for the event debugger.
|
||||
|
||||
### Inventory-adjustment listener cycle
|
||||
|
||||
**Function:** `listenInventoryAdjustments()` in `src/modules/cw-utils/procurement/listenInventoryAdjustments.ts`
|
||||
|
||||
**Interval:** Every 60 seconds, triggered from `src/index.ts`.
|
||||
|
||||
1. Fetch `GET /procurement/adjustments?pageSize=1000`.
|
||||
2. Build a normalized snapshot of tracked inventory rows (`cwCatalogId`, `onHand`, `inventory`) per adjustment.
|
||||
3. Compare to previous snapshot; extract only changed product IDs.
|
||||
4. For each changed product ID, fetch fresh CW catalog item + current on-hand.
|
||||
5. Upsert `CatalogItem` in Postgres and write Redis key `catalog:item:cw:{cwId}` with a 20-minute TTL.
|
||||
|
||||
Guardrails to prevent request storms:
|
||||
|
||||
- Diffing is computed at **product state** level (grouped by `cwCatalogId`), not raw adjustment-row churn.
|
||||
- Per-cycle syncs are capped (`CW_ADJUSTMENT_SYNC_MAX_PER_CYCLE`, default `50`).
|
||||
- Product resync cooldown is enforced (`CW_ADJUSTMENT_SYNC_COOLDOWN_MS`, default `600000` ms / 10 min).
|
||||
|
||||
This avoids full-catalog sweeps for small inventory movements and updates only the products implicated by adjustments.
|
||||
|
||||
### Full procurement catalog refresh
|
||||
|
||||
**Function:** `refreshCatalog()` in `src/modules/cw-utils/procurement/refreshCatalog.ts`
|
||||
|
||||
**Interval:** Every 30 minutes, triggered from `src/index.ts`.
|
||||
|
||||
The full catalog cache/DB sync uses the same slow-parallel thunk strategy as opportunity cache refreshes:
|
||||
|
||||
- Build arrays of thunk tasks (`() => Promise<void>`) for CW item fetches, inventory fetches, and DB upserts.
|
||||
- Execute with bounded concurrency (`CONCURRENCY=6`).
|
||||
- Pause between batches (`BATCH_DELAY_MS=250`) to avoid CW burst pressure.
|
||||
- Log task failures and retry naturally on the next cycle.
|
||||
|
||||
This keeps full-catalog refresh conservative while inventory-adjustment listener handles near-real-time targeted updates.
|
||||
|
||||
### Full inventory sweep fallback
|
||||
|
||||
`refreshInventory()` remains as a safety net but is intentionally infrequent:
|
||||
|
||||
- Runs every **6 hours** from `src/index.ts` (no startup-time full sweep).
|
||||
- Uses the same slow-parallel pattern (`CONCURRENCY=6`, `BATCH_DELAY_MS=250`) to avoid burst traffic.
|
||||
|
||||
Most on-hand freshness now comes from the 60-second adjustment listener plus 30-minute full catalog refresh.
|
||||
|
||||
### Concurrency control
|
||||
|
||||
The thunk pattern is critical. Previously, tasks were pushed as already-executing promises (`refreshTasks.push(fetchAndCache(...))`), which meant all HTTP requests fired simultaneously regardless of the batching loop. The fix was changing the array type from `Promise<void>[]` to `(() => Promise<void>)[]` so requests only start when explicitly invoked: `batch.map((fn) => fn())`.
|
||||
|
||||
### Current tuning
|
||||
|
||||
| Parameter | Value | Effect |
|
||||
| ---------------- | ---------- | ------------------------------------------ |
|
||||
| `CONCURRENCY` | 6 | Max simultaneous CW API requests per batch |
|
||||
| `BATCH_DELAY_MS` | 250 | Milliseconds between batches |
|
||||
| Refresh interval | 20 minutes | How often the full sweep runs |
|
||||
|
||||
At these settings, a full sweep of ~500 expired keys completes in ~1-2 minutes with zero CW errors and ~230ms median latency.
|
||||
|
||||
---
|
||||
|
||||
## Retry Logic (`withCwRetry`)
|
||||
|
||||
**File:** `src/modules/cw-utils/withCwRetry.ts`
|
||||
|
||||
Wraps CW API calls with exponential backoff retry on transient errors.
|
||||
|
||||
### Retryable errors
|
||||
|
||||
- `ECONNABORTED` (timeout)
|
||||
- `ECONNRESET`
|
||||
- `ETIMEDOUT`
|
||||
- `ECONNREFUSED`
|
||||
- `ERR_NETWORK`
|
||||
- `ENETUNREACH`
|
||||
- HTTP 5xx server errors
|
||||
|
||||
### Default configuration
|
||||
|
||||
| Parameter | Default | Description |
|
||||
| ------------- | ------- | ----------------------------------------------------------- |
|
||||
| `maxAttempts` | 3 | Total attempts including the first |
|
||||
| `baseDelayMs` | 1,000 | Delay before first retry (doubles each retry: 1s → 2s → 4s) |
|
||||
| `label` | — | Optional tag for log messages |
|
||||
|
||||
### Usage
|
||||
|
||||
```ts
|
||||
import { withCwRetry } from "./withCwRetry";
|
||||
|
||||
const response = await withCwRetry(
|
||||
() => connectWiseApi.get(`/company/companies/${id}`),
|
||||
{ label: `fetchCompany#${id}`, maxAttempts: 3, baseDelayMs: 1_500 },
|
||||
);
|
||||
```
|
||||
|
||||
Non-transient errors (404, 400, etc.) are re-thrown immediately without retry.
|
||||
|
||||
---
|
||||
|
||||
## CW API Logger
|
||||
|
||||
**File:** `src/modules/cw-utils/cwApiLogger.ts`
|
||||
|
||||
Axios interceptor that logs every CW API call to a JSONL file. Logging is **opt-in** — set the `LOG_CW_API` environment variable to enable it. Each process start creates a new timestamped file in the `cw-api-logs/` directory (e.g., `cw-api-logs/2026-03-02T14-30-05.123Z.jsonl`).
|
||||
|
||||
### Enabling logging
|
||||
|
||||
```bash
|
||||
# Via the dev:log shorthand script
|
||||
bun run dev:log
|
||||
|
||||
# Or manually with any command
|
||||
LOG_CW_API=1 bun run dev
|
||||
```
|
||||
|
||||
### Log entry fields
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------ | ----------------- | ----------------------------------- |
|
||||
| `timestamp` | string (ISO-8601) | When the request completed |
|
||||
| `method` | string | HTTP method |
|
||||
| `url` | string | Request URL (relative or absolute) |
|
||||
| `baseURL` | string | Axios baseURL |
|
||||
| `status` | number \| null | HTTP status (null on network error) |
|
||||
| `durationMs` | number | Wall-clock time in milliseconds |
|
||||
| `error` | string \| null | Error code + message, if any |
|
||||
| `timeout` | number | Configured timeout in ms |
|
||||
|
||||
### Analysis
|
||||
|
||||
Run the analyzer script to analyze the most recent log file:
|
||||
|
||||
```bash
|
||||
bun run utils:analyze_cw
|
||||
```
|
||||
|
||||
Or specify a particular file:
|
||||
|
||||
```bash
|
||||
python3 debug-scripts/analyze-cw-calls.py cw-api-logs/2026-03-02T14-30-05.123Z.jsonl
|
||||
```
|
||||
|
||||
This executes `debug-scripts/analyze-cw-calls.py` which produces:
|
||||
|
||||
- Overview (total calls, error rate, time span)
|
||||
- Duration statistics (min, max, mean, p50, p90, p95, p99, distribution histogram)
|
||||
- Error breakdown by type and endpoint
|
||||
- Top 20 slowest calls
|
||||
- Per-endpoint stats (count, errors, mean, p50, p95, max, total time)
|
||||
- Timeline (per-minute throughput and errors)
|
||||
- Concurrency hotspot detection
|
||||
- Summary with recommendations
|
||||
|
||||
To clear all logs:
|
||||
|
||||
```bash
|
||||
rm -rf cw-api-logs/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cache Invalidation
|
||||
|
||||
Mutation endpoints invalidate the relevant cache keys so the next read fetches fresh data from CW:
|
||||
|
||||
| Mutation | Cache invalidated |
|
||||
| ------------------------------ | ---------------------------------------------------------------- |
|
||||
| Create/update/delete note | `opp:notes:{cwOpportunityId}` via `invalidateNotesCache()` |
|
||||
| Create/update/delete contact | `opp:contacts:{cwOpportunityId}` via `invalidateContactsCache()` |
|
||||
| Add/update/resequence products | `opp:products:{cwOpportunityId}` via `invalidateProductsCache()` |
|
||||
| Refresh opportunity | All keys for that opportunity (via re-fetch) |
|
||||
|
||||
---
|
||||
|
||||
## ConnectWise API Configuration
|
||||
|
||||
The shared Axios instance (`connectWiseApi`) is configured in `src/constants.ts`:
|
||||
|
||||
| Setting | Value | Purpose |
|
||||
| --------- | ---------------------------------------------------- | ------------------------------ |
|
||||
| `baseURL` | `https://ttscw.totaltech.net/v4_6_release/apis/3.0/` | CW API base |
|
||||
| `timeout` | 30,000 ms (30s) | Per-request timeout |
|
||||
| Logger | `attachCwApiLogger()` | Writes to `cw-api-calls.jsonl` |
|
||||
|
||||
---
|
||||
|
||||
## Architecture diagram
|
||||
|
||||
```
|
||||
src/index.ts
|
||||
│
|
||||
├─ setInterval(refreshOpportunityCache, 20m)
|
||||
│
|
||||
└─► src/modules/cache/opportunityCache.ts
|
||||
│
|
||||
├─ prisma.opportunity.findMany(orderBy: cwLastUpdated DESC)
|
||||
├─ redis.pipeline().exists(...) ← batch key check
|
||||
│
|
||||
├─ Build thunk list (lazy functions)
|
||||
│
|
||||
└─ Execute thunks with CONCURRENCY=6, DELAY=250ms
|
||||
│
|
||||
├─► fetchAndCacheOppCwData() ─► opportunityCw.fetch()
|
||||
├─► fetchAndCacheActivities() ─► activityCw.fetchByOpportunityDirect()
|
||||
├─► fetchAndCacheNotes() ─► opportunityCw.fetchNotes()
|
||||
├─► fetchAndCacheContacts() ─► opportunityCw.fetchContacts()
|
||||
├─► fetchAndCacheProducts() ─► opportunityCw.fetchProducts() + fetchProcurementProducts()
|
||||
├─► fetchAndCacheCompanyCwData() ─► fetchCwCompanyById() + contacts
|
||||
└─► fetchAndCacheSite() ─► fetchCompanySite() (lazy only)
|
||||
│
|
||||
└─► connectWiseApi.get(...) ← withCwRetry + cwApiLogger interceptors
|
||||
│
|
||||
└─► Redis SET with computed TTL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File reference
|
||||
|
||||
| File | Purpose |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `src/modules/cache/opportunityCache.ts` | Cache read/write helpers, background refresh logic |
|
||||
| `src/modules/algorithms/computeCacheTTL.ts` | Primary adaptive TTL algorithm |
|
||||
| `src/modules/algorithms/computeSubResourceCacheTTL.ts` | Sub-resource (notes, contacts) TTL algorithm |
|
||||
| `src/modules/algorithms/computeProductsCacheTTL.ts` | Products TTL algorithm |
|
||||
| `src/modules/cw-utils/withCwRetry.ts` | Retry wrapper with exponential backoff |
|
||||
| `src/modules/cw-utils/cwApiLogger.ts` | Axios interceptor for JSONL call logging |
|
||||
| `src/modules/cw-utils/fetchCompany.ts` | Company fetch with retry |
|
||||
| `src/modules/cw-utils/procurement/listenInventoryAdjustments.ts` | Adjustment listener for targeted catalog-item cache + DB sync |
|
||||
| `src/constants.ts` | CW Axios instance config (timeout, logger) |
|
||||
| `src/index.ts` | Refresh interval registration |
|
||||
| `debug-scripts/analyze-cw-calls.py` | CW API call analysis script |
|
||||
@@ -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
|
||||
+233
-12
@@ -117,22 +117,71 @@ Admin-specific UI permissions that control visibility and data loading for admin
|
||||
|
||||
### Procurement Permissions
|
||||
|
||||
| Permission Node | Description | Used In | Dependencies |
|
||||
| --------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
|
||||
| `procurement.catalog.fetch` | Fetch a single catalog item | [src/api/procurement/[id]/fetch.ts](src/api/procurement/[id]/fetch.ts) | |
|
||||
| `procurement.catalog.fetch.many` | Fetch multiple catalog items or count | [src/api/procurement/fetchAll.ts](src/api/procurement/fetchAll.ts), [src/api/procurement/count.ts](src/api/procurement/count.ts) | |
|
||||
| `procurement.catalog.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` |
|
||||
| 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` |
|
||||
|
||||
### ConnectWise Callback Routes
|
||||
|
||||
`POST /v1/cw/callback/:secret/:resource` is intentionally unauthenticated for inbound ConnectWise callbacks and does **not** require a permission node.
|
||||
|
||||
| Permission Node | Description | Used In | Dependencies |
|
||||
| --------------- | ------------------------------------------------------------------------------- | ------------------------------------------------ | ------------ |
|
||||
| _None_ | Inbound callback route; secured operationally (network controls / source trust) | [src/api/cw/callback.ts](src/api/cw/callback.ts) | N/A |
|
||||
|
||||
### Sales Permissions
|
||||
|
||||
Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (forecasts, notes, contacts) are fetched live from CW.
|
||||
Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (products, notes, contacts) are fetched live from CW.
|
||||
|
||||
| Permission Node | Description | Used In | Dependencies |
|
||||
| ------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
|
||||
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (forecasts, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/forecasts.ts](src/api/sales/[id]/forecasts.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | |
|
||||
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable) or get opportunity count | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts) | |
|
||||
| `sales.opportunity.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` |
|
||||
**WebSocket note:** The `/secure` socket event chain `opp:live_quote_preview` and `opp:live_quote_preview:<id>:data` is gated by `sales.opportunity.fetch`.
|
||||
|
||||
| Permission Node | Description | Used In | Dependencies |
|
||||
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
|
||||
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/opportunities/[id]/fetch.ts](src/api/sales/opportunities/[id]/fetch.ts), [src/api/sales/opportunities/[id]/products/fetchAll.ts](src/api/sales/opportunities/[id]/products/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetchAll.ts](src/api/sales/opportunities/[id]/notes/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetch.ts](src/api/sales/opportunities/[id]/notes/fetch.ts), [src/api/sales/opportunities/[id]/contacts/fetchAll.ts](src/api/sales/opportunities/[id]/contacts/fetchAll.ts), [src/api/sockets/events/liveQuotePreview.ts](src/api/sockets/events/liveQuotePreview.ts) | |
|
||||
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/opportunities/fetchAll.ts](src/api/sales/opportunities/fetchAll.ts), [src/api/sales/opportunities/count.ts](src/api/sales/opportunities/count.ts), [src/api/sales/opportunities/fetchTypes.ts](src/api/sales/opportunities/fetchTypes.ts) | |
|
||||
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/opportunities/[id]/refresh.ts](src/api/sales/opportunities/[id]/refresh.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/opportunities/[id]/notes/create.ts](src/api/sales/opportunities/[id]/notes/create.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/opportunities/[id]/notes/update.ts](src/api/sales/opportunities/[id]/notes/update.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/opportunities/[id]/notes/delete.ts](src/api/sales/opportunities/[id]/notes/delete.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/opportunities/[id]/products/resequence.ts](src/api/sales/opportunities/[id]/products/resequence.ts), [src/api/sales/opportunities/[id]/products/update.ts](src/api/sales/opportunities/[id]/products/update.ts), [src/api/sales/opportunities/[id]/products/cancel.ts](src/api/sales/opportunities/[id]/products/cancel.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<field>` permissions. | [src/api/sales/opportunities/[id]/products/add.ts](src/api/sales/opportunities/[id]/products/add.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.add.specialOrder` | Add one or more "SPECIAL ORDER" products via the dedicated special-order route. | [src/api/sales/opportunities/[id]/products/addSpecialOrder.ts](src/api/sales/opportunities/[id]/products/addSpecialOrder.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.add.labor` | Add labor products via the dedicated labor route with Field/Tech catalog selection and labor pricing inputs. | [src/api/sales/opportunities/[id]/products/addLabor.ts](src/api/sales/opportunities/[id]/products/addLabor.ts), [src/api/sales/opportunities/[id]/products/laborOptions.ts](src/api/sales/opportunities/[id]/products/laborOptions.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.quote.fetch` | Fetch all committed quotes for an opportunity. | [src/api/sales/opportunities/[id]/quotes/fetchAll.ts](src/api/sales/opportunities/[id]/quotes/fetchAll.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.quote.commit` | Generate and store a finalized quote PDF for an opportunity with regeneration metadata and creator attribution. | [src/api/sales/opportunities/[id]/quotes/commit.ts](src/api/sales/opportunities/[id]/quotes/commit.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.quote.preview` | Generate a preview-stamped quote PDF for an opportunity without storing it. | [src/api/sales/opportunities/[id]/quotes/preview.ts](src/api/sales/opportunities/[id]/quotes/preview.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.quote.download` | Download a committed quote PDF. Each download is recorded with timestamp and user info. | [src/api/sales/opportunities/[id]/quotes/download.ts](src/api/sales/opportunities/[id]/quotes/download.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.quote.fetch_downloads` | Fetch download/print history for all quotes on an opportunity. Admin-level permission. | [src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts](src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts) | `sales.opportunity.fetch` |
|
||||
|
||||
<details>
|
||||
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
|
||||
|
||||
Each submitted field is gated by a `sales.opportunity.product.field.<field>` permission node. Only fields the user has permission for are forwarded to ConnectWise.
|
||||
|
||||
| Field Permission Node | Description |
|
||||
| ----------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `sales.opportunity.product.field.catalogItem` | Set the catalog item reference |
|
||||
| `sales.opportunity.product.field.forecastDescription` | Set the forecast description |
|
||||
| `sales.opportunity.product.field.productDescription` | Set the product description |
|
||||
| `sales.opportunity.product.field.quantity` | Set the quantity |
|
||||
| `sales.opportunity.product.field.status` | Set the status reference |
|
||||
| `sales.opportunity.product.field.productClass` | Set the product class (e.g. Product, Service, Agreement) |
|
||||
| `sales.opportunity.product.field.forecastType` | Set the forecast type |
|
||||
| `sales.opportunity.product.field.revenue` | Set the revenue amount |
|
||||
| `sales.opportunity.product.field.cost` | Set the cost amount |
|
||||
| `sales.opportunity.product.field.includeFlag` | Set the include flag |
|
||||
| `sales.opportunity.product.field.linkFlag` | Set the link flag |
|
||||
| `sales.opportunity.product.field.recurringFlag` | Set the recurring flag |
|
||||
| `sales.opportunity.product.field.taxableFlag` | Set the taxable flag |
|
||||
| `sales.opportunity.product.field.recurringRevenue` | Set the recurring revenue amount |
|
||||
| `sales.opportunity.product.field.recurringCost` | Set the recurring cost amount |
|
||||
| `sales.opportunity.product.field.cycles` | Set the number of recurring cycles |
|
||||
| `sales.opportunity.product.field.sequenceNumber` | Set the sequence number (display order) |
|
||||
|
||||
</details>
|
||||
|
||||
### UniFi Permissions
|
||||
|
||||
@@ -171,6 +220,178 @@ The WiFi fetch route uses `processObjectValuePerms` to filter each WLAN object o
|
||||
| `unifi.site.wifi.ppsk` | View private pre-shared keys (PPSKs) for a specific WiFi network | [src/api/unifi/site/wifi/ppskFetchAll.ts](src/api/unifi/site/wifi/ppskFetchAll.ts), [src/api/unifi/site/wifi/ppskCreate.ts](src/api/unifi/site/wifi/ppskCreate.ts) | `unifi.access`, `unifi.site.wifi` |
|
||||
| `unifi.site.wifi.ppsk.create` | Create a private pre-shared key on a specific WiFi network | [src/api/unifi/site/wifi/ppskCreate.ts](src/api/unifi/site/wifi/ppskCreate.ts) | `unifi.access`, `unifi.site.wifi`, `unifi.site.wifi.ppsk` |
|
||||
|
||||
---
|
||||
|
||||
## Object Type Permissions (Field-Level Gating)
|
||||
|
||||
All fetch and fetchAll routes gate response object keys using `processObjectValuePerms`. For each object type, only fields whose corresponding `<scope>.<field>` permission the user holds are included in the response. Grant `<scope>.*` to allow all fields on that object type.
|
||||
|
||||
### Company (`obj.company`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| --------------------------- | ----------------------------------------- |
|
||||
| `obj.company.id` | View company ID |
|
||||
| `obj.company.name` | View company name |
|
||||
| `obj.company.cw_Identifier` | View ConnectWise identifier |
|
||||
| `obj.company.cw_CompanyId` | View ConnectWise company ID |
|
||||
| `obj.company.cw_Data` | View ConnectWise data (address, contacts) |
|
||||
| `obj.company.createdAt` | View creation timestamp |
|
||||
| `obj.company.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts), [src/api/companies/fetchAll.ts](src/api/companies/fetchAll.ts)
|
||||
|
||||
### Credential (`obj.credential`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ---------------------------------- | ----------------------------- |
|
||||
| `obj.credential.id` | View credential ID |
|
||||
| `obj.credential.name` | View credential name |
|
||||
| `obj.credential.notes` | View credential notes |
|
||||
| `obj.credential.typeId` | View credential type ID |
|
||||
| `obj.credential.companyId` | View linked company ID |
|
||||
| `obj.credential.subCredentialOfId` | View parent credential ID |
|
||||
| `obj.credential.fields` | View credential field values |
|
||||
| `obj.credential.type` | View credential type object |
|
||||
| `obj.credential.company` | View linked company object |
|
||||
| `obj.credential.subCredentials` | View sub-credentials array |
|
||||
| `obj.credential.secureFieldIds` | View secure field identifiers |
|
||||
| `obj.credential.createdAt` | View creation timestamp |
|
||||
| `obj.credential.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/credentials/fetch.ts](src/api/credentials/fetch.ts), [src/api/credentials/fetchByCompany.ts](src/api/credentials/fetchByCompany.ts), [src/api/credentials/fetchSubCredentials.ts](src/api/credentials/fetchSubCredentials.ts), [src/api/credential-types/fetchCredentials.ts](src/api/credential-types/fetchCredentials.ts)
|
||||
|
||||
### Credential Type (`obj.credentialType`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ------------------------------------ | ----------------------------------------- |
|
||||
| `obj.credentialType.id` | View credential type ID |
|
||||
| `obj.credentialType.name` | View credential type name |
|
||||
| `obj.credentialType.permissionScope` | View permission scope |
|
||||
| `obj.credentialType.icon` | View icon |
|
||||
| `obj.credentialType.fields` | View field definitions |
|
||||
| `obj.credentialType.credentialCount` | View count of credentials using this type |
|
||||
| `obj.credentialType.createdAt` | View creation timestamp |
|
||||
| `obj.credentialType.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/credential-types/fetch.ts](src/api/credential-types/fetch.ts), [src/api/credential-types/fetchAll.ts](src/api/credential-types/fetchAll.ts)
|
||||
|
||||
### User (`obj.user`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ---------------------- | -------------------------------- |
|
||||
| `obj.user.id` | View user ID |
|
||||
| `obj.user.name` | View user display name |
|
||||
| `obj.user.roles` | View assigned role monikers |
|
||||
| `obj.user.permissions` | View aggregated permission nodes |
|
||||
| `obj.user.login` | View login identifier |
|
||||
| `obj.user.email` | View email address |
|
||||
| `obj.user.image` | View profile image URL |
|
||||
| `obj.user.createdAt` | View creation timestamp |
|
||||
| `obj.user.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/user/@me/fetch.ts](src/api/user/@me/fetch.ts), [src/api/user/fetch.ts](src/api/user/fetch.ts), [src/api/user/fetchAll.ts](src/api/user/fetchAll.ts), [src/api/roles/getUsers.ts](src/api/roles/getUsers.ts)
|
||||
|
||||
### Role (`obj.role`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ---------------------- | -------------------------------- |
|
||||
| `obj.role.id` | View role ID |
|
||||
| `obj.role.title` | View role title |
|
||||
| `obj.role.moniker` | View role moniker |
|
||||
| `obj.role.permissions` | View role permission nodes |
|
||||
| `obj.role.users` | View users assigned to this role |
|
||||
| `obj.role.createdAt` | View creation timestamp |
|
||||
| `obj.role.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/roles/fetch.ts](src/api/roles/fetch.ts), [src/api/roles/fetchAll.ts](src/api/roles/fetchAll.ts), [src/api/user/fetchRoles.ts](src/api/user/fetchRoles.ts)
|
||||
|
||||
### Catalog Item (`obj.catalogItem`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ------------------------------------- | -------------------------------- |
|
||||
| `obj.catalogItem.id` | View catalog item ID |
|
||||
| `obj.catalogItem.cwCatalogId` | View ConnectWise catalog ID |
|
||||
| `obj.catalogItem.identifier` | View item identifier |
|
||||
| `obj.catalogItem.name` | View item name |
|
||||
| `obj.catalogItem.description` | View description |
|
||||
| `obj.catalogItem.customerDescription` | View customer-facing description |
|
||||
| `obj.catalogItem.internalNotes` | View internal notes |
|
||||
| `obj.catalogItem.manufacturer` | View manufacturer name |
|
||||
| `obj.catalogItem.manufactureCwId` | View manufacturer ConnectWise ID |
|
||||
| `obj.catalogItem.partNumber` | View part number |
|
||||
| `obj.catalogItem.vendorName` | View vendor name |
|
||||
| `obj.catalogItem.vendorSku` | View vendor SKU |
|
||||
| `obj.catalogItem.vendorCwId` | View vendor ConnectWise ID |
|
||||
| `obj.catalogItem.price` | View price |
|
||||
| `obj.catalogItem.cost` | View cost |
|
||||
| `obj.catalogItem.inactive` | View inactive flag |
|
||||
| `obj.catalogItem.salesTaxable` | View sales-taxable flag |
|
||||
| `obj.catalogItem.onHand` | View on-hand inventory count |
|
||||
| `obj.catalogItem.cwLastUpdated` | View CW last-updated timestamp |
|
||||
| `obj.catalogItem.linkedItems` | View linked catalog items |
|
||||
| `obj.catalogItem.createdAt` | View creation timestamp |
|
||||
| `obj.catalogItem.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/procurement/fetchAll.ts](src/api/procurement/fetchAll.ts), [src/api/procurement/[id]/fetch.ts](src/api/procurement/[id]/fetch.ts), [src/api/procurement/[id]/fetchLinked.ts](src/api/procurement/[id]/fetchLinked.ts)
|
||||
|
||||
### Opportunity (`obj.opportunity`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ------------------------------------ | ------------------------------- |
|
||||
| `obj.opportunity.id` | View opportunity ID |
|
||||
| `obj.opportunity.cwOpportunityId` | View ConnectWise opportunity ID |
|
||||
| `obj.opportunity.name` | View opportunity name |
|
||||
| `obj.opportunity.notes` | View notes |
|
||||
| `obj.opportunity.type` | View opportunity type |
|
||||
| `obj.opportunity.stage` | View stage |
|
||||
| `obj.opportunity.status` | View status |
|
||||
| `obj.opportunity.priority` | View priority |
|
||||
| `obj.opportunity.rating` | View rating |
|
||||
| `obj.opportunity.source` | View source |
|
||||
| `obj.opportunity.campaign` | View campaign |
|
||||
| `obj.opportunity.primarySalesRep` | View primary sales rep |
|
||||
| `obj.opportunity.secondarySalesRep` | View secondary sales rep |
|
||||
| `obj.opportunity.company` | View company |
|
||||
| `obj.opportunity.contact` | View contact |
|
||||
| `obj.opportunity.site` | View site |
|
||||
| `obj.opportunity.customerPO` | View customer PO |
|
||||
| `obj.opportunity.totalSalesTax` | View total sales tax |
|
||||
| `obj.opportunity.probability` | View probability percentage |
|
||||
| `obj.opportunity.location` | View location |
|
||||
| `obj.opportunity.department` | View department |
|
||||
| `obj.opportunity.expectedCloseDate` | View expected close date |
|
||||
| `obj.opportunity.pipelineChangeDate` | View pipeline change date |
|
||||
| `obj.opportunity.dateBecameLead` | View date became lead |
|
||||
| `obj.opportunity.closedDate` | View closed date |
|
||||
| `obj.opportunity.closedFlag` | View closed flag |
|
||||
| `obj.opportunity.closedBy` | View closed-by member |
|
||||
| `obj.opportunity.companyId` | View linked company ID |
|
||||
| `obj.opportunity.cwLastUpdated` | View CW last-updated timestamp |
|
||||
| `obj.opportunity.createdAt` | View creation timestamp |
|
||||
| `obj.opportunity.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts)
|
||||
|
||||
### UniFi Site (`obj.unifiSite`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ------------------------- | ----------------------------- |
|
||||
| `obj.unifiSite.id` | View site internal ID |
|
||||
| `obj.unifiSite.name` | View site name |
|
||||
| `obj.unifiSite.siteId` | View UniFi controller site ID |
|
||||
| `obj.unifiSite.companyId` | View linked company ID |
|
||||
| `obj.unifiSite.company` | View linked company object |
|
||||
| `obj.unifiSite.createdAt` | View creation timestamp |
|
||||
| `obj.unifiSite.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/unifi/sites/fetchAll.ts](src/api/unifi/sites/fetchAll.ts), [src/api/unifi/site/fetch.ts](src/api/unifi/site/fetch.ts), [src/api/companies/[id]/unifiSites.ts](src/api/companies/[id]/unifiSites.ts)
|
||||
|
||||
### WiFi Network (`unifi.site.wifi.read`)
|
||||
|
||||
See **UniFi Permissions > Field-Level Permission Gating** above for the full list of `unifi.site.wifi.read.<field>` nodes.
|
||||
|
||||
---
|
||||
|
||||
## Permission Issuers
|
||||
|
||||
Permissions can be issued by different sources:
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
"cors": "^2.8.6",
|
||||
"cuid": "^3.0.0",
|
||||
"hono": "^4.11.5",
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"keypair": "^1.0.4",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfmake": "^0.3.5",
|
||||
"prisma": "^7.3.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"zod": "^4.3.6",
|
||||
@@ -57,8 +60,14 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="],
|
||||
|
||||
"@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="],
|
||||
|
||||
"@prisma/adapter-pg": ["@prisma/adapter-pg@7.3.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.3.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-iuYQMbIPO6i9O45Fv8TB7vWu00BXhCaNAShenqF7gLExGDbnGp5BfFB4yz1K59zQ59jF6tQ9YHrg0P6/J3OoLg=="],
|
||||
|
||||
"@prisma/client": ["@prisma/client@7.3.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.3.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-FXBIxirqQfdC6b6HnNgxGmU7ydCPEPk7maHMOduJJfnTP+MuOGa15X4omjR/zpPUUpm8ef/mEFQjJudOGkXFcQ=="],
|
||||
@@ -93,6 +102,8 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
||||
|
||||
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
|
||||
@@ -113,10 +124,14 @@
|
||||
|
||||
"axios": ["axios@1.13.3", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g=="],
|
||||
|
||||
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
|
||||
|
||||
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
|
||||
|
||||
"blakets": ["blakets@0.1.12", "", { "dependencies": { "@prokopschield/argv": "^0.1.0-2" }, "bin": { "blake": "lib/demo.js", "blake2b": "lib/cli.js", "blake2s": "lib/cli.js", "blakejs": "lib/demo.js", "blakets": "lib/demo.js" } }, "sha512-ReOnLTDRlbExlTXbJZoA2xkvhzauJ7ldpvhKnb1cUNw8gdAHWHWOWG8XMjwpxQmmEZCDAR7VZiM5BYTUSOLVrw=="],
|
||||
|
||||
"brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||
@@ -131,6 +146,10 @@
|
||||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
|
||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -143,6 +162,8 @@
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"cuid": ["cuid@3.0.0", "", {}, "sha512-WZYYkHdIDnaxdeP8Misq3Lah5vFjJwGuItJuV+tvMafosMzw0nF297T7mrm8IOWiPJkV6gc7sa8pzx27+w25Zg=="],
|
||||
@@ -159,6 +180,8 @@
|
||||
|
||||
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
||||
|
||||
"dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
@@ -185,8 +208,12 @@
|
||||
|
||||
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
@@ -223,12 +250,16 @@
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="],
|
||||
|
||||
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"jpeg-exif": ["jpeg-exif@1.1.4", "", {}, "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ=="],
|
||||
|
||||
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
@@ -239,10 +270,16 @@
|
||||
|
||||
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
|
||||
|
||||
"linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
|
||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
||||
|
||||
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||
|
||||
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
|
||||
@@ -281,10 +318,18 @@
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="],
|
||||
|
||||
"pdfkit": ["pdfkit@0.17.2", "", { "dependencies": { "crypto-js": "^4.2.0", "fontkit": "^2.0.4", "jpeg-exif": "^1.1.4", "linebreak": "^1.1.0", "png-js": "^1.0.0" } }, "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw=="],
|
||||
|
||||
"pdfmake": ["pdfmake@0.3.5", "", { "dependencies": { "linebreak": "^1.1.0", "pdfkit": "^0.17.2", "xmldoc": "^2.0.3" } }, "sha512-DR7jRrK4lk7UiRT6pi+NeWhW1ToTsL2Y8CH+bFKNYz3M7agIVgeCtwARveEORhCAqoG3AUDrN318xU/lkOr1Bg=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"pg": ["pg@8.17.2", "", { "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw=="],
|
||||
@@ -305,6 +350,8 @@
|
||||
|
||||
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
||||
|
||||
"png-js": ["png-js@1.0.0", "", {}, "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="],
|
||||
|
||||
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
|
||||
|
||||
"postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="],
|
||||
@@ -331,16 +378,24 @@
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||
|
||||
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||
|
||||
"regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="],
|
||||
|
||||
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="],
|
||||
|
||||
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
|
||||
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
@@ -363,14 +418,24 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="],
|
||||
|
||||
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
|
||||
|
||||
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="],
|
||||
@@ -381,6 +446,8 @@
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"xmldoc": ["xmldoc@2.0.3", "", { "dependencies": { "sax": "^1.4.3" } }, "sha512-6gRk4NY/Jvg67xn7OzJuxLRsGgiXBaPUQplVJ/9l99uIugxh4FTOewYz5ic8WScj7Xx/2WvhENiQKwkK9RpE4w=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="],
|
||||
@@ -397,10 +464,18 @@
|
||||
|
||||
"@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="],
|
||||
|
||||
"@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"brotli/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"nypm/citty": ["citty@0.2.0", "", {}, "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA=="],
|
||||
|
||||
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||
|
||||
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"unicode-properties/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./tests/setup.ts"]
|
||||
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyze ConnectWise API call logs.
|
||||
|
||||
Looks for the most recent log file in cw-api-logs/ by default,
|
||||
or accepts an explicit path as an argument.
|
||||
|
||||
Usage:
|
||||
python3 analyze-cw-calls.py # latest file in cw-api-logs/
|
||||
python3 analyze-cw-calls.py cw-api-logs/specific.jsonl
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import glob
|
||||
import statistics
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# ── Colours ──────────────────────────────────────────────────────────────────
|
||||
|
||||
RED = "\033[91m"
|
||||
GREEN = "\033[92m"
|
||||
YELLOW = "\033[93m"
|
||||
CYAN = "\033[96m"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
def colour_duration(ms: float) -> str:
|
||||
if ms >= 10_000:
|
||||
return f"{RED}{ms:,.0f}ms{RESET}"
|
||||
if ms >= 5_000:
|
||||
return f"{YELLOW}{ms:,.0f}ms{RESET}"
|
||||
return f"{GREEN}{ms:,.0f}ms{RESET}"
|
||||
|
||||
def header(title: str) -> str:
|
||||
return f"\n{BOLD}{CYAN}{'─' * 60}\n {title}\n{'─' * 60}{RESET}"
|
||||
|
||||
# ── Resolve log file ────────────────────────────────────────────────────────
|
||||
|
||||
def find_latest_log() -> str:
|
||||
"""Find the most recent .jsonl file in cw-api-logs/."""
|
||||
log_dir = os.path.join(os.getcwd(), "cw-api-logs")
|
||||
files = sorted(glob.glob(os.path.join(log_dir, "*.jsonl")))
|
||||
if not files:
|
||||
print(f"{RED}No log files found in cw-api-logs/{RESET}")
|
||||
print(f"Run {BOLD}bun run dev:log{RESET} to start logging CW API calls.")
|
||||
sys.exit(1)
|
||||
return files[-1]
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
log_path = sys.argv[1]
|
||||
else:
|
||||
log_path = find_latest_log()
|
||||
|
||||
print(f"{DIM}Reading: {log_path}{RESET}")
|
||||
|
||||
entries = []
|
||||
parse_errors = 0
|
||||
with open(log_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
parse_errors += 1
|
||||
|
||||
if not entries:
|
||||
print("No entries found. Check the log file path.")
|
||||
sys.exit(1)
|
||||
|
||||
# ── Derived fields ───────────────────────────────────────────────────────────
|
||||
|
||||
durations = [e["durationMs"] for e in entries]
|
||||
errors = [e for e in entries if e.get("error")]
|
||||
successes = [e for e in entries if not e.get("error")]
|
||||
timestamps = [datetime.fromisoformat(e["timestamp"].replace("Z", "+00:00")) for e in entries]
|
||||
|
||||
time_span = (timestamps[-1] - timestamps[0]) if len(timestamps) > 1 else timedelta(0)
|
||||
|
||||
# Normalise the URL to a route pattern for grouping
|
||||
def normalise_url(url: str) -> str:
|
||||
parts = url.split("?")[0].rstrip("/").split("/")
|
||||
normalised = []
|
||||
for p in parts:
|
||||
if p.isdigit():
|
||||
normalised.append(":id")
|
||||
else:
|
||||
normalised.append(p)
|
||||
return "/".join(normalised)
|
||||
|
||||
# ── 1. Overview ──────────────────────────────────────────────────────────────
|
||||
|
||||
print(header("OVERVIEW"))
|
||||
print(f" Log file : {log_path}")
|
||||
print(f" Total calls : {BOLD}{len(entries):,}{RESET}")
|
||||
print(f" Successes : {GREEN}{len(successes):,}{RESET}")
|
||||
print(f" Failures : {RED}{len(errors):,}{RESET} ({len(errors)/len(entries)*100:.1f}%)")
|
||||
print(f" Time span : {time_span}")
|
||||
if time_span.total_seconds() > 0:
|
||||
rps = len(entries) / time_span.total_seconds()
|
||||
print(f" Avg req/sec : {rps:.2f}")
|
||||
if parse_errors:
|
||||
print(f" Parse errors : {YELLOW}{parse_errors}{RESET}")
|
||||
|
||||
# ── 2. Duration stats ───────────────────────────────────────────────────────
|
||||
|
||||
print(header("DURATION STATS (all calls)"))
|
||||
sorted_dur = sorted(durations)
|
||||
p50 = sorted_dur[len(sorted_dur) * 50 // 100]
|
||||
p90 = sorted_dur[len(sorted_dur) * 90 // 100]
|
||||
p95 = sorted_dur[len(sorted_dur) * 95 // 100]
|
||||
p99 = sorted_dur[len(sorted_dur) * 99 // 100]
|
||||
|
||||
print(f" Min : {colour_duration(min(durations))}")
|
||||
print(f" Max : {colour_duration(max(durations))}")
|
||||
print(f" Mean : {colour_duration(statistics.mean(durations))}")
|
||||
print(f" Median (p50) : {colour_duration(p50)}")
|
||||
print(f" p90 : {colour_duration(p90)}")
|
||||
print(f" p95 : {colour_duration(p95)}")
|
||||
print(f" p99 : {colour_duration(p99)}")
|
||||
print(f" Std dev : {statistics.stdev(durations):,.0f}ms" if len(durations) > 1 else "")
|
||||
|
||||
# Duration buckets
|
||||
buckets = {"<500ms": 0, "500ms-1s": 0, "1-3s": 0, "3-5s": 0, "5-10s": 0, "10-20s": 0, "20s+": 0}
|
||||
for d in durations:
|
||||
if d < 500: buckets["<500ms"] += 1
|
||||
elif d < 1000: buckets["500ms-1s"] += 1
|
||||
elif d < 3000: buckets["1-3s"] += 1
|
||||
elif d < 5000: buckets["3-5s"] += 1
|
||||
elif d < 10000: buckets["5-10s"] += 1
|
||||
elif d < 20000: buckets["10-20s"] += 1
|
||||
else: buckets["20s+"] += 1
|
||||
|
||||
print(f"\n {BOLD}Distribution:{RESET}")
|
||||
max_bar = 40
|
||||
max_count = max(buckets.values()) if buckets else 1
|
||||
for label, count in buckets.items():
|
||||
bar_len = int(count / max_count * max_bar) if max_count else 0
|
||||
pct = count / len(durations) * 100
|
||||
bar = "█" * bar_len
|
||||
clr = GREEN if "500" in label or "<" in label else (YELLOW if "1-3" in label or "3-5" in label else RED)
|
||||
print(f" {label:>10s} {clr}{bar}{RESET} {count:>5,} ({pct:5.1f}%)")
|
||||
|
||||
# ── 3. Errors breakdown ─────────────────────────────────────────────────────
|
||||
|
||||
print(header("ERROR BREAKDOWN"))
|
||||
if not errors:
|
||||
print(f" {GREEN}No errors! 🎉{RESET}")
|
||||
else:
|
||||
error_codes = Counter()
|
||||
for e in errors:
|
||||
err_str = e.get("error", "unknown")
|
||||
code = err_str.split(":")[0] if ":" in err_str else err_str
|
||||
error_codes[code] += 1
|
||||
|
||||
for code, count in error_codes.most_common():
|
||||
print(f" {RED}{code:<30s}{RESET} {count:>5,} ({count/len(entries)*100:.1f}%)")
|
||||
|
||||
# Errored URLs
|
||||
errored_urls = Counter(normalise_url(e["url"]) for e in errors)
|
||||
print(f"\n {BOLD}Top errored endpoints:{RESET}")
|
||||
for url, count in errored_urls.most_common(10):
|
||||
print(f" {count:>5,} {url}")
|
||||
|
||||
# ── 4. Slowest individual calls ─────────────────────────────────────────────
|
||||
|
||||
print(header("TOP 20 SLOWEST CALLS"))
|
||||
slowest = sorted(entries, key=lambda e: e["durationMs"], reverse=True)[:20]
|
||||
for i, e in enumerate(slowest, 1):
|
||||
status = e.get("status") or f"{RED}ERR{RESET}"
|
||||
err_tag = f" {RED}[{e['error'].split(':')[0]}]{RESET}" if e.get("error") else ""
|
||||
print(f" {i:>2}. {colour_duration(e['durationMs']):>20s} {e['method']:>4s} {e['url'][:60]:<60s} {DIM}{status}{RESET}{err_tag}")
|
||||
|
||||
# ── 5. Per-endpoint stats ───────────────────────────────────────────────────
|
||||
|
||||
print(header("PER-ENDPOINT STATS (by route pattern)"))
|
||||
by_route = defaultdict(list)
|
||||
for e in entries:
|
||||
route = normalise_url(e["url"])
|
||||
by_route[route].append(e)
|
||||
|
||||
# Sort by total time spent descending (most impactful)
|
||||
route_stats = []
|
||||
for route, calls in by_route.items():
|
||||
durs = [c["durationMs"] for c in calls]
|
||||
errs = sum(1 for c in calls if c.get("error"))
|
||||
sorted_d = sorted(durs)
|
||||
route_stats.append({
|
||||
"route": route,
|
||||
"count": len(calls),
|
||||
"errors": errs,
|
||||
"total_ms": sum(durs),
|
||||
"mean": statistics.mean(durs),
|
||||
"p50": sorted_d[len(sorted_d) * 50 // 100],
|
||||
"p95": sorted_d[len(sorted_d) * 95 // 100],
|
||||
"max": max(durs),
|
||||
})
|
||||
|
||||
route_stats.sort(key=lambda r: r["total_ms"], reverse=True)
|
||||
|
||||
print(f" {'Route':<55s} {'Count':>6s} {'Errs':>5s} {'Mean':>8s} {'p50':>8s} {'p95':>8s} {'Max':>8s} {'Total':>10s}")
|
||||
print(f" {'─' * 55} {'─' * 6} {'─' * 5} {'─' * 8} {'─' * 8} {'─' * 8} {'─' * 8} {'─' * 10}")
|
||||
for r in route_stats[:25]:
|
||||
err_str = f"{RED}{r['errors']}{RESET}" if r['errors'] else f"{DIM}0{RESET}"
|
||||
print(
|
||||
f" {r['route']:<55s} {r['count']:>6,} {err_str:>14s} "
|
||||
f"{r['mean']:>7,.0f}ms {r['p50']:>7,.0f}ms {r['p95']:>7,.0f}ms "
|
||||
f"{r['max']:>7,.0f}ms {r['total_ms']/1000:>8,.1f}s"
|
||||
)
|
||||
|
||||
# ── 6. HTTP method breakdown ────────────────────────────────────────────────
|
||||
|
||||
print(header("BY HTTP METHOD"))
|
||||
by_method = defaultdict(list)
|
||||
for e in entries:
|
||||
by_method[e["method"]].append(e["durationMs"])
|
||||
|
||||
print(f" {'Method':<8s} {'Count':>7s} {'Mean':>9s} {'p95':>9s} {'Max':>9s}")
|
||||
print(f" {'─' * 8} {'─' * 7} {'─' * 9} {'─' * 9} {'─' * 9}")
|
||||
for method in sorted(by_method.keys()):
|
||||
durs = by_method[method]
|
||||
sd = sorted(durs)
|
||||
print(
|
||||
f" {method:<8s} {len(durs):>7,} "
|
||||
f"{statistics.mean(durs):>8,.0f}ms "
|
||||
f"{sd[len(sd)*95//100]:>8,.0f}ms "
|
||||
f"{max(durs):>8,.0f}ms"
|
||||
)
|
||||
|
||||
# ── 7. Timeline (calls per minute) ──────────────────────────────────────────
|
||||
|
||||
if time_span.total_seconds() > 60:
|
||||
print(header("TIMELINE (per-minute throughput & errors)"))
|
||||
by_minute = defaultdict(lambda: {"count": 0, "errors": 0, "dur_sum": 0})
|
||||
for e in entries:
|
||||
ts = e["timestamp"][:16] # YYYY-MM-DDTHH:MM
|
||||
by_minute[ts]["count"] += 1
|
||||
by_minute[ts]["dur_sum"] += e["durationMs"]
|
||||
if e.get("error"):
|
||||
by_minute[ts]["errors"] += 1
|
||||
|
||||
for minute in sorted(by_minute.keys()):
|
||||
m = by_minute[minute]
|
||||
avg = m["dur_sum"] / m["count"] if m["count"] else 0
|
||||
err_part = f" {RED}({m['errors']} errs){RESET}" if m["errors"] else ""
|
||||
bar = "▓" * min(m["count"] // 5, 50)
|
||||
avg_clr = colour_duration(avg)
|
||||
print(f" {minute} {m['count']:>5,} reqs avg {avg_clr:>20s} {bar}{err_part}")
|
||||
|
||||
# ── 8. Concurrency hotspots ─────────────────────────────────────────────────
|
||||
|
||||
print(header("CONCURRENCY HOTSPOTS (calls starting within 100ms of each other)"))
|
||||
ts_ms = [int(t.timestamp() * 1000) for t in timestamps]
|
||||
bursts = []
|
||||
i = 0
|
||||
while i < len(ts_ms):
|
||||
j = i
|
||||
while j < len(ts_ms) - 1 and ts_ms[j + 1] - ts_ms[j] < 100:
|
||||
j += 1
|
||||
burst_size = j - i + 1
|
||||
if burst_size >= 5:
|
||||
burst_entries = entries[i:j + 1]
|
||||
avg_dur = statistics.mean(e["durationMs"] for e in burst_entries)
|
||||
bursts.append((burst_size, entries[i]["timestamp"], avg_dur, burst_entries))
|
||||
i = j + 1
|
||||
|
||||
bursts.sort(key=lambda b: b[0], reverse=True)
|
||||
if bursts:
|
||||
print(f" Found {len(bursts)} burst(s) of ≥5 concurrent requests\n")
|
||||
for size, ts, avg, _ in bursts[:10]:
|
||||
print(f" {YELLOW}{size:>3} concurrent{RESET} at {ts} avg {colour_duration(avg)}")
|
||||
else:
|
||||
print(f" {GREEN}No major concurrency bursts detected.{RESET}")
|
||||
|
||||
# ── 9. Summary / recommendations ────────────────────────────────────────────
|
||||
|
||||
print(header("SUMMARY"))
|
||||
err_rate = len(errors) / len(entries) * 100
|
||||
slow_5s = sum(1 for d in durations if d >= 5000)
|
||||
slow_pct = slow_5s / len(entries) * 100
|
||||
|
||||
if err_rate > 5:
|
||||
print(f" {RED}⚠ Error rate is {err_rate:.1f}% — CW API is struggling{RESET}")
|
||||
elif err_rate > 1:
|
||||
print(f" {YELLOW}⚠ Error rate is {err_rate:.1f}% — some instability{RESET}")
|
||||
else:
|
||||
print(f" {GREEN}✓ Error rate is {err_rate:.1f}% — acceptable{RESET}")
|
||||
|
||||
if slow_pct > 10:
|
||||
print(f" {RED}⚠ {slow_5s:,} calls ({slow_pct:.1f}%) took >5s — CW is slow or rate-limiting{RESET}")
|
||||
elif slow_pct > 2:
|
||||
print(f" {YELLOW}⚠ {slow_5s:,} calls ({slow_pct:.1f}%) took >5s{RESET}")
|
||||
else:
|
||||
print(f" {GREEN}✓ Only {slow_5s:,} calls ({slow_pct:.1f}%) over 5s{RESET}")
|
||||
|
||||
if bursts:
|
||||
max_burst = max(b[0] for b in bursts)
|
||||
print(f" {YELLOW}⚠ Max concurrency burst: {max_burst} simultaneous requests — consider lowering CONCURRENCY{RESET}")
|
||||
|
||||
total_time_s = sum(durations) / 1000
|
||||
print(f"\n Total wall-clock time spent waiting on CW: {BOLD}{total_time_s:,.1f}s{RESET} ({total_time_s/60:,.1f} min)")
|
||||
print()
|
||||
@@ -0,0 +1,441 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_iso(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
normalized = value.replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def first_non_empty(*values: Any) -> str:
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and value.strip() == "":
|
||||
continue
|
||||
return str(value)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def top_lines(counter: Counter[str], limit: int) -> list[str]:
|
||||
return [f"{k}: {v}" for k, v in counter.most_common(limit)]
|
||||
|
||||
|
||||
def fmt_pct(part: int, total: int) -> str:
|
||||
if total == 0:
|
||||
return "0.0%"
|
||||
return f"{(part / total) * 100:.1f}%"
|
||||
|
||||
|
||||
def human_duration(start: datetime | None, end: datetime | None) -> str:
|
||||
if start is None or end is None:
|
||||
return "unknown"
|
||||
|
||||
delta = end - start
|
||||
total_seconds = int(delta.total_seconds())
|
||||
hours, remainder = divmod(total_seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
return f"{hours}h {minutes}m {seconds}s"
|
||||
|
||||
|
||||
def truncate(value: str, max_len: int = 90) -> str:
|
||||
if len(value) <= max_len:
|
||||
return value
|
||||
return value[: max_len - 1] + "…"
|
||||
|
||||
|
||||
def add_section(lines: list[str], title: str) -> None:
|
||||
lines.append("")
|
||||
lines.append(title)
|
||||
lines.append("-" * len(title))
|
||||
|
||||
|
||||
def supports_color(enabled: bool) -> bool:
|
||||
return enabled
|
||||
|
||||
|
||||
def paint(text: str, code: str, use_color: bool) -> str:
|
||||
if not use_color:
|
||||
return text
|
||||
return f"\033[{code}m{text}\033[0m"
|
||||
|
||||
|
||||
def good_bad_neutral(value: str, state: str, use_color: bool) -> str:
|
||||
if state == "good":
|
||||
return paint(value, "32", use_color)
|
||||
if state == "bad":
|
||||
return paint(value, "31", use_color)
|
||||
return paint(value, "36", use_color)
|
||||
|
||||
|
||||
def add_ranked_counter(
|
||||
lines: list[str],
|
||||
title: str,
|
||||
counter: Counter[str],
|
||||
top_n: int,
|
||||
total: int,
|
||||
truncate_labels: bool = False,
|
||||
) -> None:
|
||||
lines.append(f"• {title}")
|
||||
items = counter.most_common(top_n)
|
||||
if not items:
|
||||
lines.append(" (no data)")
|
||||
return
|
||||
|
||||
for index, (key, count) in enumerate(items, start=1):
|
||||
label = truncate(key) if truncate_labels else key
|
||||
lines.append(f" {index:>2}. {label:<90} {count:>4} {fmt_pct(count, total):>6}")
|
||||
|
||||
|
||||
def stream_row_summary(row: dict[str, Any], use_color: bool, max_path: int) -> str:
|
||||
request = row.get("request") or {}
|
||||
response = row.get("response") or {}
|
||||
body_parsed = request.get("bodyParsed") or {}
|
||||
entity_parsed = request.get("entityParsed") or {}
|
||||
summary = request.get("summary") or {}
|
||||
|
||||
timestamp = parse_iso(row.get("timestamp"))
|
||||
time_label = timestamp.astimezone(timezone.utc).strftime("%H:%M:%S") if timestamp else "--:--:--"
|
||||
|
||||
method = first_non_empty(request.get("method"))
|
||||
path = first_non_empty(request.get("path"))
|
||||
endpoint = path.split("?", 1)[0]
|
||||
status_code = first_non_empty(response.get("status"))
|
||||
|
||||
event_type = first_non_empty(body_parsed.get("Type"), summary.get("type"))
|
||||
action = first_non_empty(
|
||||
body_parsed.get("Action"),
|
||||
summary.get("action"),
|
||||
request.get("query", {}).get("params", {}).get("action"),
|
||||
)
|
||||
item_id = first_non_empty(body_parsed.get("ID"), summary.get("id"), request.get("query", {}).get("inferredId"))
|
||||
actor = first_non_empty(
|
||||
request.get("query", {}).get("params", {}).get("memberId"),
|
||||
summary.get("entityUpdatedBy"),
|
||||
entity_parsed.get("UpdatedBy"),
|
||||
)
|
||||
entity_status = first_non_empty(entity_parsed.get("StatusName"), summary.get("entityStatus"))
|
||||
|
||||
endpoint_label = truncate(endpoint, max_path)
|
||||
status_state = "good" if status_code.startswith("2") else "bad"
|
||||
status_colored = good_bad_neutral(status_code, status_state, use_color)
|
||||
event_colored = paint(f"{event_type}.{action}", "36", use_color)
|
||||
endpoint_colored = paint(endpoint_label, "94", use_color)
|
||||
|
||||
return (
|
||||
f"[{time_label}] {method:<4} {endpoint_colored:<20} "
|
||||
f"{status_colored:>3} {event_colored:<22} "
|
||||
f"id={item_id:<7} actor={truncate(actor, 16):<16} status={truncate(entity_status, 22)}"
|
||||
)
|
||||
|
||||
|
||||
def endpoint_stream_summary(log_path: Path, use_color: bool, max_path: int) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append(paint("ENDPOINT STREAM (chronological)", "1;95", use_color))
|
||||
lines.append(paint("────────────────────────────────────────────────────────────────────────────────────────────", "90", use_color))
|
||||
|
||||
count = 0
|
||||
invalid = 0
|
||||
with log_path.open("r", encoding="utf-8") as handle:
|
||||
for raw_line in handle:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
invalid += 1
|
||||
continue
|
||||
|
||||
lines.append(stream_row_summary(row, use_color=use_color, max_path=max_path))
|
||||
count += 1
|
||||
|
||||
lines.append(paint("────────────────────────────────────────────────────────────────────────────────────────────", "90", use_color))
|
||||
lines.append(
|
||||
f"events={count} invalid={good_bad_neutral(str(invalid), 'good' if invalid == 0 else 'bad', use_color)}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogStats:
|
||||
total_rows: int = 0
|
||||
parsed_rows: int = 0
|
||||
invalid_rows: int = 0
|
||||
earliest: datetime | None = None
|
||||
latest: datetime | None = None
|
||||
|
||||
methods: Counter[str] = None # type: ignore[assignment]
|
||||
paths: Counter[str] = None # type: ignore[assignment]
|
||||
endpoint_roots: Counter[str] = None # type: ignore[assignment]
|
||||
response_statuses: Counter[str] = None # type: ignore[assignment]
|
||||
event_types: Counter[str] = None # type: ignore[assignment]
|
||||
actions: Counter[str] = None # type: ignore[assignment]
|
||||
type_action_combo: Counter[str] = None # type: ignore[assignment]
|
||||
company_ids: Counter[str] = None # type: ignore[assignment]
|
||||
|
||||
source_members: Counter[str] = None # type: ignore[assignment]
|
||||
actor_members: Counter[str] = None # type: ignore[assignment]
|
||||
entity_updated_by: Counter[str] = None # type: ignore[assignment]
|
||||
|
||||
requests_by_hour: Counter[str] = None # type: ignore[assignment]
|
||||
requests_by_minute: Counter[str] = None # type: ignore[assignment]
|
||||
endpoint_by_hour: dict[str, Counter[str]] = None # type: ignore[assignment]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.methods = Counter()
|
||||
self.paths = Counter()
|
||||
self.endpoint_roots = Counter()
|
||||
self.response_statuses = Counter()
|
||||
self.event_types = Counter()
|
||||
self.actions = Counter()
|
||||
self.type_action_combo = Counter()
|
||||
self.company_ids = Counter()
|
||||
|
||||
self.source_members = Counter()
|
||||
self.actor_members = Counter()
|
||||
self.entity_updated_by = Counter()
|
||||
|
||||
self.requests_by_hour = Counter()
|
||||
self.requests_by_minute = Counter()
|
||||
self.endpoint_by_hour = defaultdict(Counter)
|
||||
|
||||
def add_timestamp(self, timestamp: datetime | None) -> None:
|
||||
if timestamp is None:
|
||||
return
|
||||
|
||||
self.earliest = timestamp if self.earliest is None else min(self.earliest, timestamp)
|
||||
self.latest = timestamp if self.latest is None else max(self.latest, timestamp)
|
||||
|
||||
hour_bucket = timestamp.astimezone(timezone.utc).strftime("%Y-%m-%d %H:00 UTC")
|
||||
minute_bucket = timestamp.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
self.requests_by_hour[hour_bucket] += 1
|
||||
self.requests_by_minute[minute_bucket] += 1
|
||||
|
||||
def summarize(self, top_n: int, busiest_n: int, use_color: bool) -> str:
|
||||
duration_line = human_duration(self.earliest, self.latest)
|
||||
time_range_line = "unknown"
|
||||
if self.earliest and self.latest:
|
||||
time_range_line = f"{self.earliest.isoformat()} → {self.latest.isoformat()}"
|
||||
|
||||
total_requests = self.parsed_rows
|
||||
success_count = self.response_statuses.get("200", 0)
|
||||
success_pct = fmt_pct(success_count, sum(self.response_statuses.values()))
|
||||
invalid_state = "good" if self.invalid_rows == 0 else "bad"
|
||||
|
||||
top_endpoints = self.endpoint_roots.most_common(2)
|
||||
top_users = self.actor_members.most_common(3)
|
||||
top_minutes = self.requests_by_minute.most_common(busiest_n)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append(paint("WEBHOOK SNAPSHOT", "1;95", use_color))
|
||||
lines.append(paint("────────────────────────────────────────────────────────", "90", use_color))
|
||||
lines.append(
|
||||
" "
|
||||
+ paint("Rows", "1;97", use_color)
|
||||
+ f": {self.total_rows:<4} "
|
||||
+ paint("Parsed", "1;97", use_color)
|
||||
+ f": {self.parsed_rows:<4} "
|
||||
+ paint("Invalid", "1;97", use_color)
|
||||
+ f": {good_bad_neutral(str(self.invalid_rows), invalid_state, use_color)}"
|
||||
)
|
||||
lines.append(
|
||||
" "
|
||||
+ paint("Window", "1;97", use_color)
|
||||
+ f": {duration_line:<12} "
|
||||
+ paint("Success", "1;97", use_color)
|
||||
+ f": {good_bad_neutral(success_pct, 'good' if success_count else 'neutral', use_color)}"
|
||||
)
|
||||
lines.append(" " + paint("UTC Range", "1;97", use_color) + f": {time_range_line}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(paint("Top Endpoints", "1;94", use_color))
|
||||
if top_endpoints:
|
||||
for endpoint, count in top_endpoints:
|
||||
lines.append(f" • {endpoint:<14} {count:>4} ({fmt_pct(count, total_requests)})")
|
||||
if not top_endpoints:
|
||||
lines.append(" • (no data)")
|
||||
|
||||
lines.append("")
|
||||
lines.append(paint("Most Active Users (query memberId)", "1;94", use_color))
|
||||
if top_users:
|
||||
for user, count in top_users:
|
||||
lines.append(f" • {user:<18} {count:>4} ({fmt_pct(count, total_requests)})")
|
||||
if not top_users:
|
||||
lines.append(" • (no data)")
|
||||
|
||||
lines.append("")
|
||||
lines.append(paint("Busiest Minutes", "1;94", use_color))
|
||||
if top_minutes:
|
||||
for minute, count in top_minutes:
|
||||
lines.append(f" • {minute:<22} {count:>3}")
|
||||
if not top_minutes:
|
||||
lines.append(" • (no data)")
|
||||
|
||||
lines.append("")
|
||||
lines.append(paint("Request Mix", "1;94", use_color))
|
||||
method_line = ", ".join([f"{k}:{v}" for k, v in self.methods.most_common(3)]) or "(no data)"
|
||||
event_line = ", ".join([f"{k}:{v}" for k, v in self.event_types.most_common(3)]) or "(no data)"
|
||||
action_line = ", ".join([f"{k}:{v}" for k, v in self.actions.most_common(3)]) or "(no data)"
|
||||
lines.append(f" • Methods : {method_line}")
|
||||
lines.append(f" • Types : {event_line}")
|
||||
lines.append(f" • Actions : {action_line}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(paint("Status Codes", "1;94", use_color))
|
||||
if self.response_statuses:
|
||||
status_total = sum(self.response_statuses.values())
|
||||
for status, count in self.response_statuses.most_common(5):
|
||||
state = "good" if status.startswith("2") else "bad"
|
||||
status_label = good_bad_neutral(status, state, use_color)
|
||||
lines.append(f" • {status_label}: {count} ({fmt_pct(count, status_total)})")
|
||||
if not self.response_statuses:
|
||||
lines.append(" • (no data)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def update_stats(stats: LogStats, row: dict[str, Any]) -> None:
|
||||
timestamp = parse_iso(row.get("timestamp"))
|
||||
stats.add_timestamp(timestamp)
|
||||
|
||||
request = row.get("request") or {}
|
||||
response = row.get("response") or {}
|
||||
body_parsed = request.get("bodyParsed") or {}
|
||||
entity_parsed = request.get("entityParsed") or {}
|
||||
|
||||
method = first_non_empty(request.get("method"))
|
||||
path = first_non_empty(request.get("path"))
|
||||
endpoint_root = path.split("?", 1)[0]
|
||||
status = first_non_empty(response.get("status"))
|
||||
|
||||
event_type = first_non_empty(
|
||||
body_parsed.get("Type"),
|
||||
request.get("summary", {}).get("type"),
|
||||
)
|
||||
action = first_non_empty(
|
||||
body_parsed.get("Action"),
|
||||
request.get("summary", {}).get("action"),
|
||||
request.get("query", {}).get("params", {}).get("action"),
|
||||
)
|
||||
combo = f"{event_type}:{action}"
|
||||
|
||||
source_member = first_non_empty(
|
||||
body_parsed.get("MemberId"),
|
||||
request.get("summary", {}).get("memberId"),
|
||||
)
|
||||
actor_member = first_non_empty(
|
||||
request.get("query", {}).get("params", {}).get("memberId"),
|
||||
request.get("summary", {}).get("entityUpdatedBy"),
|
||||
)
|
||||
updated_by = first_non_empty(
|
||||
entity_parsed.get("UpdatedBy"),
|
||||
request.get("summary", {}).get("entityUpdatedBy"),
|
||||
)
|
||||
company_id = first_non_empty(body_parsed.get("CompanyId"), request.get("headers", {}).get("companyname"))
|
||||
|
||||
stats.methods[method] += 1
|
||||
stats.paths[path] += 1
|
||||
stats.endpoint_roots[endpoint_root] += 1
|
||||
stats.response_statuses[status] += 1
|
||||
stats.event_types[event_type] += 1
|
||||
stats.actions[action] += 1
|
||||
stats.type_action_combo[combo] += 1
|
||||
stats.company_ids[company_id] += 1
|
||||
|
||||
stats.source_members[source_member] += 1
|
||||
stats.actor_members[actor_member] += 1
|
||||
stats.entity_updated_by[updated_by] += 1
|
||||
|
||||
if timestamp:
|
||||
bucket = timestamp.astimezone(timezone.utc).strftime("%Y-%m-%d %H:00 UTC")
|
||||
stats.endpoint_by_hour[endpoint_root][bucket] += 1
|
||||
|
||||
|
||||
def analyze_file(log_path: Path) -> LogStats:
|
||||
stats = LogStats()
|
||||
|
||||
with log_path.open("r", encoding="utf-8") as handle:
|
||||
for raw_line in handle:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
stats.total_rows += 1
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
stats.invalid_rows += 1
|
||||
continue
|
||||
|
||||
stats.parsed_rows += 1
|
||||
update_stats(stats, row)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze webhook JSONL logs by users, time, and request types."
|
||||
)
|
||||
parser.add_argument("log_file", help="Path to JSONL log file")
|
||||
parser.add_argument("--top", type=int, default=10, help="Top N entries per section (default: 10)")
|
||||
parser.add_argument(
|
||||
"--busiest-minutes",
|
||||
type=int,
|
||||
default=5,
|
||||
help="How many top minute buckets to show (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-color",
|
||||
action="store_true",
|
||||
help="Disable ANSI colors",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--endpoint-stream",
|
||||
action="store_true",
|
||||
help="Show chronological one-line summary per webhook, similar to live test webserver logs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-path",
|
||||
type=int,
|
||||
default=18,
|
||||
help="Max endpoint width in stream mode before truncation (default: 18)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
log_path = Path(args.log_file)
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
raise SystemExit(f"Log file not found: {log_path}")
|
||||
|
||||
use_color = supports_color(not args.no_color)
|
||||
|
||||
if args.endpoint_stream:
|
||||
print(endpoint_stream_summary(log_path, use_color=use_color, max_path=max(args.max_path, 10)))
|
||||
return
|
||||
|
||||
stats = analyze_file(log_path)
|
||||
print(
|
||||
stats.summarize(
|
||||
top_n=max(args.top, 1),
|
||||
busiest_n=max(args.busiest_minutes, 1),
|
||||
use_color=use_color,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,900 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a print-friendly PDF report from the latest test-webserver log file.
|
||||
|
||||
Usage:
|
||||
python3 generate_log_report.py [optional_log_file_path]
|
||||
|
||||
If no path is given, the script finds the latest test-webserver-*.jsonl
|
||||
file in ../cw-api-logs/.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
from datetime import datetime, timezone
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import LETTER
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate,
|
||||
Paragraph,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
PageBreak,
|
||||
HRFlowable,
|
||||
KeepTogether,
|
||||
)
|
||||
from reportlab.graphics.shapes import Drawing, String
|
||||
from reportlab.graphics.charts.piecharts import Pie
|
||||
from reportlab.graphics.charts.barcharts import VerticalBarChart
|
||||
|
||||
# ─── Print-friendly color palette ─────────────────────────────────────────────
|
||||
# Minimal ink: white backgrounds, thin borders, dark text, subtle accents
|
||||
HEADER_BG = colors.HexColor("#2c3e50") # Dark header (used sparingly)
|
||||
ACCENT = colors.HexColor("#2980b9") # Muted blue
|
||||
ACCENT_2 = colors.HexColor("#27ae60") # Muted green
|
||||
ACCENT_3 = colors.HexColor("#8e44ad") # Muted purple
|
||||
WHITE = colors.white
|
||||
GRAY_50 = colors.HexColor("#fafafa")
|
||||
GRAY_100 = colors.HexColor("#f5f5f5")
|
||||
GRAY_200 = colors.HexColor("#e0e0e0")
|
||||
GRAY_400 = colors.HexColor("#bdbdbd")
|
||||
GRAY_600 = colors.HexColor("#757575")
|
||||
GRAY_800 = colors.HexColor("#424242")
|
||||
GRAY_900 = colors.HexColor("#212121")
|
||||
|
||||
# Pie/chart fills — muted, distinguishable in B&W too
|
||||
PIE_COLORS = [
|
||||
colors.HexColor("#5b9bd5"), # steel blue
|
||||
colors.HexColor("#ed7d31"), # soft orange
|
||||
colors.HexColor("#a5a5a5"), # gray
|
||||
colors.HexColor("#ffc000"), # amber
|
||||
colors.HexColor("#70ad47"), # olive green
|
||||
colors.HexColor("#4472c4"), # darker blue
|
||||
colors.HexColor("#c55a11"), # brown
|
||||
colors.HexColor("#7030a0"), # purple
|
||||
]
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def find_latest_log(base_dir):
|
||||
pattern = os.path.join(base_dir, "test-webserver-*.jsonl")
|
||||
files = sorted(glob.glob(pattern))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No test-webserver log files found in {base_dir}")
|
||||
return files[-1]
|
||||
|
||||
|
||||
def parse_log(path):
|
||||
entries = []
|
||||
with open(path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return entries
|
||||
|
||||
|
||||
def fmt_ts(iso_str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
except Exception:
|
||||
return str(iso_str)
|
||||
|
||||
|
||||
def duration_str(seconds):
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes = seconds / 60
|
||||
if minutes < 60:
|
||||
return f"{minutes:.1f}m"
|
||||
hours = minutes / 60
|
||||
return f"{hours:.1f}h"
|
||||
|
||||
|
||||
def truncate(s, max_len=50):
|
||||
s = str(s)
|
||||
return s if len(s) <= max_len else s[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def resolve_actor(entry):
|
||||
"""
|
||||
Derive the actor exactly like testWebserver.ts does:
|
||||
entityUpdatedBy ?? query.params.memberId ?? summary.memberId ?? "-"
|
||||
The summary is already stored in request.summary in the log.
|
||||
"""
|
||||
req = entry.get("request", {})
|
||||
summary = req.get("summary") or {}
|
||||
query = req.get("query") or {}
|
||||
params = query.get("params") or {}
|
||||
|
||||
return str(
|
||||
summary.get("entityUpdatedBy")
|
||||
or params.get("memberId")
|
||||
or summary.get("memberId")
|
||||
or "-"
|
||||
)
|
||||
|
||||
|
||||
# ─── Analysis ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def analyze(entries):
|
||||
stats = {}
|
||||
|
||||
timestamps = []
|
||||
for e in entries:
|
||||
ts = e.get("timestamp")
|
||||
if ts:
|
||||
try:
|
||||
timestamps.append(datetime.fromisoformat(ts.replace("Z", "+00:00")))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
timestamps.sort()
|
||||
stats["total_entries"] = len(entries)
|
||||
stats["first_ts"] = timestamps[0] if timestamps else None
|
||||
stats["last_ts"] = timestamps[-1] if timestamps else None
|
||||
stats["duration_seconds"] = (
|
||||
(timestamps[-1] - timestamps[0]).total_seconds() if len(timestamps) >= 2 else 0
|
||||
)
|
||||
|
||||
# Global counters
|
||||
methods = Counter()
|
||||
paths = Counter()
|
||||
statuses = Counter()
|
||||
actions = Counter()
|
||||
types = Counter()
|
||||
actors = Counter()
|
||||
companies = Counter()
|
||||
entity_ids = set()
|
||||
stages = Counter()
|
||||
ratings = Counter()
|
||||
hourly_buckets = Counter()
|
||||
minute_buckets = Counter()
|
||||
|
||||
for e in entries:
|
||||
req = e.get("request", {})
|
||||
resp = e.get("response", {})
|
||||
bp = req.get("bodyParsed") or {}
|
||||
entity = req.get("entityParsed") or bp.get("Entity") or {}
|
||||
|
||||
methods[req.get("method", "?")] += 1
|
||||
raw_path = req.get("path", "?").split("?")[0]
|
||||
paths[raw_path] += 1
|
||||
statuses[resp.get("status", "?")] += 1
|
||||
actions[bp.get("Action", "?")] += 1
|
||||
types[bp.get("Type", "?")] += 1
|
||||
|
||||
actor = resolve_actor(e)
|
||||
actors[actor] += 1
|
||||
|
||||
if isinstance(entity, dict):
|
||||
cn = entity.get("CompanyName")
|
||||
if cn:
|
||||
companies[cn] += 1
|
||||
eid = entity.get("Id")
|
||||
if eid is not None:
|
||||
entity_ids.add(eid)
|
||||
stage = entity.get("StageName")
|
||||
if stage:
|
||||
stages[stage] += 1
|
||||
rating = entity.get("Rating")
|
||||
if rating:
|
||||
ratings[rating] += 1
|
||||
|
||||
ts_str = e.get("timestamp")
|
||||
if ts_str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
hourly_buckets[dt.strftime("%H:00")] += 1
|
||||
minute_buckets[dt.strftime("%H:%M")] += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Per-actor deep stats ──
|
||||
actor_details = defaultdict(lambda: {
|
||||
"count": 0,
|
||||
"actions": Counter(),
|
||||
"types": Counter(),
|
||||
"companies": Counter(),
|
||||
"entity_ids": set(),
|
||||
"stages": Counter(),
|
||||
"ratings": Counter(),
|
||||
"timestamps": [],
|
||||
"statuses": Counter(),
|
||||
"paths": Counter(),
|
||||
"member_ids": Counter(),
|
||||
"sales_reps": Counter(),
|
||||
"total_estimated": 0.0,
|
||||
})
|
||||
|
||||
for e in entries:
|
||||
req = e.get("request", {})
|
||||
resp = e.get("response", {})
|
||||
bp = req.get("bodyParsed") or {}
|
||||
entity = req.get("entityParsed") or bp.get("Entity") or {}
|
||||
summary = req.get("summary") or {}
|
||||
|
||||
actor = resolve_actor(e)
|
||||
ad = actor_details[actor]
|
||||
ad["count"] += 1
|
||||
ad["actions"][bp.get("Action", "?")] += 1
|
||||
ad["types"][bp.get("Type", "?")] += 1
|
||||
ad["statuses"][resp.get("status", "?")] += 1
|
||||
raw_path = req.get("path", "?").split("?")[0]
|
||||
ad["paths"][raw_path] += 1
|
||||
|
||||
# Track which MemberIds triggered callbacks for this actor
|
||||
mid = bp.get("MemberId")
|
||||
if mid:
|
||||
ad["member_ids"][mid] += 1
|
||||
|
||||
ts_str = e.get("timestamp")
|
||||
if ts_str:
|
||||
try:
|
||||
ad["timestamps"].append(datetime.fromisoformat(ts_str.replace("Z", "+00:00")))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if isinstance(entity, dict):
|
||||
cn = entity.get("CompanyName")
|
||||
if cn:
|
||||
ad["companies"][cn] += 1
|
||||
eid = entity.get("Id")
|
||||
if eid is not None:
|
||||
ad["entity_ids"].add(eid)
|
||||
stage = entity.get("StageName")
|
||||
if stage:
|
||||
ad["stages"][stage] += 1
|
||||
rating = entity.get("Rating")
|
||||
if rating:
|
||||
ad["ratings"][rating] += 1
|
||||
et = entity.get("EstimatedTotal")
|
||||
if et is not None:
|
||||
ad["total_estimated"] += float(et)
|
||||
pr = entity.get("PrimarySalesRep")
|
||||
if pr:
|
||||
ad["sales_reps"][pr] += 1
|
||||
|
||||
# Compute per-actor derived stats
|
||||
for aid, ad in actor_details.items():
|
||||
ad["timestamps"].sort()
|
||||
if len(ad["timestamps"]) >= 2:
|
||||
dur = (ad["timestamps"][-1] - ad["timestamps"][0]).total_seconds()
|
||||
ad["duration_seconds"] = dur
|
||||
ad["events_per_minute"] = ad["count"] / (dur / 60) if dur > 0 else ad["count"]
|
||||
else:
|
||||
ad["duration_seconds"] = 0
|
||||
ad["events_per_minute"] = ad["count"]
|
||||
ad["first_ts"] = ad["timestamps"][0] if ad["timestamps"] else None
|
||||
ad["last_ts"] = ad["timestamps"][-1] if ad["timestamps"] else None
|
||||
|
||||
stats["actor_details"] = dict(actor_details)
|
||||
stats["methods"] = methods
|
||||
stats["paths"] = paths
|
||||
stats["statuses"] = statuses
|
||||
stats["actions"] = actions
|
||||
stats["types"] = types
|
||||
stats["actors"] = actors
|
||||
stats["companies"] = companies
|
||||
stats["entity_ids"] = entity_ids
|
||||
stats["stages"] = stages
|
||||
stats["ratings"] = ratings
|
||||
stats["hourly_buckets"] = hourly_buckets
|
||||
stats["minute_buckets"] = minute_buckets
|
||||
|
||||
if stats["duration_seconds"] > 0:
|
||||
stats["events_per_minute"] = len(entries) / (stats["duration_seconds"] / 60)
|
||||
else:
|
||||
stats["events_per_minute"] = len(entries)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# ─── PDF building ─────────────────────────────────────────────────────────────
|
||||
|
||||
def build_styles():
|
||||
ss = getSampleStyleSheet()
|
||||
ss.add(ParagraphStyle(
|
||||
"ReportTitle", parent=ss["Title"], fontSize=24, textColor=WHITE,
|
||||
spaceAfter=4, fontName="Helvetica-Bold", alignment=TA_CENTER,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"ReportSubtitle", parent=ss["Normal"], fontSize=11, textColor=GRAY_400,
|
||||
spaceAfter=2, fontName="Helvetica", alignment=TA_CENTER,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"SectionHeader", parent=ss["Heading1"], fontSize=16, textColor=GRAY_900,
|
||||
spaceBefore=14, spaceAfter=6, fontName="Helvetica-Bold",
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"SubHeader", parent=ss["Heading2"], fontSize=12, textColor=GRAY_800,
|
||||
spaceBefore=10, spaceAfter=4, fontName="Helvetica-Bold",
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"BodyText2", parent=ss["Normal"], fontSize=9, textColor=GRAY_800,
|
||||
spaceAfter=3, fontName="Helvetica", leading=13,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"SmallGray", parent=ss["Normal"], fontSize=8, textColor=GRAY_600,
|
||||
spaceAfter=2, fontName="Helvetica",
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"KPIValue", parent=ss["Normal"], fontSize=20, textColor=GRAY_900,
|
||||
fontName="Helvetica-Bold", alignment=TA_CENTER, leading=24,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"KPILabel", parent=ss["Normal"], fontSize=8, textColor=GRAY_600,
|
||||
fontName="Helvetica", alignment=TA_CENTER, spaceAfter=2,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"BannerText", parent=ss["Normal"], fontSize=9, textColor=WHITE,
|
||||
fontName="Helvetica-Bold", spaceAfter=1,
|
||||
))
|
||||
return ss
|
||||
|
||||
|
||||
def make_header_banner(ss, log_path, stats):
|
||||
elements = []
|
||||
fname = os.path.basename(log_path)
|
||||
banner_data = [[
|
||||
Paragraph("Webhook Log Report", ss["ReportTitle"]),
|
||||
], [
|
||||
Paragraph(fname, ss["ReportSubtitle"]),
|
||||
], [
|
||||
Paragraph(
|
||||
f"Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
|
||||
ss["ReportSubtitle"],
|
||||
),
|
||||
]]
|
||||
banner = Table(banner_data, colWidths=[7.0 * inch])
|
||||
banner.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), HEADER_BG),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("TOPPADDING", (0, 0), (0, 0), 20),
|
||||
("BOTTOMPADDING", (0, -1), (-1, -1), 16),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 20),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 20),
|
||||
]))
|
||||
elements.append(banner)
|
||||
elements.append(Spacer(1, 14))
|
||||
return elements
|
||||
|
||||
|
||||
def make_kpi_card(label, value):
|
||||
ss = build_styles()
|
||||
card_data = [[
|
||||
Paragraph(str(value), ss["KPIValue"]),
|
||||
], [
|
||||
Paragraph(label, ss["KPILabel"]),
|
||||
]]
|
||||
card = Table(card_data, colWidths=[1.6 * inch], rowHeights=[28, 16])
|
||||
card.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), GRAY_100),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOX", (0, 0), (-1, -1), 0.5, GRAY_200),
|
||||
]))
|
||||
return card
|
||||
|
||||
|
||||
def make_kpi_row(cards_data):
|
||||
cards = [make_kpi_card(label, value) for label, value in cards_data]
|
||||
row = Table([cards], colWidths=[1.75 * inch] * len(cards))
|
||||
row.setStyle(TableStyle([
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
]))
|
||||
return row
|
||||
|
||||
|
||||
def make_table(title, counter, ss, max_rows=15):
|
||||
"""Generic table from a Counter — light styling for print."""
|
||||
elements = []
|
||||
elements.append(Paragraph(title, ss["SubHeader"]))
|
||||
|
||||
items = counter.most_common(max_rows)
|
||||
if not items:
|
||||
elements.append(Paragraph("<i>No data</i>", ss["BodyText2"]))
|
||||
return elements
|
||||
|
||||
total = sum(counter.values())
|
||||
header = ["Item", "Count", "%"]
|
||||
rows = [header]
|
||||
for name, count in items:
|
||||
pct = (count / total * 100) if total else 0
|
||||
rows.append([truncate(str(name), 45), f"{count:,}", f"{pct:.1f}%"])
|
||||
|
||||
t = Table(rows, colWidths=[3.6 * inch, 1.0 * inch, 0.8 * inch])
|
||||
t.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), GRAY_800),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 9),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 9),
|
||||
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
|
||||
("BOTTOMPADDING", (0, 0), (-1, 0), 6),
|
||||
("TOPPADDING", (0, 0), (-1, 0), 6),
|
||||
("GRID", (0, 0), (-1, -1), 0.4, GRAY_200),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, GRAY_50]),
|
||||
("ALIGN", (1, 0), (2, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
||||
("TOPPADDING", (0, 1), (-1, -1), 3),
|
||||
("BOTTOMPADDING", (0, 1), (-1, -1), 3),
|
||||
]))
|
||||
elements.append(t)
|
||||
return elements
|
||||
|
||||
|
||||
def make_pie_chart(title, counter, width=280, height=190):
|
||||
items = counter.most_common(8)
|
||||
if not items:
|
||||
return Spacer(1, 1)
|
||||
|
||||
d = Drawing(width, height)
|
||||
d.add(String(width / 2, height - 12, title,
|
||||
fontSize=10, fontName="Helvetica-Bold",
|
||||
fillColor=GRAY_900, textAnchor="middle"))
|
||||
|
||||
pie = Pie()
|
||||
pie.x = 50
|
||||
pie.y = 10
|
||||
pie.width = 110
|
||||
pie.height = 110
|
||||
pie.data = [v for _, v in items]
|
||||
pie.labels = [truncate(str(k), 18) for k, _ in items]
|
||||
pie.sideLabels = True
|
||||
pie.slices.strokeWidth = 0.5
|
||||
pie.slices.strokeColor = WHITE
|
||||
|
||||
for i in range(len(items)):
|
||||
pie.slices[i].fillColor = PIE_COLORS[i % len(PIE_COLORS)]
|
||||
pie.slices[i].fontName = "Helvetica"
|
||||
pie.slices[i].fontSize = 7
|
||||
pie.slices[i].labelRadius = 1.35
|
||||
|
||||
d.add(pie)
|
||||
return d
|
||||
|
||||
|
||||
def make_timeline_chart(minute_buckets, width=500, height=150):
|
||||
if not minute_buckets:
|
||||
return Spacer(1, 1)
|
||||
|
||||
sorted_keys = sorted(minute_buckets.keys())
|
||||
if len(sorted_keys) > 40:
|
||||
step = max(1, len(sorted_keys) // 40)
|
||||
sampled_keys = sorted_keys[::step]
|
||||
else:
|
||||
sampled_keys = sorted_keys
|
||||
|
||||
values = [minute_buckets[k] for k in sampled_keys]
|
||||
|
||||
d = Drawing(width, height)
|
||||
d.add(String(width / 2, height - 10, "Event Timeline (by minute)",
|
||||
fontSize=10, fontName="Helvetica-Bold",
|
||||
fillColor=GRAY_900, textAnchor="middle"))
|
||||
|
||||
chart = VerticalBarChart()
|
||||
chart.x = 50
|
||||
chart.y = 25
|
||||
chart.width = width - 80
|
||||
chart.height = height - 50
|
||||
chart.data = [values]
|
||||
chart.categoryAxis.categoryNames = sampled_keys
|
||||
chart.categoryAxis.labels.angle = 45
|
||||
chart.categoryAxis.labels.fontSize = 6
|
||||
chart.categoryAxis.labels.fontName = "Helvetica"
|
||||
chart.categoryAxis.labels.dy = -5
|
||||
chart.valueAxis.labels.fontSize = 7
|
||||
chart.valueAxis.labels.fontName = "Helvetica"
|
||||
chart.valueAxis.valueMin = 0
|
||||
chart.bars[0].fillColor = GRAY_600
|
||||
chart.bars[0].strokeColor = None
|
||||
chart.barWidth = max(2, int((width - 100) / len(values) * 0.7))
|
||||
|
||||
d.add(chart)
|
||||
return d
|
||||
|
||||
|
||||
def build_actor_activity_section(stats, ss):
|
||||
"""Per-actor deep-dive. Actor = entityUpdatedBy ?? query.memberId ?? summary.memberId."""
|
||||
elements = []
|
||||
elements.append(PageBreak())
|
||||
elements.append(Paragraph("Actor Activity Deep Dive", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=4))
|
||||
elements.append(Paragraph(
|
||||
'The <b>actor</b> is resolved as: <i>entityUpdatedBy → query.memberId → summary.memberId</i>. '
|
||||
'This is the person or system that caused the change in ConnectWise.',
|
||||
ss["SmallGray"],
|
||||
))
|
||||
elements.append(Spacer(1, 10))
|
||||
|
||||
actor_details = stats.get("actor_details", {})
|
||||
if not actor_details:
|
||||
elements.append(Paragraph("<i>No actor data available.</i>", ss["BodyText2"]))
|
||||
return elements
|
||||
|
||||
sorted_actors = sorted(actor_details.items(), key=lambda x: -x[1]["count"])
|
||||
|
||||
# Actor distribution pie chart
|
||||
actor_counter = Counter({aid: ad["count"] for aid, ad in sorted_actors})
|
||||
elements.append(make_pie_chart("Events by Actor", actor_counter, width=350, height=210))
|
||||
elements.append(Spacer(1, 10))
|
||||
|
||||
for idx, (aid, ad) in enumerate(sorted_actors):
|
||||
# Actor header — slim dark bar
|
||||
banner_data = [[
|
||||
Paragraph(
|
||||
f'<font size="12"><b>{aid}</b></font>'
|
||||
f' '
|
||||
f'<font size="9" color="#cccccc">{ad["count"]:,} events</font>',
|
||||
ss["BannerText"],
|
||||
),
|
||||
]]
|
||||
banner = Table(banner_data, colWidths=[7.0 * inch])
|
||||
banner.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), HEADER_BG),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 7),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 7),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 12),
|
||||
]))
|
||||
elements.append(banner)
|
||||
|
||||
# KPI row
|
||||
kpi = make_kpi_row([
|
||||
("Events", f"{ad['count']:,}"),
|
||||
("Entities", f"{len(ad['entity_ids']):,}"),
|
||||
("Companies", f"{len(ad['companies']):,}"),
|
||||
("Evts/Min", f"{ad['events_per_minute']:.1f}"),
|
||||
])
|
||||
elements.append(kpi)
|
||||
elements.append(Spacer(1, 4))
|
||||
|
||||
# Info grid
|
||||
first_str = ad["first_ts"].strftime("%Y-%m-%d %H:%M:%S") if ad["first_ts"] else "—"
|
||||
last_str = ad["last_ts"].strftime("%Y-%m-%d %H:%M:%S") if ad["last_ts"] else "—"
|
||||
dur = duration_str(ad["duration_seconds"])
|
||||
est = ad["total_estimated"]
|
||||
|
||||
mid_str = ", ".join(f"{k} ({v})" for k, v in ad["member_ids"].most_common(5)) if ad["member_ids"] else "—"
|
||||
sr_str = ", ".join(f"{k} ({v})" for k, v in ad["sales_reps"].most_common(5)) if ad["sales_reps"] else "—"
|
||||
|
||||
info_rows = [
|
||||
[
|
||||
Paragraph('<font color="#757575">First Event</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{first_str}</b>', ss["BodyText2"]),
|
||||
Paragraph('<font color="#757575">Last Event</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{last_str}</b>', ss["BodyText2"]),
|
||||
],
|
||||
[
|
||||
Paragraph('<font color="#757575">Active Duration</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{dur}</b>', ss["BodyText2"]),
|
||||
Paragraph('<font color="#757575">Total Est. Value</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>${est:,.2f}</b>', ss["BodyText2"]),
|
||||
],
|
||||
[
|
||||
Paragraph('<font color="#757575">Callback Members</font>', ss["BodyText2"]),
|
||||
Paragraph(f'{truncate(mid_str, 40)}', ss["BodyText2"]),
|
||||
Paragraph('<font color="#757575">Sales Reps</font>', ss["BodyText2"]),
|
||||
Paragraph(f'{truncate(sr_str, 40)}', ss["BodyText2"]),
|
||||
],
|
||||
]
|
||||
info_table = Table(info_rows, colWidths=[1.3 * inch, 2.1 * inch, 1.3 * inch, 2.1 * inch])
|
||||
info_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), GRAY_50),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("GRID", (0, 0), (-1, -1), 0.3, GRAY_200),
|
||||
]))
|
||||
elements.append(info_table)
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
# Breakdown tables
|
||||
if ad["types"]:
|
||||
elements.extend(make_table(f"{aid} — Entity Types", ad["types"], ss, max_rows=8))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
if ad["companies"]:
|
||||
elements.extend(make_table(f"{aid} — Companies", ad["companies"], ss, max_rows=10))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
if ad["stages"]:
|
||||
elements.extend(make_table(f"{aid} — Stages", ad["stages"], ss, max_rows=8))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
if ad["ratings"]:
|
||||
elements.extend(make_table(f"{aid} — Ratings", ad["ratings"], ss, max_rows=8))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
# Entity IDs
|
||||
if ad["entity_ids"]:
|
||||
id_list = sorted(ad["entity_ids"])
|
||||
id_str = ", ".join(str(i) for i in id_list[:30])
|
||||
if len(id_list) > 30:
|
||||
id_str += f" ... (+{len(id_list) - 30} more)"
|
||||
elements.append(Paragraph(f"{aid} — Entity IDs Touched", ss["SubHeader"]))
|
||||
elements.append(Paragraph(f'<font size="8">{id_str}</font>', ss["BodyText2"]))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
# Divider
|
||||
if idx < len(sorted_actors) - 1:
|
||||
elements.append(Spacer(1, 4))
|
||||
elements.append(HRFlowable(width="100%", thickness=0.5, color=GRAY_200, spaceAfter=4))
|
||||
elements.append(Spacer(1, 4))
|
||||
|
||||
return elements
|
||||
|
||||
|
||||
def build_summary_log_table(entries, ss, max_rows=30):
|
||||
elements = []
|
||||
elements.append(PageBreak())
|
||||
elements.append(Paragraph("Event Summary", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=6))
|
||||
elements.append(Paragraph(
|
||||
f"Aggregated view of {len(entries):,} webhook events — grouped by entity.",
|
||||
ss["SmallGray"],
|
||||
))
|
||||
elements.append(Spacer(1, 8))
|
||||
|
||||
entity_groups = defaultdict(lambda: {
|
||||
"count": 0, "name": "—", "company": "—",
|
||||
"actions": Counter(), "actors": set(),
|
||||
"est_total": None,
|
||||
})
|
||||
|
||||
for e in entries:
|
||||
req = e.get("request", {})
|
||||
bp = req.get("bodyParsed") or {}
|
||||
entity = req.get("entityParsed") or bp.get("Entity") or {}
|
||||
if not isinstance(entity, dict):
|
||||
continue
|
||||
eid = entity.get("Id")
|
||||
if eid is None:
|
||||
continue
|
||||
|
||||
eg = entity_groups[eid]
|
||||
eg["count"] += 1
|
||||
eg["name"] = entity.get("OpportunityName") or entity.get("CompanyName") or eg["name"]
|
||||
eg["company"] = entity.get("CompanyName") or eg["company"]
|
||||
eg["actions"][bp.get("Action", "?")] += 1
|
||||
actor = resolve_actor(e)
|
||||
eg["actors"].add(actor)
|
||||
et = entity.get("EstimatedTotal")
|
||||
if et is not None:
|
||||
eg["est_total"] = et
|
||||
|
||||
sorted_entities = sorted(entity_groups.items(), key=lambda x: -x[1]["count"])
|
||||
|
||||
header = ["ID", "Entity Name", "Company", "Events", "Actions", "Actors", "Est. Total"]
|
||||
rows = [header]
|
||||
|
||||
for eid, eg in sorted_entities[:max_rows]:
|
||||
actions_str = ", ".join(f"{a}({c})" for a, c in eg["actions"].most_common(3))
|
||||
actors_str = ", ".join(sorted(eg["actors"]))
|
||||
total_str = f"${eg['est_total']:,.2f}" if eg["est_total"] is not None else "—"
|
||||
rows.append([
|
||||
str(eid),
|
||||
truncate(eg["name"], 28),
|
||||
truncate(eg["company"], 18),
|
||||
f"{eg['count']:,}",
|
||||
truncate(actions_str, 24),
|
||||
truncate(actors_str, 18),
|
||||
total_str,
|
||||
])
|
||||
|
||||
t = Table(rows, colWidths=[0.45 * inch, 1.7 * inch, 1.1 * inch, 0.55 * inch, 1.2 * inch, 1.0 * inch, 0.8 * inch])
|
||||
t.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), GRAY_800),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 8),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 8),
|
||||
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
|
||||
("GRID", (0, 0), (-1, -1), 0.3, GRAY_200),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, GRAY_50]),
|
||||
("ALIGN", (0, 0), (0, -1), "CENTER"),
|
||||
("ALIGN", (3, 0), (3, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
||||
("TOPPADDING", (0, 1), (-1, -1), 2),
|
||||
("BOTTOMPADDING", (0, 1), (-1, -1), 2),
|
||||
]))
|
||||
elements.append(t)
|
||||
|
||||
if len(sorted_entities) > max_rows:
|
||||
elements.append(Spacer(1, 4))
|
||||
elements.append(Paragraph(
|
||||
f'Showing top {max_rows} of {len(sorted_entities)} entities.',
|
||||
ss["SmallGray"],
|
||||
))
|
||||
|
||||
return elements
|
||||
|
||||
|
||||
def add_page_number(canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFillColor(GRAY_800)
|
||||
canvas.rect(0, 0, LETTER[0], 22, fill=1, stroke=0)
|
||||
canvas.setFillColor(WHITE)
|
||||
canvas.setFont("Helvetica", 7)
|
||||
canvas.drawCentredString(LETTER[0] / 2, 8, f"Page {doc.page}")
|
||||
canvas.setFillColor(GRAY_400)
|
||||
canvas.setFont("Helvetica", 7)
|
||||
canvas.drawString(30, 8, "Optima API · Webhook Log Report")
|
||||
canvas.drawRightString(LETTER[0] - 30, 8, datetime.now(timezone.utc).strftime("%Y-%m-%d"))
|
||||
canvas.restoreState()
|
||||
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
log_dir = os.path.join(script_dir, "..", "cw-api-logs")
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
log_path = sys.argv[1]
|
||||
else:
|
||||
log_path = find_latest_log(log_dir)
|
||||
|
||||
print(f"📄 Reading log: {log_path}")
|
||||
entries = parse_log(log_path)
|
||||
print(f" → {len(entries)} entries parsed")
|
||||
|
||||
stats = analyze(entries)
|
||||
ss = build_styles()
|
||||
|
||||
log_basename = os.path.splitext(os.path.basename(log_path))[0]
|
||||
out_path = os.path.join(script_dir, "..", "cw-api-logs", f"{log_basename}-report.pdf")
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
out_path,
|
||||
pagesize=LETTER,
|
||||
leftMargin=0.6 * inch,
|
||||
rightMargin=0.6 * inch,
|
||||
topMargin=0.5 * inch,
|
||||
bottomMargin=0.5 * inch,
|
||||
)
|
||||
|
||||
elements = []
|
||||
|
||||
# ── Title Banner ──
|
||||
elements.extend(make_header_banner(ss, log_path, stats))
|
||||
|
||||
# ── Overview ──
|
||||
elements.append(Paragraph("Overview", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
|
||||
|
||||
elements.append(make_kpi_row([
|
||||
("Total Events", f"{stats['total_entries']:,}"),
|
||||
("Unique Entities", f"{len(stats['entity_ids']):,}"),
|
||||
("Companies", f"{len(stats['companies']):,}"),
|
||||
("Duration", duration_str(stats["duration_seconds"])),
|
||||
]))
|
||||
elements.append(Spacer(1, 8))
|
||||
elements.append(make_kpi_row([
|
||||
("Events / Min", f"{stats['events_per_minute']:.1f}"),
|
||||
("HTTP Methods", f"{len(stats['methods']):,}"),
|
||||
("Action Types", f"{len(stats['actions']):,}"),
|
||||
("Actors", f"{len(stats['actors']):,}"),
|
||||
]))
|
||||
elements.append(Spacer(1, 12))
|
||||
|
||||
# Time range
|
||||
if stats["first_ts"] and stats["last_ts"]:
|
||||
info = [
|
||||
[
|
||||
Paragraph('<font color="#757575">First Event</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{stats["first_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}</b>', ss["BodyText2"]),
|
||||
Paragraph('<font color="#757575">Last Event</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{stats["last_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}</b>', ss["BodyText2"]),
|
||||
]
|
||||
]
|
||||
ti = Table(info, colWidths=[1.2 * inch, 2.4 * inch, 1.2 * inch, 2.4 * inch])
|
||||
ti.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), GRAY_50),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("BOX", (0, 0), (-1, -1), 0.4, GRAY_200),
|
||||
]))
|
||||
elements.append(ti)
|
||||
elements.append(Spacer(1, 14))
|
||||
|
||||
# ── Charts ──
|
||||
elements.append(Paragraph("Visual Breakdown", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
|
||||
|
||||
elements.append(make_timeline_chart(stats["minute_buckets"]))
|
||||
elements.append(Spacer(1, 12))
|
||||
|
||||
pie_row = [[
|
||||
make_pie_chart("By Action", stats["actions"]),
|
||||
make_pie_chart("By Type", stats["types"]),
|
||||
]]
|
||||
pt = Table(pie_row, colWidths=[3.5 * inch, 3.5 * inch])
|
||||
pt.setStyle(TableStyle([
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
]))
|
||||
elements.append(pt)
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
if len(stats["stages"]) > 1 or len(stats["ratings"]) > 1:
|
||||
pie_row2 = [[
|
||||
make_pie_chart("By Stage", stats["stages"]),
|
||||
make_pie_chart("By Rating", stats["ratings"]),
|
||||
]]
|
||||
pt2 = Table(pie_row2, colWidths=[3.5 * inch, 3.5 * inch])
|
||||
pt2.setStyle(TableStyle([
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
]))
|
||||
elements.append(pt2)
|
||||
|
||||
# Actor pie chart
|
||||
elements.append(Spacer(1, 6))
|
||||
elements.append(make_pie_chart("By Actor", stats["actors"], width=350, height=210))
|
||||
|
||||
# ── General Information ──
|
||||
elements.append(PageBreak())
|
||||
elements.append(Paragraph("General Information", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
|
||||
|
||||
elements.extend(make_table("Response Status Codes", stats["statuses"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("HTTP Methods", stats["methods"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("Webhook Actions", stats["actions"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("Entity Types", stats["types"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("Request Paths", stats["paths"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("Actors", stats["actors"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
|
||||
if stats["companies"]:
|
||||
elements.extend(make_table("Companies", stats["companies"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
if stats["stages"]:
|
||||
elements.extend(make_table("Opportunity Stages", stats["stages"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
if stats["ratings"]:
|
||||
elements.extend(make_table("Opportunity Ratings", stats["ratings"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
|
||||
elements.extend(make_table("Hourly Distribution", stats["hourly_buckets"], ss))
|
||||
|
||||
# ── Actor Deep Dive ──
|
||||
elements.extend(build_actor_activity_section(stats, ss))
|
||||
|
||||
# ── Entity Summary ──
|
||||
elements.extend(build_summary_log_table(entries, ss, max_rows=30))
|
||||
|
||||
# Build
|
||||
doc.build(elements, onFirstPage=add_page_number, onLaterPages=add_page_number)
|
||||
print(f"✅ Report generated: {out_path}")
|
||||
print(f" File size: {os.path.getsize(out_path) / 1024:.1f} KB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -67,3 +67,8 @@ export type SecureValue = Prisma.SecureValueModel
|
||||
*
|
||||
*/
|
||||
export type Credential = Prisma.CredentialModel
|
||||
/**
|
||||
* Model GeneratedQuotes
|
||||
*
|
||||
*/
|
||||
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
|
||||
|
||||
@@ -89,3 +89,8 @@ export type SecureValue = Prisma.SecureValueModel
|
||||
*
|
||||
*/
|
||||
export type Credential = Prisma.CredentialModel
|
||||
/**
|
||||
* Model GeneratedQuotes
|
||||
*
|
||||
*/
|
||||
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
|
||||
|
||||
@@ -280,6 +280,23 @@ export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||
_max?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type BytesFilter<$PrismaModel = never> = {
|
||||
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
|
||||
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBytesFilter<$PrismaModel> | runtime.Bytes
|
||||
}
|
||||
|
||||
export type BytesWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
|
||||
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBytesWithAggregatesFilter<$PrismaModel> | runtime.Bytes
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBytesFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBytesFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedStringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
@@ -521,4 +538,21 @@ export type NestedJsonFilterBase<$PrismaModel = never> = {
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type NestedBytesFilter<$PrismaModel = never> = {
|
||||
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
|
||||
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBytesFilter<$PrismaModel> | runtime.Bytes
|
||||
}
|
||||
|
||||
export type NestedBytesWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
|
||||
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
notIn?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedBytesWithAggregatesFilter<$PrismaModel> | runtime.Bytes
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedBytesFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedBytesFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -393,7 +393,8 @@ export const ModelName = {
|
||||
Opportunity: 'Opportunity',
|
||||
CredentialType: 'CredentialType',
|
||||
SecureValue: 'SecureValue',
|
||||
Credential: 'Credential'
|
||||
Credential: 'Credential',
|
||||
GeneratedQuotes: 'GeneratedQuotes'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -409,7 +410,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential"
|
||||
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential" | "generatedQuotes"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
model: {
|
||||
@@ -1153,6 +1154,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
}
|
||||
}
|
||||
}
|
||||
GeneratedQuotes: {
|
||||
payload: Prisma.$GeneratedQuotesPayload<ExtArgs>
|
||||
fields: Prisma.GeneratedQuotesFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.GeneratedQuotesFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.GeneratedQuotesFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.GeneratedQuotesFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.GeneratedQuotesFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.GeneratedQuotesFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.GeneratedQuotesCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.GeneratedQuotesCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
createManyAndReturn: {
|
||||
args: Prisma.GeneratedQuotesCreateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>[]
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.GeneratedQuotesDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.GeneratedQuotesUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.GeneratedQuotesDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.GeneratedQuotesUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateManyAndReturn: {
|
||||
args: Prisma.GeneratedQuotesUpdateManyAndReturnArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>[]
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.GeneratedQuotesUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$GeneratedQuotesPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.GeneratedQuotesAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateGeneratedQuotes>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.GeneratedQuotesGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.GeneratedQuotesGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.GeneratedQuotesCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.GeneratedQuotesCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} & {
|
||||
other: {
|
||||
@@ -1213,6 +1288,7 @@ export const UserScalarFieldEnum = {
|
||||
email: 'email',
|
||||
emailVerified: 'emailVerified',
|
||||
image: 'image',
|
||||
cwIdentifier: 'cwIdentifier',
|
||||
userId: 'userId',
|
||||
token: 'token',
|
||||
createdAt: 'createdAt',
|
||||
@@ -1266,6 +1342,10 @@ export const CatalogItemScalarFieldEnum = {
|
||||
description: 'description',
|
||||
customerDescription: 'customerDescription',
|
||||
internalNotes: 'internalNotes',
|
||||
category: 'category',
|
||||
categoryCwId: 'categoryCwId',
|
||||
subcategory: 'subcategory',
|
||||
subcategoryCwId: 'subcategoryCwId',
|
||||
manufacturer: 'manufacturer',
|
||||
manufactureCwId: 'manufactureCwId',
|
||||
partNumber: 'partNumber',
|
||||
@@ -1317,6 +1397,7 @@ export const OpportunityScalarFieldEnum = {
|
||||
siteName: 'siteName',
|
||||
customerPO: 'customerPO',
|
||||
totalSalesTax: 'totalSalesTax',
|
||||
probability: 'probability',
|
||||
locationName: 'locationName',
|
||||
locationCwId: 'locationCwId',
|
||||
departmentName: 'departmentName',
|
||||
@@ -1329,6 +1410,7 @@ export const OpportunityScalarFieldEnum = {
|
||||
closedByName: 'closedByName',
|
||||
closedByCwId: 'closedByCwId',
|
||||
companyId: 'companyId',
|
||||
productSequence: 'productSequence',
|
||||
cwLastUpdated: 'cwLastUpdated',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
@@ -1378,6 +1460,23 @@ export const CredentialScalarFieldEnum = {
|
||||
export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof CredentialScalarFieldEnum]
|
||||
|
||||
|
||||
export const GeneratedQuotesScalarFieldEnum = {
|
||||
id: 'id',
|
||||
quoteRegenData: 'quoteRegenData',
|
||||
quoteRegenParams: 'quoteRegenParams',
|
||||
quoteRegenHash: 'quoteRegenHash',
|
||||
downloads: 'downloads',
|
||||
quoteFile: 'quoteFile',
|
||||
quoteFileName: 'quoteFileName',
|
||||
opportunityId: 'opportunityId',
|
||||
createdById: 'createdById',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -1500,6 +1599,20 @@ export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'J
|
||||
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Bytes'
|
||||
*/
|
||||
export type BytesFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Bytes'>
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Reference to a field of type 'Bytes[]'
|
||||
*/
|
||||
export type ListBytesFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Bytes[]'>
|
||||
|
||||
|
||||
/**
|
||||
* Batch Payload for updateMany & deleteMany & createMany
|
||||
*/
|
||||
@@ -1605,6 +1718,7 @@ export type GlobalOmitConfig = {
|
||||
credentialType?: Prisma.CredentialTypeOmit
|
||||
secureValue?: Prisma.SecureValueOmit
|
||||
credential?: Prisma.CredentialOmit
|
||||
generatedQuotes?: Prisma.GeneratedQuotesOmit
|
||||
}
|
||||
|
||||
/* Types for Logging */
|
||||
|
||||
@@ -60,7 +60,8 @@ export const ModelName = {
|
||||
Opportunity: 'Opportunity',
|
||||
CredentialType: 'CredentialType',
|
||||
SecureValue: 'SecureValue',
|
||||
Credential: 'Credential'
|
||||
Credential: 'Credential',
|
||||
GeneratedQuotes: 'GeneratedQuotes'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -100,6 +101,7 @@ export const UserScalarFieldEnum = {
|
||||
email: 'email',
|
||||
emailVerified: 'emailVerified',
|
||||
image: 'image',
|
||||
cwIdentifier: 'cwIdentifier',
|
||||
userId: 'userId',
|
||||
token: 'token',
|
||||
createdAt: 'createdAt',
|
||||
@@ -153,6 +155,10 @@ export const CatalogItemScalarFieldEnum = {
|
||||
description: 'description',
|
||||
customerDescription: 'customerDescription',
|
||||
internalNotes: 'internalNotes',
|
||||
category: 'category',
|
||||
categoryCwId: 'categoryCwId',
|
||||
subcategory: 'subcategory',
|
||||
subcategoryCwId: 'subcategoryCwId',
|
||||
manufacturer: 'manufacturer',
|
||||
manufactureCwId: 'manufactureCwId',
|
||||
partNumber: 'partNumber',
|
||||
@@ -204,6 +210,7 @@ export const OpportunityScalarFieldEnum = {
|
||||
siteName: 'siteName',
|
||||
customerPO: 'customerPO',
|
||||
totalSalesTax: 'totalSalesTax',
|
||||
probability: 'probability',
|
||||
locationName: 'locationName',
|
||||
locationCwId: 'locationCwId',
|
||||
departmentName: 'departmentName',
|
||||
@@ -216,6 +223,7 @@ export const OpportunityScalarFieldEnum = {
|
||||
closedByName: 'closedByName',
|
||||
closedByCwId: 'closedByCwId',
|
||||
companyId: 'companyId',
|
||||
productSequence: 'productSequence',
|
||||
cwLastUpdated: 'cwLastUpdated',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
@@ -265,6 +273,23 @@ export const CredentialScalarFieldEnum = {
|
||||
export type CredentialScalarFieldEnum = (typeof CredentialScalarFieldEnum)[keyof typeof CredentialScalarFieldEnum]
|
||||
|
||||
|
||||
export const GeneratedQuotesScalarFieldEnum = {
|
||||
id: 'id',
|
||||
quoteRegenData: 'quoteRegenData',
|
||||
quoteRegenParams: 'quoteRegenParams',
|
||||
quoteRegenHash: 'quoteRegenHash',
|
||||
downloads: 'downloads',
|
||||
quoteFile: 'quoteFile',
|
||||
quoteFileName: 'quoteFileName',
|
||||
opportunityId: 'opportunityId',
|
||||
createdById: 'createdById',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
|
||||
@@ -18,4 +18,5 @@ export type * from './models/Opportunity.ts'
|
||||
export type * from './models/CredentialType.ts'
|
||||
export type * from './models/SecureValue.ts'
|
||||
export type * from './models/Credential.ts'
|
||||
export type * from './models/GeneratedQuotes.ts'
|
||||
export type * from './commonInputTypes.ts'
|
||||
@@ -28,6 +28,8 @@ export type AggregateCatalogItem = {
|
||||
|
||||
export type CatalogItemAvgAggregateOutputType = {
|
||||
cwCatalogId: number | null
|
||||
categoryCwId: number | null
|
||||
subcategoryCwId: number | null
|
||||
manufactureCwId: number | null
|
||||
vendorCwId: number | null
|
||||
price: number | null
|
||||
@@ -37,6 +39,8 @@ export type CatalogItemAvgAggregateOutputType = {
|
||||
|
||||
export type CatalogItemSumAggregateOutputType = {
|
||||
cwCatalogId: number | null
|
||||
categoryCwId: number | null
|
||||
subcategoryCwId: number | null
|
||||
manufactureCwId: number | null
|
||||
vendorCwId: number | null
|
||||
price: number | null
|
||||
@@ -52,6 +56,10 @@ export type CatalogItemMinAggregateOutputType = {
|
||||
description: string | null
|
||||
customerDescription: string | null
|
||||
internalNotes: string | null
|
||||
category: string | null
|
||||
categoryCwId: number | null
|
||||
subcategory: string | null
|
||||
subcategoryCwId: number | null
|
||||
manufacturer: string | null
|
||||
manufactureCwId: number | null
|
||||
partNumber: string | null
|
||||
@@ -76,6 +84,10 @@ export type CatalogItemMaxAggregateOutputType = {
|
||||
description: string | null
|
||||
customerDescription: string | null
|
||||
internalNotes: string | null
|
||||
category: string | null
|
||||
categoryCwId: number | null
|
||||
subcategory: string | null
|
||||
subcategoryCwId: number | null
|
||||
manufacturer: string | null
|
||||
manufactureCwId: number | null
|
||||
partNumber: string | null
|
||||
@@ -100,6 +112,10 @@ export type CatalogItemCountAggregateOutputType = {
|
||||
description: number
|
||||
customerDescription: number
|
||||
internalNotes: number
|
||||
category: number
|
||||
categoryCwId: number
|
||||
subcategory: number
|
||||
subcategoryCwId: number
|
||||
manufacturer: number
|
||||
manufactureCwId: number
|
||||
partNumber: number
|
||||
@@ -120,6 +136,8 @@ export type CatalogItemCountAggregateOutputType = {
|
||||
|
||||
export type CatalogItemAvgAggregateInputType = {
|
||||
cwCatalogId?: true
|
||||
categoryCwId?: true
|
||||
subcategoryCwId?: true
|
||||
manufactureCwId?: true
|
||||
vendorCwId?: true
|
||||
price?: true
|
||||
@@ -129,6 +147,8 @@ export type CatalogItemAvgAggregateInputType = {
|
||||
|
||||
export type CatalogItemSumAggregateInputType = {
|
||||
cwCatalogId?: true
|
||||
categoryCwId?: true
|
||||
subcategoryCwId?: true
|
||||
manufactureCwId?: true
|
||||
vendorCwId?: true
|
||||
price?: true
|
||||
@@ -144,6 +164,10 @@ export type CatalogItemMinAggregateInputType = {
|
||||
description?: true
|
||||
customerDescription?: true
|
||||
internalNotes?: true
|
||||
category?: true
|
||||
categoryCwId?: true
|
||||
subcategory?: true
|
||||
subcategoryCwId?: true
|
||||
manufacturer?: true
|
||||
manufactureCwId?: true
|
||||
partNumber?: true
|
||||
@@ -168,6 +192,10 @@ export type CatalogItemMaxAggregateInputType = {
|
||||
description?: true
|
||||
customerDescription?: true
|
||||
internalNotes?: true
|
||||
category?: true
|
||||
categoryCwId?: true
|
||||
subcategory?: true
|
||||
subcategoryCwId?: true
|
||||
manufacturer?: true
|
||||
manufactureCwId?: true
|
||||
partNumber?: true
|
||||
@@ -192,6 +220,10 @@ export type CatalogItemCountAggregateInputType = {
|
||||
description?: true
|
||||
customerDescription?: true
|
||||
internalNotes?: true
|
||||
category?: true
|
||||
categoryCwId?: true
|
||||
subcategory?: true
|
||||
subcategoryCwId?: true
|
||||
manufacturer?: true
|
||||
manufactureCwId?: true
|
||||
partNumber?: true
|
||||
@@ -303,6 +335,10 @@ export type CatalogItemGroupByOutputType = {
|
||||
description: string | null
|
||||
customerDescription: string | null
|
||||
internalNotes: string | null
|
||||
category: string | null
|
||||
categoryCwId: number | null
|
||||
subcategory: string | null
|
||||
subcategoryCwId: number | null
|
||||
manufacturer: string | null
|
||||
manufactureCwId: number | null
|
||||
partNumber: string | null
|
||||
@@ -350,6 +386,10 @@ export type CatalogItemWhereInput = {
|
||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
internalNotes?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
category?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
categoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
subcategory?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
subcategoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
manufacturer?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
@@ -376,6 +416,10 @@ export type CatalogItemOrderByWithRelationInput = {
|
||||
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
category?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
@@ -405,6 +449,10 @@ export type CatalogItemWhereUniqueInput = Prisma.AtLeast<{
|
||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
internalNotes?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
category?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
categoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
subcategory?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
subcategoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
manufacturer?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
@@ -431,6 +479,10 @@ export type CatalogItemOrderByWithAggregationInput = {
|
||||
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
category?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
@@ -463,6 +515,10 @@ export type CatalogItemScalarWhereWithAggregatesInput = {
|
||||
description?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
customerDescription?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
internalNotes?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
category?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
categoryCwId?: Prisma.IntNullableWithAggregatesFilter<"CatalogItem"> | number | null
|
||||
subcategory?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
subcategoryCwId?: Prisma.IntNullableWithAggregatesFilter<"CatalogItem"> | number | null
|
||||
manufacturer?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
manufactureCwId?: Prisma.IntNullableWithAggregatesFilter<"CatalogItem"> | number | null
|
||||
partNumber?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
@@ -487,6 +543,10 @@ export type CatalogItemCreateInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -513,6 +573,10 @@ export type CatalogItemUncheckedCreateInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -539,6 +603,10 @@ export type CatalogItemUpdateInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -565,6 +633,10 @@ export type CatalogItemUncheckedUpdateInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -591,6 +663,10 @@ export type CatalogItemCreateManyInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -615,6 +691,10 @@ export type CatalogItemUpdateManyMutationInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -639,6 +719,10 @@ export type CatalogItemUncheckedUpdateManyInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -673,6 +757,10 @@ export type CatalogItemCountOrderByAggregateInput = {
|
||||
description?: Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrder
|
||||
category?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrder
|
||||
@@ -691,6 +779,8 @@ export type CatalogItemCountOrderByAggregateInput = {
|
||||
|
||||
export type CatalogItemAvgOrderByAggregateInput = {
|
||||
cwCatalogId?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
vendorCwId?: Prisma.SortOrder
|
||||
price?: Prisma.SortOrder
|
||||
@@ -706,6 +796,10 @@ export type CatalogItemMaxOrderByAggregateInput = {
|
||||
description?: Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrder
|
||||
category?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrder
|
||||
@@ -730,6 +824,10 @@ export type CatalogItemMinOrderByAggregateInput = {
|
||||
description?: Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrder
|
||||
category?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrder
|
||||
@@ -748,6 +846,8 @@ export type CatalogItemMinOrderByAggregateInput = {
|
||||
|
||||
export type CatalogItemSumOrderByAggregateInput = {
|
||||
cwCatalogId?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
vendorCwId?: Prisma.SortOrder
|
||||
price?: Prisma.SortOrder
|
||||
@@ -855,6 +955,10 @@ export type CatalogItemCreateWithoutLinkedToInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -880,6 +984,10 @@ export type CatalogItemUncheckedCreateWithoutLinkedToInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -910,6 +1018,10 @@ export type CatalogItemCreateWithoutLinkedItemsInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -935,6 +1047,10 @@ export type CatalogItemUncheckedCreateWithoutLinkedItemsInput = {
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -984,6 +1100,10 @@ export type CatalogItemScalarWhereInput = {
|
||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
internalNotes?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
category?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
categoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
subcategory?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
subcategoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
manufacturer?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
@@ -1024,6 +1144,10 @@ export type CatalogItemUpdateWithoutLinkedToInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1049,6 +1173,10 @@ export type CatalogItemUncheckedUpdateWithoutLinkedToInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1074,6 +1202,10 @@ export type CatalogItemUncheckedUpdateManyWithoutLinkedToInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1098,6 +1230,10 @@ export type CatalogItemUpdateWithoutLinkedItemsInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1123,6 +1259,10 @@ export type CatalogItemUncheckedUpdateWithoutLinkedItemsInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1148,6 +1288,10 @@ export type CatalogItemUncheckedUpdateManyWithoutLinkedItemsInput = {
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1212,6 +1356,10 @@ export type CatalogItemSelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
||||
description?: boolean
|
||||
customerDescription?: boolean
|
||||
internalNotes?: boolean
|
||||
category?: boolean
|
||||
categoryCwId?: boolean
|
||||
subcategory?: boolean
|
||||
subcategoryCwId?: boolean
|
||||
manufacturer?: boolean
|
||||
manufactureCwId?: boolean
|
||||
partNumber?: boolean
|
||||
@@ -1239,6 +1387,10 @@ export type CatalogItemSelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
description?: boolean
|
||||
customerDescription?: boolean
|
||||
internalNotes?: boolean
|
||||
category?: boolean
|
||||
categoryCwId?: boolean
|
||||
subcategory?: boolean
|
||||
subcategoryCwId?: boolean
|
||||
manufacturer?: boolean
|
||||
manufactureCwId?: boolean
|
||||
partNumber?: boolean
|
||||
@@ -1263,6 +1415,10 @@ export type CatalogItemSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
description?: boolean
|
||||
customerDescription?: boolean
|
||||
internalNotes?: boolean
|
||||
category?: boolean
|
||||
categoryCwId?: boolean
|
||||
subcategory?: boolean
|
||||
subcategoryCwId?: boolean
|
||||
manufacturer?: boolean
|
||||
manufactureCwId?: boolean
|
||||
partNumber?: boolean
|
||||
@@ -1287,6 +1443,10 @@ export type CatalogItemSelectScalar = {
|
||||
description?: boolean
|
||||
customerDescription?: boolean
|
||||
internalNotes?: boolean
|
||||
category?: boolean
|
||||
categoryCwId?: boolean
|
||||
subcategory?: boolean
|
||||
subcategoryCwId?: boolean
|
||||
manufacturer?: boolean
|
||||
manufactureCwId?: boolean
|
||||
partNumber?: boolean
|
||||
@@ -1303,7 +1463,7 @@ export type CatalogItemSelectScalar = {
|
||||
updatedAt?: boolean
|
||||
}
|
||||
|
||||
export type CatalogItemOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwCatalogId" | "identifier" | "name" | "description" | "customerDescription" | "internalNotes" | "manufacturer" | "manufactureCwId" | "partNumber" | "vendorName" | "vendorSku" | "vendorCwId" | "price" | "cost" | "inactive" | "salesTaxable" | "onHand" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["catalogItem"]>
|
||||
export type CatalogItemOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwCatalogId" | "identifier" | "name" | "description" | "customerDescription" | "internalNotes" | "category" | "categoryCwId" | "subcategory" | "subcategoryCwId" | "manufacturer" | "manufactureCwId" | "partNumber" | "vendorName" | "vendorSku" | "vendorCwId" | "price" | "cost" | "inactive" | "salesTaxable" | "onHand" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["catalogItem"]>
|
||||
export type CatalogItemInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
linkedItems?: boolean | Prisma.CatalogItem$linkedItemsArgs<ExtArgs>
|
||||
linkedTo?: boolean | Prisma.CatalogItem$linkedToArgs<ExtArgs>
|
||||
@@ -1326,6 +1486,10 @@ export type $CatalogItemPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
||||
description: string | null
|
||||
customerDescription: string | null
|
||||
internalNotes: string | null
|
||||
category: string | null
|
||||
categoryCwId: number | null
|
||||
subcategory: string | null
|
||||
subcategoryCwId: number | null
|
||||
manufacturer: string | null
|
||||
manufactureCwId: number | null
|
||||
partNumber: string | null
|
||||
@@ -1772,6 +1936,10 @@ export interface CatalogItemFieldRefs {
|
||||
readonly description: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly customerDescription: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly internalNotes: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly category: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly categoryCwId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
||||
readonly subcategory: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly subcategoryCwId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
||||
readonly manufacturer: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly manufactureCwId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
||||
readonly partNumber: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,9 +40,11 @@ export type OpportunityAvgAggregateOutputType = {
|
||||
contactCwId: number | null
|
||||
siteCwId: number | null
|
||||
totalSalesTax: number | null
|
||||
probability: number | null
|
||||
locationCwId: number | null
|
||||
departmentCwId: number | null
|
||||
closedByCwId: number | null
|
||||
productSequence: number | null
|
||||
}
|
||||
|
||||
export type OpportunitySumAggregateOutputType = {
|
||||
@@ -59,9 +61,11 @@ export type OpportunitySumAggregateOutputType = {
|
||||
contactCwId: number | null
|
||||
siteCwId: number | null
|
||||
totalSalesTax: number | null
|
||||
probability: number | null
|
||||
locationCwId: number | null
|
||||
departmentCwId: number | null
|
||||
closedByCwId: number | null
|
||||
productSequence: number[]
|
||||
}
|
||||
|
||||
export type OpportunityMinAggregateOutputType = {
|
||||
@@ -96,6 +100,7 @@ export type OpportunityMinAggregateOutputType = {
|
||||
siteName: string | null
|
||||
customerPO: string | null
|
||||
totalSalesTax: number | null
|
||||
probability: number | null
|
||||
locationName: string | null
|
||||
locationCwId: number | null
|
||||
departmentName: string | null
|
||||
@@ -145,6 +150,7 @@ export type OpportunityMaxAggregateOutputType = {
|
||||
siteName: string | null
|
||||
customerPO: string | null
|
||||
totalSalesTax: number | null
|
||||
probability: number | null
|
||||
locationName: string | null
|
||||
locationCwId: number | null
|
||||
departmentName: string | null
|
||||
@@ -194,6 +200,7 @@ export type OpportunityCountAggregateOutputType = {
|
||||
siteName: number
|
||||
customerPO: number
|
||||
totalSalesTax: number
|
||||
probability: number
|
||||
locationName: number
|
||||
locationCwId: number
|
||||
departmentName: number
|
||||
@@ -206,6 +213,7 @@ export type OpportunityCountAggregateOutputType = {
|
||||
closedByName: number
|
||||
closedByCwId: number
|
||||
companyId: number
|
||||
productSequence: number
|
||||
cwLastUpdated: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
@@ -227,9 +235,11 @@ export type OpportunityAvgAggregateInputType = {
|
||||
contactCwId?: true
|
||||
siteCwId?: true
|
||||
totalSalesTax?: true
|
||||
probability?: true
|
||||
locationCwId?: true
|
||||
departmentCwId?: true
|
||||
closedByCwId?: true
|
||||
productSequence?: true
|
||||
}
|
||||
|
||||
export type OpportunitySumAggregateInputType = {
|
||||
@@ -246,9 +256,11 @@ export type OpportunitySumAggregateInputType = {
|
||||
contactCwId?: true
|
||||
siteCwId?: true
|
||||
totalSalesTax?: true
|
||||
probability?: true
|
||||
locationCwId?: true
|
||||
departmentCwId?: true
|
||||
closedByCwId?: true
|
||||
productSequence?: true
|
||||
}
|
||||
|
||||
export type OpportunityMinAggregateInputType = {
|
||||
@@ -283,6 +295,7 @@ export type OpportunityMinAggregateInputType = {
|
||||
siteName?: true
|
||||
customerPO?: true
|
||||
totalSalesTax?: true
|
||||
probability?: true
|
||||
locationName?: true
|
||||
locationCwId?: true
|
||||
departmentName?: true
|
||||
@@ -332,6 +345,7 @@ export type OpportunityMaxAggregateInputType = {
|
||||
siteName?: true
|
||||
customerPO?: true
|
||||
totalSalesTax?: true
|
||||
probability?: true
|
||||
locationName?: true
|
||||
locationCwId?: true
|
||||
departmentName?: true
|
||||
@@ -381,6 +395,7 @@ export type OpportunityCountAggregateInputType = {
|
||||
siteName?: true
|
||||
customerPO?: true
|
||||
totalSalesTax?: true
|
||||
probability?: true
|
||||
locationName?: true
|
||||
locationCwId?: true
|
||||
departmentName?: true
|
||||
@@ -393,6 +408,7 @@ export type OpportunityCountAggregateInputType = {
|
||||
closedByName?: true
|
||||
closedByCwId?: true
|
||||
companyId?: true
|
||||
productSequence?: true
|
||||
cwLastUpdated?: true
|
||||
createdAt?: true
|
||||
updatedAt?: true
|
||||
@@ -517,6 +533,7 @@ export type OpportunityGroupByOutputType = {
|
||||
siteName: string | null
|
||||
customerPO: string | null
|
||||
totalSalesTax: number
|
||||
probability: number
|
||||
locationName: string | null
|
||||
locationCwId: number | null
|
||||
departmentName: string | null
|
||||
@@ -529,6 +546,7 @@ export type OpportunityGroupByOutputType = {
|
||||
closedByName: string | null
|
||||
closedByCwId: number | null
|
||||
companyId: string | null
|
||||
productSequence: number[]
|
||||
cwLastUpdated: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
@@ -589,6 +607,7 @@ export type OpportunityWhereInput = {
|
||||
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
|
||||
probability?: Prisma.FloatFilter<"Opportunity"> | number
|
||||
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
@@ -601,9 +620,11 @@ export type OpportunityWhereInput = {
|
||||
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
||||
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
|
||||
}
|
||||
|
||||
@@ -639,6 +660,7 @@ export type OpportunityOrderByWithRelationInput = {
|
||||
siteName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
customerPO?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
departmentName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
@@ -651,9 +673,11 @@ export type OpportunityOrderByWithRelationInput = {
|
||||
closedByName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
|
||||
company?: Prisma.CompanyOrderByWithRelationInput
|
||||
}
|
||||
|
||||
@@ -692,6 +716,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
|
||||
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
|
||||
probability?: Prisma.FloatFilter<"Opportunity"> | number
|
||||
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
@@ -704,9 +729,11 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
|
||||
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
||||
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
|
||||
}, "id" | "cwOpportunityId">
|
||||
|
||||
@@ -742,6 +769,7 @@ export type OpportunityOrderByWithAggregationInput = {
|
||||
siteName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
customerPO?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
departmentName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
@@ -754,6 +782,7 @@ export type OpportunityOrderByWithAggregationInput = {
|
||||
closedByName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
@@ -799,6 +828,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
|
||||
siteName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
customerPO?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
totalSalesTax?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number
|
||||
probability?: Prisma.FloatWithAggregatesFilter<"Opportunity"> | number
|
||||
locationName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
locationCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null
|
||||
departmentName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
@@ -811,6 +841,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
|
||||
closedByName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
closedByCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null
|
||||
companyId?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||
cwLastUpdated?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
||||
@@ -848,6 +879,7 @@ export type OpportunityCreateInput = {
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
@@ -859,9 +891,11 @@ export type OpportunityCreateInput = {
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
|
||||
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
|
||||
}
|
||||
|
||||
@@ -897,6 +931,7 @@ export type OpportunityUncheckedCreateInput = {
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
@@ -909,9 +944,11 @@ export type OpportunityUncheckedCreateInput = {
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
companyId?: string | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
|
||||
}
|
||||
|
||||
export type OpportunityUpdateInput = {
|
||||
@@ -946,6 +983,7 @@ export type OpportunityUpdateInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -957,9 +995,11 @@ export type OpportunityUpdateInput = {
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
|
||||
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
|
||||
}
|
||||
|
||||
@@ -995,6 +1035,7 @@ export type OpportunityUncheckedUpdateInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1007,9 +1048,11 @@ export type OpportunityUncheckedUpdateInput = {
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
|
||||
}
|
||||
|
||||
export type OpportunityCreateManyInput = {
|
||||
@@ -1044,6 +1087,7 @@ export type OpportunityCreateManyInput = {
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
@@ -1056,6 +1100,7 @@ export type OpportunityCreateManyInput = {
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
companyId?: string | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -1093,6 +1138,7 @@ export type OpportunityUpdateManyMutationInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1104,6 +1150,7 @@ export type OpportunityUpdateManyMutationInput = {
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -1141,6 +1188,7 @@ export type OpportunityUncheckedUpdateManyInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1153,6 +1201,7 @@ export type OpportunityUncheckedUpdateManyInput = {
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -1168,6 +1217,14 @@ export type OpportunityOrderByRelationAggregateInput = {
|
||||
_count?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type IntNullableListFilter<$PrismaModel = never> = {
|
||||
equals?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
has?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
hasEvery?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
hasSome?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
isEmpty?: boolean
|
||||
}
|
||||
|
||||
export type OpportunityCountOrderByAggregateInput = {
|
||||
id?: Prisma.SortOrder
|
||||
cwOpportunityId?: Prisma.SortOrder
|
||||
@@ -1200,6 +1257,7 @@ export type OpportunityCountOrderByAggregateInput = {
|
||||
siteName?: Prisma.SortOrder
|
||||
customerPO?: Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationName?: Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentName?: Prisma.SortOrder
|
||||
@@ -1212,6 +1270,7 @@ export type OpportunityCountOrderByAggregateInput = {
|
||||
closedByName?: Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrder
|
||||
companyId?: Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
cwLastUpdated?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
@@ -1231,9 +1290,11 @@ export type OpportunityAvgOrderByAggregateInput = {
|
||||
contactCwId?: Prisma.SortOrder
|
||||
siteCwId?: Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentCwId?: Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type OpportunityMaxOrderByAggregateInput = {
|
||||
@@ -1268,6 +1329,7 @@ export type OpportunityMaxOrderByAggregateInput = {
|
||||
siteName?: Prisma.SortOrder
|
||||
customerPO?: Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationName?: Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentName?: Prisma.SortOrder
|
||||
@@ -1317,6 +1379,7 @@ export type OpportunityMinOrderByAggregateInput = {
|
||||
siteName?: Prisma.SortOrder
|
||||
customerPO?: Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationName?: Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentName?: Prisma.SortOrder
|
||||
@@ -1348,9 +1411,16 @@ export type OpportunitySumOrderByAggregateInput = {
|
||||
contactCwId?: Prisma.SortOrder
|
||||
siteCwId?: Prisma.SortOrder
|
||||
totalSalesTax?: Prisma.SortOrder
|
||||
probability?: Prisma.SortOrder
|
||||
locationCwId?: Prisma.SortOrder
|
||||
departmentCwId?: Prisma.SortOrder
|
||||
closedByCwId?: Prisma.SortOrder
|
||||
productSequence?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type OpportunityScalarRelationFilter = {
|
||||
is?: Prisma.OpportunityWhereInput
|
||||
isNot?: Prisma.OpportunityWhereInput
|
||||
}
|
||||
|
||||
export type OpportunityCreateNestedManyWithoutCompanyInput = {
|
||||
@@ -1395,6 +1465,29 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyNestedInput = {
|
||||
deleteMany?: Prisma.OpportunityScalarWhereInput | Prisma.OpportunityScalarWhereInput[]
|
||||
}
|
||||
|
||||
export type OpportunityCreateproductSequenceInput = {
|
||||
set: number[]
|
||||
}
|
||||
|
||||
export type OpportunityUpdateproductSequenceInput = {
|
||||
set?: number[]
|
||||
push?: number | number[]
|
||||
}
|
||||
|
||||
export type OpportunityCreateNestedOneWithoutGeneratedQuotesInput = {
|
||||
create?: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutGeneratedQuotesInput
|
||||
connect?: Prisma.OpportunityWhereUniqueInput
|
||||
}
|
||||
|
||||
export type OpportunityUpdateOneRequiredWithoutGeneratedQuotesNestedInput = {
|
||||
create?: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
connectOrCreate?: Prisma.OpportunityCreateOrConnectWithoutGeneratedQuotesInput
|
||||
upsert?: Prisma.OpportunityUpsertWithoutGeneratedQuotesInput
|
||||
connect?: Prisma.OpportunityWhereUniqueInput
|
||||
update?: Prisma.XOR<Prisma.XOR<Prisma.OpportunityUpdateToOneWithWhereWithoutGeneratedQuotesInput, Prisma.OpportunityUpdateWithoutGeneratedQuotesInput>, Prisma.OpportunityUncheckedUpdateWithoutGeneratedQuotesInput>
|
||||
}
|
||||
|
||||
export type OpportunityCreateWithoutCompanyInput = {
|
||||
id?: string
|
||||
cwOpportunityId: number
|
||||
@@ -1427,6 +1520,7 @@ export type OpportunityCreateWithoutCompanyInput = {
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
@@ -1438,9 +1532,11 @@ export type OpportunityCreateWithoutCompanyInput = {
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
|
||||
}
|
||||
|
||||
export type OpportunityUncheckedCreateWithoutCompanyInput = {
|
||||
@@ -1475,6 +1571,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
@@ -1486,9 +1583,11 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
|
||||
}
|
||||
|
||||
export type OpportunityCreateOrConnectWithoutCompanyInput = {
|
||||
@@ -1552,6 +1651,7 @@ export type OpportunityScalarWhereInput = {
|
||||
siteName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
customerPO?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
totalSalesTax?: Prisma.FloatFilter<"Opportunity"> | number
|
||||
probability?: Prisma.FloatFilter<"Opportunity"> | number
|
||||
locationName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
locationCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
departmentName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
@@ -1564,11 +1664,232 @@ export type OpportunityScalarWhereInput = {
|
||||
closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null
|
||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||
}
|
||||
|
||||
export type OpportunityCreateWithoutGeneratedQuotesInput = {
|
||||
id?: string
|
||||
cwOpportunityId: number
|
||||
name: string
|
||||
notes?: string | null
|
||||
typeName?: string | null
|
||||
typeCwId?: number | null
|
||||
stageName?: string | null
|
||||
stageCwId?: number | null
|
||||
statusName?: string | null
|
||||
statusCwId?: number | null
|
||||
priorityName?: string | null
|
||||
priorityCwId?: number | null
|
||||
ratingName?: string | null
|
||||
ratingCwId?: number | null
|
||||
source?: string | null
|
||||
campaignName?: string | null
|
||||
campaignCwId?: number | null
|
||||
primarySalesRepName?: string | null
|
||||
primarySalesRepIdentifier?: string | null
|
||||
primarySalesRepCwId?: number | null
|
||||
secondarySalesRepName?: string | null
|
||||
secondarySalesRepIdentifier?: string | null
|
||||
secondarySalesRepCwId?: number | null
|
||||
companyCwId?: number | null
|
||||
companyName?: string | null
|
||||
contactCwId?: number | null
|
||||
contactName?: string | null
|
||||
siteCwId?: number | null
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
departmentCwId?: number | null
|
||||
expectedCloseDate?: Date | string | null
|
||||
pipelineChangeDate?: Date | string | null
|
||||
dateBecameLead?: Date | string | null
|
||||
closedDate?: Date | string | null
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
|
||||
}
|
||||
|
||||
export type OpportunityUncheckedCreateWithoutGeneratedQuotesInput = {
|
||||
id?: string
|
||||
cwOpportunityId: number
|
||||
name: string
|
||||
notes?: string | null
|
||||
typeName?: string | null
|
||||
typeCwId?: number | null
|
||||
stageName?: string | null
|
||||
stageCwId?: number | null
|
||||
statusName?: string | null
|
||||
statusCwId?: number | null
|
||||
priorityName?: string | null
|
||||
priorityCwId?: number | null
|
||||
ratingName?: string | null
|
||||
ratingCwId?: number | null
|
||||
source?: string | null
|
||||
campaignName?: string | null
|
||||
campaignCwId?: number | null
|
||||
primarySalesRepName?: string | null
|
||||
primarySalesRepIdentifier?: string | null
|
||||
primarySalesRepCwId?: number | null
|
||||
secondarySalesRepName?: string | null
|
||||
secondarySalesRepIdentifier?: string | null
|
||||
secondarySalesRepCwId?: number | null
|
||||
companyCwId?: number | null
|
||||
companyName?: string | null
|
||||
contactCwId?: number | null
|
||||
contactName?: string | null
|
||||
siteCwId?: number | null
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
departmentCwId?: number | null
|
||||
expectedCloseDate?: Date | string | null
|
||||
pipelineChangeDate?: Date | string | null
|
||||
dateBecameLead?: Date | string | null
|
||||
closedDate?: Date | string | null
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
companyId?: string | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
|
||||
export type OpportunityCreateOrConnectWithoutGeneratedQuotesInput = {
|
||||
where: Prisma.OpportunityWhereUniqueInput
|
||||
create: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
}
|
||||
|
||||
export type OpportunityUpsertWithoutGeneratedQuotesInput = {
|
||||
update: Prisma.XOR<Prisma.OpportunityUpdateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedUpdateWithoutGeneratedQuotesInput>
|
||||
create: Prisma.XOR<Prisma.OpportunityCreateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
where?: Prisma.OpportunityWhereInput
|
||||
}
|
||||
|
||||
export type OpportunityUpdateToOneWithWhereWithoutGeneratedQuotesInput = {
|
||||
where?: Prisma.OpportunityWhereInput
|
||||
data: Prisma.XOR<Prisma.OpportunityUpdateWithoutGeneratedQuotesInput, Prisma.OpportunityUncheckedUpdateWithoutGeneratedQuotesInput>
|
||||
}
|
||||
|
||||
export type OpportunityUpdateWithoutGeneratedQuotesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwOpportunityId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
typeName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
typeCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
stageName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
stageCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
statusName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
statusCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
priorityName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
priorityCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
ratingName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
ratingCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
source?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
campaignName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
campaignCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
primarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
primarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
primarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
secondarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
secondarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
secondarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
contactCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
contactName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
siteCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
departmentCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
expectedCloseDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
pipelineChangeDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
dateBecameLead?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
closedDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
|
||||
}
|
||||
|
||||
export type OpportunityUncheckedUpdateWithoutGeneratedQuotesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwOpportunityId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
typeName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
typeCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
stageName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
stageCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
statusName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
statusCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
priorityName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
priorityCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
ratingName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
ratingCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
source?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
campaignName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
campaignCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
primarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
primarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
primarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
secondarySalesRepName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
secondarySalesRepIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
secondarySalesRepCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
contactCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
contactName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
siteCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
departmentCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
expectedCloseDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
pipelineChangeDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
dateBecameLead?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
closedDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
||||
export type OpportunityCreateManyCompanyInput = {
|
||||
id?: string
|
||||
cwOpportunityId: number
|
||||
@@ -1601,6 +1922,7 @@ export type OpportunityCreateManyCompanyInput = {
|
||||
siteName?: string | null
|
||||
customerPO?: string | null
|
||||
totalSalesTax?: number
|
||||
probability?: number
|
||||
locationName?: string | null
|
||||
locationCwId?: number | null
|
||||
departmentName?: string | null
|
||||
@@ -1612,6 +1934,7 @@ export type OpportunityCreateManyCompanyInput = {
|
||||
closedFlag?: boolean
|
||||
closedByName?: string | null
|
||||
closedByCwId?: number | null
|
||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Date | string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
@@ -1649,6 +1972,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1660,9 +1984,11 @@ export type OpportunityUpdateWithoutCompanyInput = {
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
|
||||
}
|
||||
|
||||
export type OpportunityUncheckedUpdateWithoutCompanyInput = {
|
||||
@@ -1697,6 +2023,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1708,9 +2035,11 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
|
||||
}
|
||||
|
||||
export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
|
||||
@@ -1745,6 +2074,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
|
||||
siteName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerPO?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
totalSalesTax?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
probability?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
locationName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
locationCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
departmentName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1756,12 +2086,42 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
|
||||
closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Count Type OpportunityCountOutputType
|
||||
*/
|
||||
|
||||
export type OpportunityCountOutputType = {
|
||||
generatedQuotes: number
|
||||
}
|
||||
|
||||
export type OpportunityCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
generatedQuotes?: boolean | OpportunityCountOutputTypeCountGeneratedQuotesArgs
|
||||
}
|
||||
|
||||
/**
|
||||
* OpportunityCountOutputType without action
|
||||
*/
|
||||
export type OpportunityCountOutputTypeDefaultArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
/**
|
||||
* Select specific fields to fetch from the OpportunityCountOutputType
|
||||
*/
|
||||
select?: Prisma.OpportunityCountOutputTypeSelect<ExtArgs> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* OpportunityCountOutputType without action
|
||||
*/
|
||||
export type OpportunityCountOutputTypeCountGeneratedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
where?: Prisma.GeneratedQuotesWhereInput
|
||||
}
|
||||
|
||||
|
||||
export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||
id?: boolean
|
||||
@@ -1795,6 +2155,7 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
||||
siteName?: boolean
|
||||
customerPO?: boolean
|
||||
totalSalesTax?: boolean
|
||||
probability?: boolean
|
||||
locationName?: boolean
|
||||
locationCwId?: boolean
|
||||
departmentName?: boolean
|
||||
@@ -1807,10 +2168,13 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
||||
closedByName?: boolean
|
||||
closedByCwId?: boolean
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
|
||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.OpportunityCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["opportunity"]>
|
||||
|
||||
export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||
@@ -1845,6 +2209,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
siteName?: boolean
|
||||
customerPO?: boolean
|
||||
totalSalesTax?: boolean
|
||||
probability?: boolean
|
||||
locationName?: boolean
|
||||
locationCwId?: boolean
|
||||
departmentName?: boolean
|
||||
@@ -1857,6 +2222,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
closedByName?: boolean
|
||||
closedByCwId?: boolean
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
@@ -1895,6 +2261,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
siteName?: boolean
|
||||
customerPO?: boolean
|
||||
totalSalesTax?: boolean
|
||||
probability?: boolean
|
||||
locationName?: boolean
|
||||
locationCwId?: boolean
|
||||
departmentName?: boolean
|
||||
@@ -1907,6 +2274,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
closedByName?: boolean
|
||||
closedByCwId?: boolean
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
@@ -1945,6 +2313,7 @@ export type OpportunitySelectScalar = {
|
||||
siteName?: boolean
|
||||
customerPO?: boolean
|
||||
totalSalesTax?: boolean
|
||||
probability?: boolean
|
||||
locationName?: boolean
|
||||
locationCwId?: boolean
|
||||
departmentName?: boolean
|
||||
@@ -1957,14 +2326,17 @@ export type OpportunitySelectScalar = {
|
||||
closedByName?: boolean
|
||||
closedByCwId?: boolean
|
||||
companyId?: boolean
|
||||
productSequence?: boolean
|
||||
cwLastUpdated?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
}
|
||||
|
||||
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
|
||||
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "probability" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "productSequence" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
|
||||
export type OpportunityInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
|
||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.OpportunityCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}
|
||||
export type OpportunityIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||
@@ -1976,6 +2348,7 @@ export type OpportunityIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.
|
||||
export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
name: "Opportunity"
|
||||
objects: {
|
||||
generatedQuotes: Prisma.$GeneratedQuotesPayload<ExtArgs>[]
|
||||
company: Prisma.$CompanyPayload<ExtArgs> | null
|
||||
}
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
@@ -2010,6 +2383,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
||||
siteName: string | null
|
||||
customerPO: string | null
|
||||
totalSalesTax: number
|
||||
probability: number
|
||||
locationName: string | null
|
||||
locationCwId: number | null
|
||||
departmentName: string | null
|
||||
@@ -2022,6 +2396,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
||||
closedByName: string | null
|
||||
closedByCwId: number | null
|
||||
companyId: string | null
|
||||
productSequence: number[]
|
||||
cwLastUpdated: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
@@ -2419,6 +2794,7 @@ readonly fields: OpportunityFieldRefs;
|
||||
*/
|
||||
export interface Prisma__OpportunityClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
|
||||
readonly [Symbol.toStringTag]: "PrismaPromise"
|
||||
generatedQuotes<T extends Prisma.Opportunity$generatedQuotesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Opportunity$generatedQuotesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$GeneratedQuotesPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
company<T extends Prisma.Opportunity$companyArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Opportunity$companyArgs<ExtArgs>>): Prisma.Prisma__CompanyClient<runtime.Types.Result.GetResult<Prisma.$CompanyPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
|
||||
/**
|
||||
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||
@@ -2480,6 +2856,7 @@ export interface OpportunityFieldRefs {
|
||||
readonly siteName: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
readonly customerPO: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
readonly totalSalesTax: Prisma.FieldRef<"Opportunity", 'Float'>
|
||||
readonly probability: Prisma.FieldRef<"Opportunity", 'Float'>
|
||||
readonly locationName: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
readonly locationCwId: Prisma.FieldRef<"Opportunity", 'Int'>
|
||||
readonly departmentName: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
@@ -2492,6 +2869,7 @@ export interface OpportunityFieldRefs {
|
||||
readonly closedByName: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
readonly closedByCwId: Prisma.FieldRef<"Opportunity", 'Int'>
|
||||
readonly companyId: Prisma.FieldRef<"Opportunity", 'String'>
|
||||
readonly productSequence: Prisma.FieldRef<"Opportunity", 'Int[]'>
|
||||
readonly cwLastUpdated: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||
readonly createdAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||
readonly updatedAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||
@@ -2890,6 +3268,30 @@ export type OpportunityDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.I
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Opportunity.generatedQuotes
|
||||
*/
|
||||
export type Opportunity$generatedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
/**
|
||||
* Select specific fields to fetch from the GeneratedQuotes
|
||||
*/
|
||||
select?: Prisma.GeneratedQuotesSelect<ExtArgs> | null
|
||||
/**
|
||||
* Omit specific fields from the GeneratedQuotes
|
||||
*/
|
||||
omit?: Prisma.GeneratedQuotesOmit<ExtArgs> | null
|
||||
/**
|
||||
* Choose, which related nodes to fetch as well
|
||||
*/
|
||||
include?: Prisma.GeneratedQuotesInclude<ExtArgs> | null
|
||||
where?: Prisma.GeneratedQuotesWhereInput
|
||||
orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[]
|
||||
cursor?: Prisma.GeneratedQuotesWhereUniqueInput
|
||||
take?: number
|
||||
skip?: number
|
||||
distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Opportunity.company
|
||||
*/
|
||||
|
||||
@@ -32,6 +32,7 @@ export type UserMinAggregateOutputType = {
|
||||
email: string | null
|
||||
emailVerified: Date | null
|
||||
image: string | null
|
||||
cwIdentifier: string | null
|
||||
userId: string | null
|
||||
token: string | null
|
||||
createdAt: Date | null
|
||||
@@ -46,6 +47,7 @@ export type UserMaxAggregateOutputType = {
|
||||
email: string | null
|
||||
emailVerified: Date | null
|
||||
image: string | null
|
||||
cwIdentifier: string | null
|
||||
userId: string | null
|
||||
token: string | null
|
||||
createdAt: Date | null
|
||||
@@ -60,6 +62,7 @@ export type UserCountAggregateOutputType = {
|
||||
email: number
|
||||
emailVerified: number
|
||||
image: number
|
||||
cwIdentifier: number
|
||||
userId: number
|
||||
token: number
|
||||
createdAt: number
|
||||
@@ -76,6 +79,7 @@ export type UserMinAggregateInputType = {
|
||||
email?: true
|
||||
emailVerified?: true
|
||||
image?: true
|
||||
cwIdentifier?: true
|
||||
userId?: true
|
||||
token?: true
|
||||
createdAt?: true
|
||||
@@ -90,6 +94,7 @@ export type UserMaxAggregateInputType = {
|
||||
email?: true
|
||||
emailVerified?: true
|
||||
image?: true
|
||||
cwIdentifier?: true
|
||||
userId?: true
|
||||
token?: true
|
||||
createdAt?: true
|
||||
@@ -104,6 +109,7 @@ export type UserCountAggregateInputType = {
|
||||
email?: true
|
||||
emailVerified?: true
|
||||
image?: true
|
||||
cwIdentifier?: true
|
||||
userId?: true
|
||||
token?: true
|
||||
createdAt?: true
|
||||
@@ -191,6 +197,7 @@ export type UserGroupByOutputType = {
|
||||
email: string
|
||||
emailVerified: Date | null
|
||||
image: string | null
|
||||
cwIdentifier: string | null
|
||||
userId: string
|
||||
token: string | null
|
||||
createdAt: Date
|
||||
@@ -226,12 +233,14 @@ export type UserWhereInput = {
|
||||
email?: Prisma.StringFilter<"User"> | string
|
||||
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
cwIdentifier?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
userId?: Prisma.StringFilter<"User"> | string
|
||||
token?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
roles?: Prisma.RoleListRelationFilter
|
||||
sessions?: Prisma.SessionListRelationFilter
|
||||
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
||||
}
|
||||
|
||||
export type UserOrderByWithRelationInput = {
|
||||
@@ -242,12 +251,14 @@ export type UserOrderByWithRelationInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
roles?: Prisma.RoleOrderByRelationAggregateInput
|
||||
sessions?: Prisma.SessionOrderByRelationAggregateInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
|
||||
}
|
||||
|
||||
export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||
@@ -262,11 +273,13 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||
name?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
cwIdentifier?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
token?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
roles?: Prisma.RoleListRelationFilter
|
||||
sessions?: Prisma.SessionListRelationFilter
|
||||
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
||||
}, "id" | "login" | "email" | "userId">
|
||||
|
||||
export type UserOrderByWithAggregationInput = {
|
||||
@@ -277,6 +290,7 @@ export type UserOrderByWithAggregationInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -297,6 +311,7 @@ export type UserScalarWhereWithAggregatesInput = {
|
||||
email?: Prisma.StringWithAggregatesFilter<"User"> | string
|
||||
emailVerified?: Prisma.DateTimeNullableWithAggregatesFilter<"User"> | Date | string | null
|
||||
image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
cwIdentifier?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
userId?: Prisma.StringWithAggregatesFilter<"User"> | string
|
||||
token?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
||||
@@ -311,12 +326,14 @@ export type UserCreateInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
|
||||
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
|
||||
}
|
||||
|
||||
export type UserUncheckedCreateInput = {
|
||||
@@ -327,12 +344,14 @@ export type UserUncheckedCreateInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
|
||||
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
|
||||
}
|
||||
|
||||
export type UserUpdateInput = {
|
||||
@@ -343,12 +362,14 @@ export type UserUpdateInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
|
||||
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateInput = {
|
||||
@@ -359,12 +380,14 @@ export type UserUncheckedUpdateInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
|
||||
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
|
||||
}
|
||||
|
||||
export type UserCreateManyInput = {
|
||||
@@ -375,6 +398,7 @@ export type UserCreateManyInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -389,6 +413,7 @@ export type UserUpdateManyMutationInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -403,6 +428,7 @@ export type UserUncheckedUpdateManyInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -422,6 +448,7 @@ export type UserCountOrderByAggregateInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -436,6 +463,7 @@ export type UserMaxOrderByAggregateInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -450,6 +478,7 @@ export type UserMinOrderByAggregateInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -466,6 +495,11 @@ export type UserOrderByRelationAggregateInput = {
|
||||
_count?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type UserNullableScalarRelationFilter = {
|
||||
is?: Prisma.UserWhereInput | null
|
||||
isNot?: Prisma.UserWhereInput | null
|
||||
}
|
||||
|
||||
export type UserCreateNestedOneWithoutSessionsInput = {
|
||||
create?: Prisma.XOR<Prisma.UserCreateWithoutSessionsInput, Prisma.UserUncheckedCreateWithoutSessionsInput>
|
||||
connectOrCreate?: Prisma.UserCreateOrConnectWithoutSessionsInput
|
||||
@@ -522,6 +556,22 @@ export type UserUncheckedUpdateManyWithoutRolesNestedInput = {
|
||||
deleteMany?: Prisma.UserScalarWhereInput | Prisma.UserScalarWhereInput[]
|
||||
}
|
||||
|
||||
export type UserCreateNestedOneWithoutGeneratedQuotesInput = {
|
||||
create?: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
connectOrCreate?: Prisma.UserCreateOrConnectWithoutGeneratedQuotesInput
|
||||
connect?: Prisma.UserWhereUniqueInput
|
||||
}
|
||||
|
||||
export type UserUpdateOneWithoutGeneratedQuotesNestedInput = {
|
||||
create?: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
connectOrCreate?: Prisma.UserCreateOrConnectWithoutGeneratedQuotesInput
|
||||
upsert?: Prisma.UserUpsertWithoutGeneratedQuotesInput
|
||||
disconnect?: Prisma.UserWhereInput | boolean
|
||||
delete?: Prisma.UserWhereInput | boolean
|
||||
connect?: Prisma.UserWhereUniqueInput
|
||||
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutGeneratedQuotesInput, Prisma.UserUpdateWithoutGeneratedQuotesInput>, Prisma.UserUncheckedUpdateWithoutGeneratedQuotesInput>
|
||||
}
|
||||
|
||||
export type UserCreateWithoutSessionsInput = {
|
||||
id?: string
|
||||
permissions?: string | null
|
||||
@@ -530,11 +580,13 @@ export type UserCreateWithoutSessionsInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
|
||||
}
|
||||
|
||||
export type UserUncheckedCreateWithoutSessionsInput = {
|
||||
@@ -545,11 +597,13 @@ export type UserUncheckedCreateWithoutSessionsInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
|
||||
}
|
||||
|
||||
export type UserCreateOrConnectWithoutSessionsInput = {
|
||||
@@ -576,11 +630,13 @@ export type UserUpdateWithoutSessionsInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateWithoutSessionsInput = {
|
||||
@@ -591,11 +647,13 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
|
||||
}
|
||||
|
||||
export type UserCreateWithoutRolesInput = {
|
||||
@@ -606,11 +664,13 @@ export type UserCreateWithoutRolesInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutCreatedByInput
|
||||
}
|
||||
|
||||
export type UserUncheckedCreateWithoutRolesInput = {
|
||||
@@ -621,11 +681,13 @@ export type UserUncheckedCreateWithoutRolesInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutCreatedByInput
|
||||
}
|
||||
|
||||
export type UserCreateOrConnectWithoutRolesInput = {
|
||||
@@ -660,12 +722,97 @@ export type UserScalarWhereInput = {
|
||||
email?: Prisma.StringFilter<"User"> | string
|
||||
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
cwIdentifier?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
userId?: Prisma.StringFilter<"User"> | string
|
||||
token?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
}
|
||||
|
||||
export type UserCreateWithoutGeneratedQuotesInput = {
|
||||
id?: string
|
||||
permissions?: string | null
|
||||
login: string
|
||||
name?: string | null
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
roles?: Prisma.RoleCreateNestedManyWithoutUsersInput
|
||||
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
|
||||
}
|
||||
|
||||
export type UserUncheckedCreateWithoutGeneratedQuotesInput = {
|
||||
id?: string
|
||||
permissions?: string | null
|
||||
login: string
|
||||
name?: string | null
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
roles?: Prisma.RoleUncheckedCreateNestedManyWithoutUsersInput
|
||||
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
|
||||
}
|
||||
|
||||
export type UserCreateOrConnectWithoutGeneratedQuotesInput = {
|
||||
where: Prisma.UserWhereUniqueInput
|
||||
create: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
}
|
||||
|
||||
export type UserUpsertWithoutGeneratedQuotesInput = {
|
||||
update: Prisma.XOR<Prisma.UserUpdateWithoutGeneratedQuotesInput, Prisma.UserUncheckedUpdateWithoutGeneratedQuotesInput>
|
||||
create: Prisma.XOR<Prisma.UserCreateWithoutGeneratedQuotesInput, Prisma.UserUncheckedCreateWithoutGeneratedQuotesInput>
|
||||
where?: Prisma.UserWhereInput
|
||||
}
|
||||
|
||||
export type UserUpdateToOneWithWhereWithoutGeneratedQuotesInput = {
|
||||
where?: Prisma.UserWhereInput
|
||||
data: Prisma.XOR<Prisma.UserUpdateWithoutGeneratedQuotesInput, Prisma.UserUncheckedUpdateWithoutGeneratedQuotesInput>
|
||||
}
|
||||
|
||||
export type UserUpdateWithoutGeneratedQuotesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
login?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
name?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
roles?: Prisma.RoleUpdateManyWithoutUsersNestedInput
|
||||
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateWithoutGeneratedQuotesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
login?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
name?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
roles?: Prisma.RoleUncheckedUpdateManyWithoutUsersNestedInput
|
||||
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
|
||||
}
|
||||
|
||||
export type UserUpdateWithoutRolesInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
permissions?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -674,11 +821,13 @@ export type UserUpdateWithoutRolesInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutCreatedByNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateWithoutRolesInput = {
|
||||
@@ -689,11 +838,13 @@ export type UserUncheckedUpdateWithoutRolesInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
|
||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutCreatedByNestedInput
|
||||
}
|
||||
|
||||
export type UserUncheckedUpdateManyWithoutRolesInput = {
|
||||
@@ -704,6 +855,7 @@ export type UserUncheckedUpdateManyWithoutRolesInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -718,11 +870,13 @@ export type UserUncheckedUpdateManyWithoutRolesInput = {
|
||||
export type UserCountOutputType = {
|
||||
roles: number
|
||||
sessions: number
|
||||
generatedQuotes: number
|
||||
}
|
||||
|
||||
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
roles?: boolean | UserCountOutputTypeCountRolesArgs
|
||||
sessions?: boolean | UserCountOutputTypeCountSessionsArgs
|
||||
generatedQuotes?: boolean | UserCountOutputTypeCountGeneratedQuotesArgs
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -749,6 +903,13 @@ export type UserCountOutputTypeCountSessionsArgs<ExtArgs extends runtime.Types.E
|
||||
where?: Prisma.SessionWhereInput
|
||||
}
|
||||
|
||||
/**
|
||||
* UserCountOutputType without action
|
||||
*/
|
||||
export type UserCountOutputTypeCountGeneratedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
where?: Prisma.GeneratedQuotesWhereInput
|
||||
}
|
||||
|
||||
|
||||
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||
id?: boolean
|
||||
@@ -758,12 +919,14 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
||||
email?: boolean
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
cwIdentifier?: boolean
|
||||
userId?: boolean
|
||||
token?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
|
||||
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
|
||||
generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, ExtArgs["result"]["user"]>
|
||||
|
||||
@@ -775,6 +938,7 @@ export type UserSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
||||
email?: boolean
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
cwIdentifier?: boolean
|
||||
userId?: boolean
|
||||
token?: boolean
|
||||
createdAt?: boolean
|
||||
@@ -789,6 +953,7 @@ export type UserSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
||||
email?: boolean
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
cwIdentifier?: boolean
|
||||
userId?: boolean
|
||||
token?: boolean
|
||||
createdAt?: boolean
|
||||
@@ -803,16 +968,18 @@ export type UserSelectScalar = {
|
||||
email?: boolean
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
cwIdentifier?: boolean
|
||||
userId?: boolean
|
||||
token?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
}
|
||||
|
||||
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "permissions" | "login" | "name" | "email" | "emailVerified" | "image" | "userId" | "token" | "createdAt" | "updatedAt", ExtArgs["result"]["user"]>
|
||||
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "permissions" | "login" | "name" | "email" | "emailVerified" | "image" | "cwIdentifier" | "userId" | "token" | "createdAt" | "updatedAt", ExtArgs["result"]["user"]>
|
||||
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
|
||||
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
|
||||
generatedQuotes?: boolean | Prisma.User$generatedQuotesArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}
|
||||
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
|
||||
@@ -823,6 +990,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||
objects: {
|
||||
roles: Prisma.$RolePayload<ExtArgs>[]
|
||||
sessions: Prisma.$SessionPayload<ExtArgs>[]
|
||||
generatedQuotes: Prisma.$GeneratedQuotesPayload<ExtArgs>[]
|
||||
}
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
id: string
|
||||
@@ -832,6 +1000,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||
email: string
|
||||
emailVerified: Date | null
|
||||
image: string | null
|
||||
cwIdentifier: string | null
|
||||
userId: string
|
||||
token: string | null
|
||||
createdAt: Date
|
||||
@@ -1232,6 +1401,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
|
||||
readonly [Symbol.toStringTag]: "PrismaPromise"
|
||||
roles<T extends Prisma.User$rolesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$rolesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$RolePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
sessions<T extends Prisma.User$sessionsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$sessionsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SessionPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
generatedQuotes<T extends Prisma.User$generatedQuotesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$generatedQuotesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$GeneratedQuotesPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||
/**
|
||||
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
||||
@@ -1268,6 +1438,7 @@ export interface UserFieldRefs {
|
||||
readonly email: Prisma.FieldRef<"User", 'String'>
|
||||
readonly emailVerified: Prisma.FieldRef<"User", 'DateTime'>
|
||||
readonly image: Prisma.FieldRef<"User", 'String'>
|
||||
readonly cwIdentifier: Prisma.FieldRef<"User", 'String'>
|
||||
readonly userId: Prisma.FieldRef<"User", 'String'>
|
||||
readonly token: Prisma.FieldRef<"User", 'String'>
|
||||
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
|
||||
@@ -1707,6 +1878,30 @@ export type User$sessionsArgs<ExtArgs extends runtime.Types.Extensions.InternalA
|
||||
distinct?: Prisma.SessionScalarFieldEnum | Prisma.SessionScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* User.generatedQuotes
|
||||
*/
|
||||
export type User$generatedQuotesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
/**
|
||||
* Select specific fields to fetch from the GeneratedQuotes
|
||||
*/
|
||||
select?: Prisma.GeneratedQuotesSelect<ExtArgs> | null
|
||||
/**
|
||||
* Omit specific fields from the GeneratedQuotes
|
||||
*/
|
||||
omit?: Prisma.GeneratedQuotesOmit<ExtArgs> | null
|
||||
/**
|
||||
* Choose, which related nodes to fetch as well
|
||||
*/
|
||||
include?: Prisma.GeneratedQuotesInclude<ExtArgs> | null
|
||||
where?: Prisma.GeneratedQuotesWhereInput
|
||||
orderBy?: Prisma.GeneratedQuotesOrderByWithRelationInput | Prisma.GeneratedQuotesOrderByWithRelationInput[]
|
||||
cursor?: Prisma.GeneratedQuotesWhereUniqueInput
|
||||
take?: number
|
||||
skip?: number
|
||||
distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[]
|
||||
}
|
||||
|
||||
/**
|
||||
* User without action
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development bun --watch src/index.ts",
|
||||
"dev:log": "LOG_CW_API=1 NODE_ENV=development bun --watch src/index.ts",
|
||||
"test": "bun test --preload ./tests/setup.ts",
|
||||
"db:gen": "prisma generate",
|
||||
"db:push": "prisma migrate dev --skip-generate",
|
||||
@@ -27,6 +28,9 @@
|
||||
"utils:gen_private_keys": "bun ./utils/genPrivateKeys",
|
||||
"utils:create_admin_role": "bun ./utils/createAdminRole",
|
||||
"utils:assign_user_role": "bun ./utils/assignUserRole",
|
||||
"utils:test_webserver": "bun ./utils/testWebserver.ts",
|
||||
"utils:test_adjustments_poll": "bun ./utils/testAdjustmentsPoll.ts",
|
||||
"utils:analyze_cw": "python3 debug-scripts/analyze-cw-calls.py",
|
||||
"db:check": "bunx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --shadow-database-url $DATABASE_URL --exit-code"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -41,8 +45,11 @@
|
||||
"cors": "^2.8.6",
|
||||
"cuid": "^3.0.0",
|
||||
"hono": "^4.11.5",
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"keypair": "^1.0.4",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfmake": "^0.3.5",
|
||||
"prisma": "^7.3.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"zod": "^4.3.6",
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Generate diff SQL between current migrations and the Prisma schema
|
||||
DIFF_SQL=$(bunx prisma migrate diff \
|
||||
--from-migrations prisma/migrations \
|
||||
--to-schema-datamodel prisma/schema.prisma \
|
||||
--script 2>/dev/null || true)
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Resolve any previously failed migrations so deploy can proceed.
|
||||
# Only migrations explicitly marked as "Failed" in the status output are
|
||||
# resolved. We grep for lines containing "Failed" and extract the name.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "[migrate] Checking for failed migrations..."
|
||||
STATUS_OUTPUT=$(bunx prisma migrate status 2>&1 || true)
|
||||
echo "$STATUS_OUTPUT"
|
||||
|
||||
# If there's a meaningful diff (not just empty/comments), create a migration
|
||||
if [ -n "$DIFF_SQL" ] && echo "$DIFF_SQL" | grep -qvE '^\s*$|^--'; then
|
||||
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
|
||||
MIGRATION_DIR="prisma/migrations/${TIMESTAMP}_auto_generated"
|
||||
mkdir -p "$MIGRATION_DIR"
|
||||
echo "$DIFF_SQL" > "$MIGRATION_DIR/migration.sql"
|
||||
echo "[migrate] Created migration: $MIGRATION_DIR"
|
||||
else
|
||||
echo "[migrate] Schema and migrations are in sync — no migration needed."
|
||||
fi
|
||||
# Only resolve migrations whose status line explicitly says "Failed"
|
||||
echo "$STATUS_OUTPUT" | grep -i "failed" | grep -oE '[0-9]{14}_[a-zA-Z_]+' | while read -r MIGRATION; do
|
||||
echo "[migrate] Resolving failed migration: $MIGRATION"
|
||||
bunx prisma migrate resolve --rolled-back "$MIGRATION" || true
|
||||
done
|
||||
|
||||
# Deploy all pending migrations
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Deploy all pending migrations from the migrations directory.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "[migrate] Running prisma migrate deploy..."
|
||||
bunx prisma migrate deploy
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "identifier" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CatalogItem_identifier_key" ON "CatalogItem"("identifier");
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterTable: User
|
||||
ALTER TABLE "User" ADD COLUMN "cwIdentifier" TEXT;
|
||||
|
||||
-- AlterTable: CatalogItem
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "category" TEXT;
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "categoryCwId" INTEGER;
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "subcategory" TEXT;
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "subcategoryCwId" INTEGER;
|
||||
|
||||
-- AlterTable: Opportunity
|
||||
ALTER TABLE "Opportunity" ADD COLUMN "productSequence" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
|
||||
@@ -0,0 +1,9 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "GeneratedQuotes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"quoteFile" BYTEA NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GeneratedQuotes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `opportunityId` to the `GeneratedQuotes` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `quoteFileName` to the `GeneratedQuotes` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `quoteRegenData` to the `GeneratedQuotes` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "GeneratedQuotes" ADD COLUMN "createdById" TEXT,
|
||||
ADD COLUMN "opportunityId" TEXT NOT NULL,
|
||||
ADD COLUMN "quoteFileName" TEXT NOT NULL,
|
||||
ADD COLUMN "quoteRegenData" JSONB NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GeneratedQuotes" ADD CONSTRAINT "GeneratedQuotes_opportunityId_fkey" FOREIGN KEY ("opportunityId") REFERENCES "Opportunity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GeneratedQuotes" ADD CONSTRAINT "GeneratedQuotes_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: Opportunity
|
||||
ALTER TABLE "Opportunity" ADD COLUMN "probability" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- AlterTable: GeneratedQuotes — add columns missing from prior db push
|
||||
ALTER TABLE "GeneratedQuotes" ADD COLUMN "quoteRegenParams" JSONB NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE "GeneratedQuotes" ADD COLUMN "quoteRegenHash" TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE "GeneratedQuotes" ADD COLUMN "downloads" JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
-- AlterTable: GeneratedQuotes — set default on existing quoteRegenData column
|
||||
ALTER TABLE "GeneratedQuotes" ALTER COLUMN "quoteRegenData" SET DEFAULT '{}';
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GeneratedQuotes_quoteRegenHash_key" ON "GeneratedQuotes"("quoteRegenHash");
|
||||
+39
-2
@@ -34,13 +34,16 @@ model User {
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
|
||||
cwIdentifier String?
|
||||
|
||||
userId String @unique
|
||||
token String?
|
||||
|
||||
sessions Session[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
generatedQuotes GeneratedQuotes[]
|
||||
}
|
||||
|
||||
model Role {
|
||||
@@ -95,6 +98,11 @@ model CatalogItem {
|
||||
linkedItems CatalogItem[] @relation("LinkedItems")
|
||||
linkedTo CatalogItem[] @relation("LinkedItems")
|
||||
|
||||
category String?
|
||||
categoryCwId Int?
|
||||
subcategory String?
|
||||
subcategoryCwId Int?
|
||||
|
||||
manufacturer String?
|
||||
manufactureCwId Int?
|
||||
|
||||
@@ -123,6 +131,8 @@ model Opportunity {
|
||||
name String
|
||||
notes String?
|
||||
|
||||
generatedQuotes GeneratedQuotes[]
|
||||
|
||||
// Stage / status / priority / type / rating stored as JSON references
|
||||
// so we don't need separate lookup tables for CW enums
|
||||
typeName String?
|
||||
@@ -158,6 +168,7 @@ model Opportunity {
|
||||
|
||||
// Financials
|
||||
totalSalesTax Float @default(0)
|
||||
probability Float @default(0)
|
||||
|
||||
// Location / department
|
||||
locationName String?
|
||||
@@ -178,6 +189,10 @@ model Opportunity {
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id])
|
||||
|
||||
// Local product sequence — array of CW forecast item IDs in display order.
|
||||
// When present, fetchProducts() uses this order instead of CW sequenceNumber.
|
||||
productSequence Int[] @default([])
|
||||
|
||||
cwLastUpdated DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@@ -233,3 +248,25 @@ model Credential {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model GeneratedQuotes {
|
||||
id String @id @default(uuid())
|
||||
|
||||
quoteRegenData Json @default("{}") // Store any additional data needed for quote regeneration, such as product details, pricing, etc.
|
||||
quoteRegenParams Json @default("{}") // Store parameters used for quote regeneration, such as template ID, formatting options, etc.
|
||||
quoteRegenHash String @unique @default("")
|
||||
|
||||
downloads Json @default("[]") // Array of download records with timestamp and user info
|
||||
|
||||
quoteFile Bytes
|
||||
quoteFileName String
|
||||
|
||||
opportunityId String
|
||||
opportunity Opportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdById String?
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/company/companies/[id] */
|
||||
export default createRoute(
|
||||
@@ -42,13 +43,20 @@ export default createRoute(
|
||||
}
|
||||
}
|
||||
|
||||
const companyData = company.toJson({
|
||||
includeAddress,
|
||||
includePrimaryContact,
|
||||
includeAllContacts,
|
||||
});
|
||||
const gatedData = await processObjectValuePerms(
|
||||
companyData,
|
||||
"obj.company",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company Fetched Successfully!",
|
||||
company.toJson({
|
||||
includeAddress,
|
||||
includePrimaryContact,
|
||||
includeAllContacts,
|
||||
}),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { companies } from "../../../managers/companies";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/company/companies/:identifier/unifi/sites */
|
||||
export default createRoute(
|
||||
@@ -12,9 +13,16 @@ export default createRoute(
|
||||
async (c) => {
|
||||
const company = await companies.fetch(c.req.param("identifier"));
|
||||
const sites = await unifiSites.fetchByCompany(company.id);
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
sites.map((site) =>
|
||||
processObjectValuePerms(site, "obj.unifiSite", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company UniFi Sites Fetched Successfully!",
|
||||
sites,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { companies } from "../../managers/companies";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/company/companies */
|
||||
export default createRoute(
|
||||
@@ -22,9 +23,15 @@ export default createRoute(
|
||||
? (await companies.search(search, 1, 999999)).length
|
||||
: await companies.count();
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
data.map((item) =>
|
||||
processObjectValuePerms(item, "obj.company", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
let response = apiResponse.successful(
|
||||
"Companies Fetched Successfully!",
|
||||
data,
|
||||
gatedData,
|
||||
{
|
||||
pagination: {
|
||||
previousPage: page == 1 ? null : page - 1, // Previous Page
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentialTypes } from "../../managers/credentialTypes";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/credential-type/:identifier */
|
||||
export default createRoute(
|
||||
@@ -15,9 +16,15 @@ export default createRoute(
|
||||
c.req.param("identifier"),
|
||||
);
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
credentialType.toJson({ includeCredentialCount: true }),
|
||||
"obj.credentialType",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Credential Type Fetched Successfully!",
|
||||
credentialType.toJson({ includeCredentialCount: true }),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentialTypes } from "../../managers/credentialTypes";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/credential-type */
|
||||
export default createRoute(
|
||||
@@ -13,11 +14,19 @@ export default createRoute(
|
||||
async (c) => {
|
||||
const allCredentialTypes = await credentialTypes.fetchAll();
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
allCredentialTypes.map((ct) =>
|
||||
processObjectValuePerms(
|
||||
ct.toJson({ includeCredentialCount: true }),
|
||||
"obj.credentialType",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Credential Types Fetched Successfully!",
|
||||
allCredentialTypes.map((ct) =>
|
||||
ct.toJson({ includeCredentialCount: true }),
|
||||
),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentialTypes } from "../../managers/credentialTypes";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/credential-type/:id/credentials */
|
||||
export default createRoute(
|
||||
@@ -14,9 +15,15 @@ export default createRoute(
|
||||
const credentialType = await credentialTypes.fetch(c.req.param("id"));
|
||||
const credentials = await credentialType.fetchCredentials();
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
credentials.map((cred) =>
|
||||
processObjectValuePerms(cred.toJson(), "obj.credential", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Credentials Fetched Successfully!",
|
||||
credentials.map((cred) => cred.toJson()),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentials } from "../../managers/credentials";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/credential/:id */
|
||||
export default createRoute(
|
||||
@@ -12,10 +13,15 @@ export default createRoute(
|
||||
|
||||
async (c) => {
|
||||
const credential = await credentials.fetch(c.req.param("id"));
|
||||
const gatedData = await processObjectValuePerms(
|
||||
credential.toJson(),
|
||||
"obj.credential",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Credential Fetched Successfully!",
|
||||
credential.toJson(),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentials } from "../../managers/credentials";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/credential/company/:companyId */
|
||||
export default createRoute(
|
||||
@@ -15,9 +16,15 @@ export default createRoute(
|
||||
c.req.param("companyId"),
|
||||
);
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
companyCredentials.map((cred) =>
|
||||
processObjectValuePerms(cred.toJson(), "obj.credential", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company Credentials Fetched Successfully!",
|
||||
companyCredentials.map((cred) => cred.toJson()),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { credentials } from "../../managers/credentials";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/credential/credentials/:id/sub-credentials */
|
||||
export default createRoute(
|
||||
@@ -17,9 +18,15 @@ export default createRoute(
|
||||
|
||||
const subCredentials = await credentials.fetchSubCredentials(parentId);
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
subCredentials.map((sc) =>
|
||||
processObjectValuePerms(sc.toJson(), "obj.credential", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Sub-Credentials Fetched Successfully!",
|
||||
subCredentials.map((sc) => sc.toJson()),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { z } from "zod";
|
||||
import GenericError from "../../Errors/GenericError";
|
||||
|
||||
type ParsedJson = Record<string, unknown> | unknown[];
|
||||
|
||||
const callbackResource = z.enum([
|
||||
"opportunity",
|
||||
"ticket",
|
||||
"company",
|
||||
"activity",
|
||||
]);
|
||||
|
||||
const safeParseJson = (value: string): ParsedJson | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const isObject = typeof parsed === "object" && parsed !== null;
|
||||
|
||||
return isObject ? (parsed as ParsedJson) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const asObject = (value: ParsedJson | null): Record<string, unknown> | null => {
|
||||
if (!value) return null;
|
||||
if (Array.isArray(value)) return null;
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const parseJsonStringFields = (
|
||||
value: Record<string, unknown> | null,
|
||||
): Record<string, unknown> | null => {
|
||||
if (!value) return null;
|
||||
|
||||
return Object.entries(value).reduce<Record<string, unknown>>(
|
||||
(acc, [key, current]) => {
|
||||
if (typeof current !== "string") {
|
||||
acc[key] = current;
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
const looksLikeJson = current.startsWith("{") || current.startsWith("[");
|
||||
if (!looksLikeJson) {
|
||||
acc[key] = current;
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
const parsed = safeParseJson(current);
|
||||
acc[key] = parsed ?? current;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
const parseEntity = (value: unknown): ParsedJson | null => {
|
||||
if (typeof value === "string") return safeParseJson(value);
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
|
||||
return value as ParsedJson;
|
||||
};
|
||||
|
||||
const buildSummary = (
|
||||
resource: z.infer<typeof callbackResource>,
|
||||
parsedBody: Record<string, unknown> | null,
|
||||
parsedEntity: Record<string, unknown> | null,
|
||||
) => {
|
||||
if (!parsedBody) return null;
|
||||
|
||||
return {
|
||||
resource,
|
||||
messageId: parsedBody.MessageId ?? null,
|
||||
action: parsedBody.Action ?? null,
|
||||
type: parsedBody.Type ?? null,
|
||||
id: parsedBody.ID ?? null,
|
||||
memberId: parsedBody.MemberId ?? null,
|
||||
entityStatus:
|
||||
parsedEntity?.StatusName ??
|
||||
parsedEntity?.TicketStatus ??
|
||||
parsedEntity?.Status ??
|
||||
null,
|
||||
entitySummary: parsedEntity?.Summary ?? parsedEntity?.CompanyName ?? null,
|
||||
entityUpdatedBy: parsedEntity?.UpdatedBy ?? null,
|
||||
entityLastUpdated:
|
||||
parsedEntity?.LastUpdatedUTC ?? parsedEntity?.LastUpdated ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const parseHeaders = (headers: Headers): Record<string, string> =>
|
||||
Object.fromEntries(headers.entries());
|
||||
|
||||
const callbackHeaderSummary = (headers: Record<string, string>) => ({
|
||||
contentType: headers["content-type"] ?? null,
|
||||
userAgent: headers["user-agent"] ?? null,
|
||||
host: headers.host ?? null,
|
||||
forwardedFor: headers["x-forwarded-for"] ?? null,
|
||||
callbackId:
|
||||
headers["x-cw-request-id"] ??
|
||||
headers["x-request-id"] ??
|
||||
headers["x-correlation-id"] ??
|
||||
null,
|
||||
});
|
||||
|
||||
/* /v1/cw/callback/:resource */
|
||||
export default createRoute("post", ["/callback/:secret/:resource"], async (c) => {
|
||||
const suppliedSecret = c.req.param("secret");
|
||||
const expectedSecret = process.env.CW_CALLBACK_SECRET;
|
||||
|
||||
if (expectedSecret && suppliedSecret !== expectedSecret) {
|
||||
throw new GenericError({
|
||||
name: "Unauthorized",
|
||||
message: "Invalid callback secret.",
|
||||
cause: "Path secret mismatch",
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (!expectedSecret) {
|
||||
console.warn(
|
||||
"[cw-callback] CW_CALLBACK_SECRET is not configured; accepting path secret without verification",
|
||||
);
|
||||
}
|
||||
|
||||
const resource = callbackResource.parse(c.req.param("resource"));
|
||||
const headers = parseHeaders(c.req.raw.headers);
|
||||
const headerSummary = callbackHeaderSummary(headers);
|
||||
const rawBody = await c.req.text();
|
||||
const parsedJson = safeParseJson(rawBody);
|
||||
const parsedBody = asObject(parsedJson);
|
||||
const parsedBodyExpanded = parseJsonStringFields(parsedBody);
|
||||
const parsedEntity = asObject(parseEntity(parsedBodyExpanded?.Entity));
|
||||
const summary = buildSummary(resource, parsedBodyExpanded, parsedEntity);
|
||||
|
||||
const line = [
|
||||
`[cw-callback] resource=${resource}`,
|
||||
`action=${String(summary?.action ?? "-")}`,
|
||||
`type=${String(summary?.type ?? "-")}`,
|
||||
`id=${String(summary?.id ?? "-")}`,
|
||||
`by=${String(summary?.entityUpdatedBy ?? summary?.memberId ?? "-")}`,
|
||||
`requestId=${String(headerSummary.callbackId ?? "-")}`,
|
||||
`status=${String(summary?.entityStatus ?? "-")}`,
|
||||
`summary=${String(summary?.entitySummary ?? "-")}`,
|
||||
].join(" ");
|
||||
console.log(line);
|
||||
|
||||
const response = apiResponse.successful("CW callback received.", {
|
||||
resource,
|
||||
secretValidated: Boolean(expectedSecret),
|
||||
summary,
|
||||
headers,
|
||||
headerSummary,
|
||||
bodyParsed: parsedBodyExpanded,
|
||||
receivedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { default as callback } from "./callback";
|
||||
|
||||
export { callback };
|
||||
@@ -3,6 +3,7 @@ import { procurement } from "../../../managers/procurement";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/procurement/items/:identifier */
|
||||
export default createRoute(
|
||||
@@ -14,9 +15,15 @@ export default createRoute(
|
||||
|
||||
const item = await procurement.fetchItem(identifier);
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
item.toJson({ includeLinkedItems }),
|
||||
"obj.catalogItem",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Catalog item fetched successfully!",
|
||||
item.toJson({ includeLinkedItems }),
|
||||
gatedData,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { procurement } from "../../../managers/procurement";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/procurement/items/:identifier/linked */
|
||||
export default createRoute(
|
||||
@@ -14,9 +15,15 @@ export default createRoute(
|
||||
|
||||
const linkedItems = item.getLinkedItems().map((linked) => linked.toJson());
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
linkedItems.map((linked) =>
|
||||
processObjectValuePerms(linked, "obj.catalogItem", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Linked catalog items fetched successfully!",
|
||||
linkedItems,
|
||||
gatedData,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import {
|
||||
serializeCategoryTree,
|
||||
serializeEcosystemTree,
|
||||
} from "../../modules/catalog-categories/catalogCategories";
|
||||
|
||||
/* /v1/procurement/categories */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/categories"],
|
||||
async (c) => {
|
||||
const categories = serializeCategoryTree();
|
||||
const ecosystems = serializeEcosystemTree();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Category and ecosystem data fetched successfully!",
|
||||
{ categories, ecosystems },
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
|
||||
);
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { procurement } from "../../managers/procurement";
|
||||
import { procurement, CatalogFilterOpts } from "../../managers/procurement";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/procurement/items */
|
||||
export default createRoute(
|
||||
@@ -14,17 +15,53 @@ export default createRoute(
|
||||
const search = c.req.query("search") as string;
|
||||
const includeInactive = c.req.query("includeInactive") === "true";
|
||||
|
||||
const data = search
|
||||
? await procurement.search(search, page, rpp, { includeInactive })
|
||||
: await procurement.fetchPages(page, rpp, { includeInactive });
|
||||
// Category / filter params
|
||||
const category = c.req.query("category") as string | undefined;
|
||||
const subcategory = c.req.query("subcategory") as string | undefined;
|
||||
const group = c.req.query("group") as string | undefined;
|
||||
const manufacturer = c.req.query("manufacturer") as string | undefined;
|
||||
const ecosystem = c.req.query("ecosystem") as string | undefined;
|
||||
const inStock = c.req.query("inStock") === "true" ? true : undefined;
|
||||
const minPrice = c.req.query("minPrice")
|
||||
? Number(c.req.query("minPrice"))
|
||||
: undefined;
|
||||
const maxPrice = c.req.query("maxPrice")
|
||||
? Number(c.req.query("maxPrice"))
|
||||
: undefined;
|
||||
|
||||
const totalRecords = await procurement.count({
|
||||
activeOnly: !includeInactive,
|
||||
});
|
||||
const filterOpts: CatalogFilterOpts = {
|
||||
includeInactive,
|
||||
category,
|
||||
subcategory,
|
||||
group,
|
||||
manufacturer,
|
||||
ecosystem,
|
||||
inStock,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
};
|
||||
|
||||
const data = search
|
||||
? await procurement.search(search, page, rpp, filterOpts)
|
||||
: await procurement.fetchPages(page, rpp, filterOpts);
|
||||
|
||||
const totalRecords = search
|
||||
? await procurement.countSearch(search, filterOpts)
|
||||
: await procurement.count(filterOpts);
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
data.map((item) =>
|
||||
processObjectValuePerms(
|
||||
item.toJson(),
|
||||
"obj.catalogItem",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Catalog items fetched successfully!",
|
||||
data.map((item) => item.toJson()),
|
||||
gatedData,
|
||||
{
|
||||
pagination: {
|
||||
previousPage: page <= 1 ? null : page - 1,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { procurement } from "../../managers/procurement";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
|
||||
/* /v1/procurement/filters */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/filters"],
|
||||
async (c) => {
|
||||
const category = c.req.query("category") as string | undefined;
|
||||
const subcategory = c.req.query("subcategory") as string | undefined;
|
||||
const includeInactive = c.req.query("includeInactive") === "true";
|
||||
|
||||
const filterOpts = { category, subcategory, includeInactive };
|
||||
|
||||
const [categories, subcategories, manufacturers] = await Promise.all([
|
||||
procurement.fetchDistinctValues("category", filterOpts),
|
||||
procurement.fetchDistinctValues("subcategory", filterOpts),
|
||||
procurement.fetchDistinctValues("manufacturer", filterOpts),
|
||||
]);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Available filter values fetched successfully!",
|
||||
{ categories, subcategories, manufacturers },
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
|
||||
);
|
||||
@@ -5,5 +5,17 @@ import { default as link } from "./[id]/link";
|
||||
import { default as unlink } from "./[id]/unlink";
|
||||
import { default as fetchLinked } from "./[id]/fetchLinked";
|
||||
import { default as count } from "./count";
|
||||
import { default as categories } from "./categories";
|
||||
import { default as filters } from "./filters";
|
||||
|
||||
export { count, fetch, fetchAll, fetchLinked, link, refreshInventory, unlink };
|
||||
export {
|
||||
categories,
|
||||
count,
|
||||
fetch,
|
||||
fetchAll,
|
||||
fetchLinked,
|
||||
filters,
|
||||
link,
|
||||
refreshInventory,
|
||||
unlink,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { roles } from "../../managers/roles";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/role/:identifier */
|
||||
export default createRoute(
|
||||
@@ -15,9 +16,15 @@ export default createRoute(
|
||||
|
||||
const role = await roles.fetch(identifier);
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
role.toJson({ viewPermissions: true }),
|
||||
"obj.role",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Role Fetched Successfully!",
|
||||
role.toJson({ viewPermissions: true }),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { roles } from "../../managers/roles";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/role */
|
||||
export default createRoute(
|
||||
@@ -13,13 +14,19 @@ export default createRoute(
|
||||
async (c) => {
|
||||
const allRoles = await roles.fetchAllRoles();
|
||||
|
||||
const rolesArray = allRoles.map((role) =>
|
||||
role.toJson({ viewPermissions: true }),
|
||||
const gatedData = await Promise.all(
|
||||
allRoles.map((role) =>
|
||||
processObjectValuePerms(
|
||||
role.toJson({ viewPermissions: true }),
|
||||
"obj.role",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Roles Fetched Successfully!",
|
||||
rolesArray,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { roles } from "../../managers/roles";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/role/:identifier/users */
|
||||
export default createRoute(
|
||||
@@ -16,11 +17,15 @@ export default createRoute(
|
||||
const role = await roles.fetch(identifier);
|
||||
const users = role.getUsers();
|
||||
|
||||
const usersArray = users.map((user) => user.toJson());
|
||||
const gatedData = await Promise.all(
|
||||
users.map((user) =>
|
||||
processObjectValuePerms(user.toJson(), "obj.user", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Users Fetched Successfully!",
|
||||
usersArray,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import * as cwRoutes from "../cw";
|
||||
|
||||
const cwRouter = new Hono();
|
||||
Object.values(cwRoutes).map((r) => cwRouter.route("/", r));
|
||||
|
||||
export default cwRouter;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/contacts */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/contacts"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
|
||||
const contacts = await opportunityCw.fetchContacts(item.cwOpportunityId);
|
||||
|
||||
const data = contacts.map((ct) => ({
|
||||
id: ct.id,
|
||||
contact: ct.contact ? { id: ct.contact.id, name: ct.contact.name } : null,
|
||||
company: ct.company
|
||||
? {
|
||||
id: ct.company.id,
|
||||
identifier: ct.company.identifier,
|
||||
name: ct.company.name,
|
||||
}
|
||||
: null,
|
||||
role: ct.role ? { id: ct.role.id, name: ct.role.name } : null,
|
||||
notes: ct.notes,
|
||||
referralFlag: ct.referralFlag,
|
||||
}));
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity contacts fetched successfully!",
|
||||
data,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
+163
-4
@@ -3,21 +3,180 @@ import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { prisma } from "../../../constants";
|
||||
import { computeSubResourceCacheTTL } from "../../../modules/algorithms/computeSubResourceCacheTTL";
|
||||
import { computeProductsCacheTTL } from "../../../modules/algorithms/computeProductsCacheTTL";
|
||||
import {
|
||||
getCachedSite,
|
||||
getCachedNotes,
|
||||
getCachedContacts,
|
||||
getCachedProducts,
|
||||
fetchAndCacheNotes,
|
||||
fetchAndCacheContacts,
|
||||
fetchAndCacheProducts,
|
||||
fetchAndCacheSite,
|
||||
} from "../../../modules/cache/opportunityCache";
|
||||
import { generatedQuotes } from "../../../managers/generatedQuotes";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier */
|
||||
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const includeParam = c.req.query("include") ?? "";
|
||||
const includes = new Set(
|
||||
includeParam
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
// ── Quick DB lookup (≈3ms) to get cwOpportunityId for pre-warming ──
|
||||
const isNumeric = /^\d+$/.test(identifier);
|
||||
const dbRecord = await prisma.opportunity.findFirst({
|
||||
where: isNumeric
|
||||
? { cwOpportunityId: Number(identifier) }
|
||||
: { id: identifier },
|
||||
select: {
|
||||
cwOpportunityId: true,
|
||||
companyCwId: true,
|
||||
siteCwId: true,
|
||||
closedFlag: true,
|
||||
closedDate: true,
|
||||
expectedCloseDate: true,
|
||||
cwLastUpdated: true,
|
||||
statusCwId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbRecord) {
|
||||
throw new GenericError({
|
||||
message: "Opportunity not found",
|
||||
name: "OpportunityNotFound",
|
||||
cause: `No opportunity exists with identifier '${identifier}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Compute TTLs from DB state
|
||||
const subTtl = computeSubResourceCacheTTL({
|
||||
closedFlag: dbRecord.closedFlag,
|
||||
closedDate: dbRecord.closedDate,
|
||||
expectedCloseDate: dbRecord.expectedCloseDate,
|
||||
lastUpdated: dbRecord.cwLastUpdated,
|
||||
});
|
||||
const prodTtl = computeProductsCacheTTL({
|
||||
closedFlag: dbRecord.closedFlag,
|
||||
closedDate: dbRecord.closedDate,
|
||||
expectedCloseDate: dbRecord.expectedCloseDate,
|
||||
lastUpdated: dbRecord.cwLastUpdated,
|
||||
statusCwId: dbRecord.statusCwId,
|
||||
});
|
||||
|
||||
// ── Pre-warm sub-resources only on cache miss ───────────────────────
|
||||
// Check Redis first — if the background refresh has kept the keys warm,
|
||||
// skip the CW calls entirely. Only fetch-and-cache on a miss.
|
||||
const cwOppId = dbRecord.cwOpportunityId;
|
||||
const _ignoreErrors = (p: Promise<any>) => p.catch(() => {});
|
||||
|
||||
const prewarmPromises: Promise<any>[] = [];
|
||||
if (dbRecord.companyCwId && dbRecord.siteCwId) {
|
||||
const compId = dbRecord.companyCwId,
|
||||
siteId = dbRecord.siteCwId;
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedSite(compId, siteId).then(
|
||||
(c) => c ?? fetchAndCacheSite(compId, siteId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (includes.has("notes") && subTtl)
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedNotes(cwOppId).then(
|
||||
(c) => c ?? fetchAndCacheNotes(cwOppId, subTtl),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (includes.has("contacts") && subTtl)
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedContacts(cwOppId).then(
|
||||
(c) => c ?? fetchAndCacheContacts(cwOppId, subTtl),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (includes.has("products") && prodTtl)
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedProducts(cwOppId).then(
|
||||
(c) => c ?? fetchAndCacheProducts(cwOppId, prodTtl),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// fetchItem runs its own CW calls (opp, activities, company) —
|
||||
// these execute concurrently with the sub-resource pre-warming above.
|
||||
const [item] = await Promise.all([
|
||||
opportunities.fetchItem(identifier),
|
||||
...prewarmPromises,
|
||||
]);
|
||||
|
||||
// Sub-resources now hit warm Redis cache (near-instant)
|
||||
const subResourcePromises: Record<string, Promise<any>> = {
|
||||
_site: item.fetchSite(),
|
||||
};
|
||||
if (includes.has("notes")) {
|
||||
subResourcePromises.notes = item.fetchNotes();
|
||||
}
|
||||
if (includes.has("contacts")) {
|
||||
subResourcePromises.contacts = item.fetchContacts();
|
||||
}
|
||||
if (includes.has("products")) {
|
||||
subResourcePromises.products = item
|
||||
.fetchProducts()
|
||||
.then((products) => products.map((p) => p.toJson()));
|
||||
}
|
||||
if (includes.has("quotes")) {
|
||||
subResourcePromises.quotes = generatedQuotes
|
||||
.fetchByOpportunity(item.id)
|
||||
.then((quotes) => quotes.map((q) => q.toJson()));
|
||||
}
|
||||
|
||||
const keys = Object.keys(subResourcePromises);
|
||||
const results = await Promise.all(keys.map((k) => subResourcePromises[k]));
|
||||
|
||||
// Apply toJson after site is hydrated (side-effect from fetchSite)
|
||||
const gatedData = await processObjectValuePerms(
|
||||
item.toJson(),
|
||||
"obj.opportunity",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const originalOpportunityNoteText = (gatedData as any).notes;
|
||||
|
||||
// Attach sub-resources (skip the internal _site key)
|
||||
keys.forEach((k, i) => {
|
||||
if (k !== "_site") {
|
||||
(gatedData as any)[k] = results[i];
|
||||
}
|
||||
});
|
||||
|
||||
if (includes.has("notes")) {
|
||||
(gatedData as any).opportunityNoteText =
|
||||
typeof originalOpportunityNoteText === "string"
|
||||
? originalOpportunityNoteText
|
||||
: null;
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity fetched successfully!",
|
||||
item.toJson(),
|
||||
gatedData,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/forecasts */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/forecasts"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
|
||||
const forecasts = await opportunityCw.fetchForecasts(item.cwOpportunityId);
|
||||
|
||||
const data = forecasts.map((f) => ({
|
||||
id: f.id,
|
||||
forecastType: f.forecastType,
|
||||
forecastMonth: f.forecastMonth,
|
||||
revenue: f.revenue,
|
||||
cost: f.cost,
|
||||
forecastPercentage: f.forecastPercentage,
|
||||
status: f.status ? { id: f.status.id, name: f.status.name } : null,
|
||||
includedFlag: f.includedFlag,
|
||||
linkedFlag: f.linkedFlag,
|
||||
recurringFlag: f.recurringFlag,
|
||||
}));
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity forecasts fetched successfully!",
|
||||
data,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -1,34 +0,0 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/notes */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/notes"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
|
||||
const notes = await opportunityCw.fetchNotes(item.cwOpportunityId);
|
||||
|
||||
const data = notes.map((n) => ({
|
||||
id: n.id,
|
||||
text: n.text,
|
||||
type: n.type ? { id: n.type.id, name: n.type.name } : null,
|
||||
flagged: n.flagged,
|
||||
enteredBy: n.enteredBy,
|
||||
}));
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity notes fetched successfully!",
|
||||
data,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -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"] }),
|
||||
);
|
||||
+50
-8
@@ -1,9 +1,51 @@
|
||||
import { default as fetchAll } from "./fetchAll";
|
||||
import { default as count } from "./count";
|
||||
import { default as fetch } from "./[id]/fetch";
|
||||
import { default as refresh } from "./[id]/refresh";
|
||||
import { default as forecasts } from "./[id]/forecasts";
|
||||
import { default as notes } from "./[id]/notes";
|
||||
import { default as contacts } from "./[id]/contacts";
|
||||
import { default as fetchAll } from "./opportunities/fetchAll";
|
||||
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
|
||||
import { default as count } from "./opportunities/count";
|
||||
import { default as fetch } from "./opportunities/[id]/fetch";
|
||||
import { default as refresh } from "./opportunities/[id]/refresh";
|
||||
import { default as products } from "./opportunities/[id]/products/fetchAll";
|
||||
import { default as addProduct } from "./opportunities/[id]/products/add";
|
||||
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
|
||||
import { default as addLabor } from "./opportunities/[id]/products/addLabor";
|
||||
import { default as laborOptions } from "./opportunities/[id]/products/laborOptions";
|
||||
import { default as resequenceProducts } from "./opportunities/[id]/products/resequence";
|
||||
import { default as updateProduct } from "./opportunities/[id]/products/update";
|
||||
import { default as cancelProduct } from "./opportunities/[id]/products/cancel";
|
||||
import { default as notes } from "./opportunities/[id]/notes/fetchAll";
|
||||
import { default as fetchNote } from "./opportunities/[id]/notes/fetch";
|
||||
import { default as createNote } from "./opportunities/[id]/notes/create";
|
||||
import { default as updateNote } from "./opportunities/[id]/notes/update";
|
||||
import { default as deleteNote } from "./opportunities/[id]/notes/delete";
|
||||
import { default as contacts } from "./opportunities/[id]/contacts";
|
||||
import { default as commitQuote } from "./opportunities/[id]/quotes/commit";
|
||||
import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll";
|
||||
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
|
||||
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
|
||||
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
|
||||
|
||||
export { count, fetch, fetchAll, forecasts, notes, contacts, refresh };
|
||||
export {
|
||||
addProduct,
|
||||
addLabor,
|
||||
laborOptions,
|
||||
addSpecialOrderProduct,
|
||||
count,
|
||||
fetch,
|
||||
fetchAll,
|
||||
fetchOpportunityTypes,
|
||||
products,
|
||||
resequenceProducts,
|
||||
updateProduct,
|
||||
cancelProduct,
|
||||
notes,
|
||||
fetchNote,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
contacts,
|
||||
commitQuote,
|
||||
fetchQuotes,
|
||||
previewQuote,
|
||||
downloadQuote,
|
||||
fetchDownloads,
|
||||
refresh,
|
||||
};
|
||||
|
||||
@@ -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"] }),
|
||||
);
|
||||
@@ -0,0 +1,187 @@
|
||||
import { createRoute } from "../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../../modules/permission-utils/processObjectPermissions";
|
||||
import GenericError from "../../../../Errors/GenericError";
|
||||
import { prisma } from "../../../../constants";
|
||||
import { computeSubResourceCacheTTL } from "../../../../modules/algorithms/computeSubResourceCacheTTL";
|
||||
import { computeProductsCacheTTL } from "../../../../modules/algorithms/computeProductsCacheTTL";
|
||||
import {
|
||||
getCachedSite,
|
||||
getCachedNotes,
|
||||
getCachedContacts,
|
||||
getCachedProducts,
|
||||
fetchAndCacheNotes,
|
||||
fetchAndCacheContacts,
|
||||
fetchAndCacheProducts,
|
||||
fetchAndCacheSite,
|
||||
} from "../../../../modules/cache/opportunityCache";
|
||||
import { generatedQuotes } from "../../../../managers/generatedQuotes";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const includeParam = c.req.query("include") ?? "";
|
||||
const includes = new Set(
|
||||
includeParam
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
// ── Quick DB lookup (≈3ms) to get cwOpportunityId for pre-warming ──
|
||||
const isNumeric = /^\d+$/.test(identifier);
|
||||
const dbRecord = await prisma.opportunity.findFirst({
|
||||
where: isNumeric
|
||||
? { cwOpportunityId: Number(identifier) }
|
||||
: { id: identifier },
|
||||
select: {
|
||||
cwOpportunityId: true,
|
||||
companyCwId: true,
|
||||
siteCwId: true,
|
||||
closedFlag: true,
|
||||
closedDate: true,
|
||||
expectedCloseDate: true,
|
||||
cwLastUpdated: true,
|
||||
statusCwId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbRecord) {
|
||||
throw new GenericError({
|
||||
message: "Opportunity not found",
|
||||
name: "OpportunityNotFound",
|
||||
cause: `No opportunity exists with identifier '${identifier}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Compute TTLs from DB state
|
||||
const subTtl = computeSubResourceCacheTTL({
|
||||
closedFlag: dbRecord.closedFlag,
|
||||
closedDate: dbRecord.closedDate,
|
||||
expectedCloseDate: dbRecord.expectedCloseDate,
|
||||
lastUpdated: dbRecord.cwLastUpdated,
|
||||
});
|
||||
const prodTtl = computeProductsCacheTTL({
|
||||
closedFlag: dbRecord.closedFlag,
|
||||
closedDate: dbRecord.closedDate,
|
||||
expectedCloseDate: dbRecord.expectedCloseDate,
|
||||
lastUpdated: dbRecord.cwLastUpdated,
|
||||
statusCwId: dbRecord.statusCwId,
|
||||
});
|
||||
|
||||
// ── Pre-warm sub-resources only on cache miss ───────────────────────
|
||||
// Check Redis first — if the background refresh has kept the keys warm,
|
||||
// skip the CW calls entirely. Only fetch-and-cache on a miss.
|
||||
const cwOppId = dbRecord.cwOpportunityId;
|
||||
const _ignoreErrors = (p: Promise<any>) => p.catch(() => {});
|
||||
|
||||
const prewarmPromises: Promise<any>[] = [];
|
||||
if (dbRecord.companyCwId && dbRecord.siteCwId) {
|
||||
const compId = dbRecord.companyCwId,
|
||||
siteId = dbRecord.siteCwId;
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedSite(compId, siteId).then(
|
||||
(c) => c ?? fetchAndCacheSite(compId, siteId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (includes.has("notes") && subTtl)
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedNotes(cwOppId).then(
|
||||
(c) => c ?? fetchAndCacheNotes(cwOppId, subTtl),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (includes.has("contacts") && subTtl)
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedContacts(cwOppId).then(
|
||||
(c) => c ?? fetchAndCacheContacts(cwOppId, subTtl),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (includes.has("products") && prodTtl)
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedProducts(cwOppId).then(
|
||||
(c) => c ?? fetchAndCacheProducts(cwOppId, prodTtl),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// fetchItem runs its own CW calls (opp, activities, company) —
|
||||
// these execute concurrently with the sub-resource pre-warming above.
|
||||
const [item] = await Promise.all([
|
||||
opportunities.fetchItem(identifier),
|
||||
...prewarmPromises,
|
||||
]);
|
||||
|
||||
// Sub-resources now hit warm Redis cache (near-instant)
|
||||
const subResourcePromises: Record<string, Promise<any>> = {
|
||||
_site: item.fetchSite(),
|
||||
};
|
||||
if (includes.has("notes")) {
|
||||
subResourcePromises.notes = item.fetchNotes();
|
||||
}
|
||||
if (includes.has("contacts")) {
|
||||
subResourcePromises.contacts = item.fetchContacts();
|
||||
}
|
||||
if (includes.has("products")) {
|
||||
subResourcePromises.products = item
|
||||
.fetchProducts()
|
||||
.then((products) => products.map((p) => p.toJson()));
|
||||
}
|
||||
if (includes.has("quotes")) {
|
||||
const includeRegenData = c.req.query("includeRegenData") === "true";
|
||||
const includeRegenParams = c.req.query("includeRegenParams") === "true";
|
||||
subResourcePromises.quotes = generatedQuotes
|
||||
.fetchByOpportunity(item.id)
|
||||
.then((quotes) =>
|
||||
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams })),
|
||||
);
|
||||
}
|
||||
|
||||
const keys = Object.keys(subResourcePromises);
|
||||
const results = await Promise.all(keys.map((k) => subResourcePromises[k]));
|
||||
|
||||
// Apply toJson after site is hydrated (side-effect from fetchSite)
|
||||
const gatedData = await processObjectValuePerms(
|
||||
item.toJson(),
|
||||
"obj.opportunity",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const originalOpportunityNoteText = (gatedData as any).notes;
|
||||
|
||||
// Attach sub-resources (skip the internal _site key)
|
||||
keys.forEach((k, i) => {
|
||||
if (k !== "_site") {
|
||||
(gatedData as any)[k] = results[i];
|
||||
}
|
||||
});
|
||||
|
||||
if (includes.has("notes")) {
|
||||
(gatedData as any).opportunityNoteText =
|
||||
typeof originalOpportunityNoteText === "string"
|
||||
? originalOpportunityNoteText
|
||||
: null;
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity fetched successfully!",
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/notes */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/notes"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const schema = z.object({
|
||||
text: z.string().min(1, "Note text is required"),
|
||||
flagged: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const data = schema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const user = c.get("user");
|
||||
|
||||
const created = await item.addNote(data.text, user.login, {
|
||||
flagged: data.flagged,
|
||||
});
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Opportunity note created successfully!",
|
||||
{
|
||||
id: created.id,
|
||||
text: created.text,
|
||||
type: created.type
|
||||
? { id: created.type.id, name: created.type.name }
|
||||
: null,
|
||||
flagged: created.flagged,
|
||||
enteredBy: await resolveMember(created.enteredBy),
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.note.create"] }),
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
|
||||
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
export default createRoute(
|
||||
"delete",
|
||||
["/opportunities/:identifier/notes/:noteId"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const noteId = Number(c.req.param("noteId"));
|
||||
|
||||
if (isNaN(noteId))
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidNoteId",
|
||||
message: "Note ID must be a number",
|
||||
});
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
await item.deleteNote(noteId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity note deleted successfully!",
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.note.delete"] }),
|
||||
);
|
||||
@@ -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"] }),
|
||||
);
|
||||
@@ -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"] }),
|
||||
);
|
||||
@@ -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"] }),
|
||||
);
|
||||
@@ -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"] }),
|
||||
);
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { procurement } from "../../../../../managers/procurement";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
const LABOR_DEFAULT_RATE = {
|
||||
corporate: 100,
|
||||
residential: 85,
|
||||
} as const;
|
||||
|
||||
const roundMoney = (value: number) => Math.round(value * 100) / 100;
|
||||
|
||||
const addLaborSchema = z
|
||||
.object({
|
||||
laborStyle: z.enum(["field", "tech"]),
|
||||
customerType: z.enum(["corporate", "residential"]).optional(),
|
||||
hours: z.number().positive().optional(),
|
||||
rate: z.number().min(0).optional(),
|
||||
ppu: z.number().min(0).optional(),
|
||||
cpu: z.number().min(0).optional(),
|
||||
taxable: z.boolean().optional(),
|
||||
taxableFlag: z.boolean().optional(),
|
||||
description: z.string().min(1).optional(),
|
||||
customerDescription: z.string().min(1).optional(),
|
||||
procurementNotes: z.string().optional(),
|
||||
productNarrative: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/products/labor */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/products/labor"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
const input = addLaborSchema.parse(body);
|
||||
|
||||
const laborCatalog = await procurement.fetchLaborCatalogItems();
|
||||
const selectedCatalog =
|
||||
input.laborStyle === "tech" ? laborCatalog.tech : laborCatalog.field;
|
||||
|
||||
const customerType = input.customerType ?? "corporate";
|
||||
const defaultRate = LABOR_DEFAULT_RATE[customerType];
|
||||
const quantity = input.hours ?? 1;
|
||||
const ppu = input.ppu ?? input.rate ?? defaultRate;
|
||||
const cpu = input.cpu ?? roundMoney(ppu * 0.5);
|
||||
const taxableFlag =
|
||||
input.taxable ?? input.taxableFlag ?? selectedCatalog.salesTaxable;
|
||||
|
||||
const makeCustomField = (
|
||||
caption: string,
|
||||
value: string,
|
||||
fieldId: number,
|
||||
) => ({
|
||||
id: fieldId,
|
||||
caption,
|
||||
type: "Text",
|
||||
entryMethod: "EntryField",
|
||||
value,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
...(input.procurementNotes || input.productNarrative
|
||||
? {
|
||||
customFields: [
|
||||
...(input.procurementNotes
|
||||
? [
|
||||
makeCustomField(
|
||||
"Procurement Notes",
|
||||
input.procurementNotes,
|
||||
29,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(input.productNarrative
|
||||
? [
|
||||
makeCustomField(
|
||||
"Product Narrative",
|
||||
input.productNarrative,
|
||||
46,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
catalogItem: { id: selectedCatalog.cwCatalogId },
|
||||
description:
|
||||
input.description ??
|
||||
selectedCatalog.name ??
|
||||
selectedCatalog.identifier ??
|
||||
`${input.laborStyle.toUpperCase()} Labor`,
|
||||
customerDescription: input.customerDescription,
|
||||
quantity,
|
||||
price: ppu,
|
||||
cost: cpu,
|
||||
taxableFlag,
|
||||
dropshipFlag: false,
|
||||
billableOption: "Billable",
|
||||
};
|
||||
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
const [created] = await opportunity.addProcurementProducts(payload);
|
||||
|
||||
const fields = Array.isArray(created?.customFields)
|
||||
? created.customFields
|
||||
: [];
|
||||
const procurementNotes =
|
||||
fields.find((f: any) => f?.id === 29)?.value ?? null;
|
||||
const productNarrative =
|
||||
fields.find((f: any) => f?.id === 46)?.value ?? null;
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Labor added to opportunity successfully!",
|
||||
{
|
||||
id: created?.id ?? null,
|
||||
forecastDetailId: created?.forecastDetailId ?? null,
|
||||
laborStyle: input.laborStyle,
|
||||
customerType,
|
||||
catalogItem: {
|
||||
id: selectedCatalog.cwCatalogId,
|
||||
identifier: selectedCatalog.identifier,
|
||||
name: selectedCatalog.name,
|
||||
},
|
||||
description: created?.description ?? payload.description,
|
||||
customerDescription:
|
||||
created?.customerDescription ?? input.customerDescription ?? null,
|
||||
quantity: created?.quantity ?? quantity,
|
||||
rate: ppu,
|
||||
ppu,
|
||||
cpu,
|
||||
revenue: roundMoney((created?.quantity ?? quantity) * ppu),
|
||||
cost: roundMoney((created?.quantity ?? quantity) * cpu),
|
||||
taxableFlag: created?.taxableFlag ?? taxableFlag,
|
||||
procurementNotes,
|
||||
productNarrative,
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { procurement } from "../../../../../managers/procurement";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
const specialOrderItemSchema = z
|
||||
.object({
|
||||
desc: z.string().min(1),
|
||||
customerDesc: z.string().min(1).optional(),
|
||||
qty: z.number().positive().optional(),
|
||||
price: z.number(),
|
||||
cost: z.number().optional(),
|
||||
taxable: z.boolean().optional(),
|
||||
taxableFlag: z.boolean().optional(),
|
||||
procurementNotes: z.string().optional(),
|
||||
productNarrative: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const addSpecialOrderSchema = z.union([
|
||||
specialOrderItemSchema,
|
||||
z
|
||||
.array(specialOrderItemSchema)
|
||||
.min(1, "At least one special-order product is required"),
|
||||
]);
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/products/special-order */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/products/special-order"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const validated = addSpecialOrderSchema.parse(body);
|
||||
const inputItems = Array.isArray(validated) ? validated : [validated];
|
||||
const specialOrderCatalogItem =
|
||||
await procurement.fetchItem("SPECIAL ORDER");
|
||||
|
||||
const makeCustomField = (
|
||||
caption: string,
|
||||
value: string,
|
||||
fieldId: number,
|
||||
) => ({
|
||||
id: fieldId,
|
||||
caption,
|
||||
type: "Text",
|
||||
entryMethod: "EntryField",
|
||||
value,
|
||||
});
|
||||
|
||||
const normalizedItems = inputItems.map((item) => ({
|
||||
...(item.procurementNotes || item.productNarrative
|
||||
? {
|
||||
customFields: [
|
||||
...(item.procurementNotes
|
||||
? [
|
||||
makeCustomField(
|
||||
"Procurement Notes",
|
||||
item.procurementNotes,
|
||||
29,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(item.productNarrative
|
||||
? [
|
||||
makeCustomField(
|
||||
"Product Narrative",
|
||||
item.productNarrative,
|
||||
46,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
catalogItem: { id: specialOrderCatalogItem.cwCatalogId },
|
||||
description: item.desc,
|
||||
customerDescription: item.customerDesc,
|
||||
quantity: item.qty ?? 1,
|
||||
price: item.price,
|
||||
cost: item.cost,
|
||||
taxableFlag:
|
||||
item.taxable ??
|
||||
item.taxableFlag ??
|
||||
specialOrderCatalogItem.salesTaxable,
|
||||
dropshipFlag: false,
|
||||
billableOption: "Billable",
|
||||
}));
|
||||
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
const created = await opportunity.addProcurementProducts(normalizedItems);
|
||||
|
||||
const serialized = created.map((item: any) => {
|
||||
const fields = Array.isArray(item?.customFields) ? item.customFields : [];
|
||||
const procurementNotes =
|
||||
fields.find((f: any) => f?.id === 29)?.value ?? null;
|
||||
const productNarrative =
|
||||
fields.find((f: any) => f?.id === 46)?.value ?? null;
|
||||
|
||||
return {
|
||||
id: item?.id ?? null,
|
||||
forecastDetailId: item?.forecastDetailId ?? null,
|
||||
description: item?.description ?? null,
|
||||
productDescription: item?.description ?? null,
|
||||
customerDescription: item?.customerDescription ?? null,
|
||||
quantity: item?.quantity ?? null,
|
||||
price: item?.price ?? null,
|
||||
revenue: item?.price ?? null,
|
||||
cost: item?.cost ?? null,
|
||||
taxableFlag: item?.taxableFlag ?? null,
|
||||
specialOrderFlag: item?.specialOrderFlag ?? null,
|
||||
procurementNotes,
|
||||
productNarrative,
|
||||
};
|
||||
});
|
||||
|
||||
const isBatch = Array.isArray(body);
|
||||
const response = apiResponse.created(
|
||||
isBatch
|
||||
? `${created.length} special-order product(s) added successfully!`
|
||||
: "Special-order product added successfully!",
|
||||
isBatch ? serialized : serialized[0]!,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["sales.opportunity.product.add.specialOrder"],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
import { z } from "zod";
|
||||
|
||||
const cancelProductSchema = z
|
||||
.object({
|
||||
quantityCancelled: z.number().int().min(0),
|
||||
cancellationReason: z.string().nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/cancel */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/:identifier/products/:productId/cancel"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const productId = Number(c.req.param("productId"));
|
||||
const body = await c.req.json();
|
||||
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidProductId",
|
||||
message: "productId must be a positive integer",
|
||||
});
|
||||
}
|
||||
|
||||
const input = cancelProductSchema.parse(body);
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
|
||||
const products = await opportunity.fetchProducts();
|
||||
const product = products.find((item) => item.cwForecastId === productId);
|
||||
|
||||
if (!product) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${productId} not found on opportunity`,
|
||||
});
|
||||
}
|
||||
|
||||
const quantity = product.quantity ?? 0;
|
||||
if (input.quantityCancelled > quantity) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidCancelledQuantity",
|
||||
message: `quantityCancelled cannot exceed product quantity (${quantity})`,
|
||||
});
|
||||
}
|
||||
|
||||
await opportunity.setProductCancellation(productId, {
|
||||
quantityCancelled: input.quantityCancelled,
|
||||
cancellationReason: input.cancellationReason,
|
||||
});
|
||||
|
||||
const refreshedProducts = await opportunity.fetchProducts({ fresh: true });
|
||||
const updated = refreshedProducts.find(
|
||||
(item) => item.cwForecastId === productId,
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${productId} not found on opportunity`,
|
||||
});
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
input.quantityCancelled === 0
|
||||
? "Product uncancelled successfully!"
|
||||
: "Product cancellation updated successfully!",
|
||||
updated.toJson(),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/products */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/products"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
|
||||
const data = await item.fetchProducts();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity products fetched successfully!",
|
||||
data.map((p) => p.toJson()),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { procurement } from "../../../../../managers/procurement";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/products/labor/options */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/products/labor/options"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
|
||||
await opportunities.fetchRecord(identifier);
|
||||
|
||||
const laborCatalog = await procurement.fetchLaborCatalogItems();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Labor product options fetched successfully!",
|
||||
{
|
||||
defaults: {
|
||||
customerType: "corporate",
|
||||
rates: {
|
||||
corporate: 100,
|
||||
residential: 85,
|
||||
},
|
||||
cpuMultiplier: 0.5,
|
||||
quantity: 1,
|
||||
},
|
||||
options: {
|
||||
field: {
|
||||
cwCatalogId: laborCatalog.field.cwCatalogId,
|
||||
identifier: laborCatalog.field.identifier,
|
||||
name: laborCatalog.field.name,
|
||||
taxableFlag: laborCatalog.field.salesTaxable,
|
||||
},
|
||||
tech: {
|
||||
cwCatalogId: laborCatalog.tech.cwCatalogId,
|
||||
identifier: laborCatalog.tech.identifier,
|
||||
name: laborCatalog.tech.name,
|
||||
taxableFlag: laborCatalog.tech.salesTaxable,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/:identifier/products/sequence"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const schema = z.object({
|
||||
orderedIds: z
|
||||
.array(z.number().int().positive())
|
||||
.min(1, "At least one forecast item ID is required"),
|
||||
});
|
||||
|
||||
const { orderedIds } = schema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const updated = await item.resequenceProducts(orderedIds);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Product sequence updated successfully!",
|
||||
{
|
||||
products: updated.map((p) => p.toJson()),
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
|
||||
);
|
||||
@@ -0,0 +1,242 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
import { z } from "zod";
|
||||
|
||||
const PRODUCT_NARRATIVE_FIELD_ID = 46;
|
||||
const PROCUREMENT_NOTES_FIELD_ID = 29;
|
||||
|
||||
const updateProductSchema = z
|
||||
.object({
|
||||
productDescription: z.string().min(1).optional(),
|
||||
quantity: z.number().positive().optional(),
|
||||
unitPrice: z.number().min(0).optional(),
|
||||
unitCost: z.number().min(0).optional(),
|
||||
customerDescription: z.string().nullable().optional(),
|
||||
productNarrative: z.string().nullable().optional(),
|
||||
procurementNotes: z.string().nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(value) =>
|
||||
Object.values(value).some((item) => item !== undefined && item !== null),
|
||||
"At least one editable field is required",
|
||||
);
|
||||
|
||||
const upsertCustomTextField = (
|
||||
fields: Array<Record<string, unknown>>,
|
||||
fieldId: number,
|
||||
caption: string,
|
||||
value: string,
|
||||
) => {
|
||||
const next = [...fields];
|
||||
const idx = next.findIndex((f) => Number(f.id) === fieldId);
|
||||
|
||||
const field = {
|
||||
id: fieldId,
|
||||
caption,
|
||||
type: "Text",
|
||||
entryMethod: "EntryField",
|
||||
value,
|
||||
};
|
||||
|
||||
if (idx === -1) {
|
||||
next.push(field);
|
||||
return next;
|
||||
}
|
||||
|
||||
next[idx] = {
|
||||
...next[idx],
|
||||
...field,
|
||||
};
|
||||
return next;
|
||||
};
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/edit */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/:identifier/products/:productId/edit"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const productId = Number(c.req.param("productId"));
|
||||
const body = await c.req.json();
|
||||
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidProductId",
|
||||
message: "productId must be a positive integer",
|
||||
});
|
||||
}
|
||||
|
||||
const input = updateProductSchema.parse(body);
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
|
||||
const forecastItems = await opportunity.fetchProducts();
|
||||
const forecastItem = forecastItems.find(
|
||||
(item) => item.cwForecastId === productId,
|
||||
);
|
||||
|
||||
if (!forecastItem) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${productId} not found on opportunity`,
|
||||
});
|
||||
}
|
||||
|
||||
const forecastJson = forecastItem.toJson();
|
||||
const effectiveQuantity = input.quantity ?? forecastJson.quantity ?? 1;
|
||||
|
||||
const forecastPatch: Record<string, unknown> = {};
|
||||
if (input.productDescription !== undefined) {
|
||||
forecastPatch.productDescription = input.productDescription;
|
||||
}
|
||||
if (input.quantity !== undefined) {
|
||||
forecastPatch.quantity = input.quantity;
|
||||
}
|
||||
if (
|
||||
input.customerDescription !== undefined &&
|
||||
input.customerDescription !== null
|
||||
) {
|
||||
forecastPatch.customerDescription = input.customerDescription;
|
||||
}
|
||||
if (input.unitPrice !== undefined) {
|
||||
forecastPatch.revenue = Number(
|
||||
(input.unitPrice * effectiveQuantity).toFixed(2),
|
||||
);
|
||||
}
|
||||
if (input.unitCost !== undefined) {
|
||||
forecastPatch.cost = Number(
|
||||
(input.unitCost * effectiveQuantity).toFixed(2),
|
||||
);
|
||||
}
|
||||
|
||||
const existingProcurement =
|
||||
await opportunity.fetchProcurementProductByForecastItem(productId);
|
||||
|
||||
if (
|
||||
(input.productNarrative !== undefined ||
|
||||
input.procurementNotes !== undefined) &&
|
||||
!existingProcurement
|
||||
) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "ProcurementLinkRequired",
|
||||
message:
|
||||
"Product Narrative and Procurement Notes can only be updated on products linked to a procurement record",
|
||||
});
|
||||
}
|
||||
|
||||
let updatedProcurement = existingProcurement;
|
||||
if (existingProcurement) {
|
||||
const procurementPatch: Record<string, unknown> = {};
|
||||
if (input.productDescription !== undefined) {
|
||||
procurementPatch.description = input.productDescription;
|
||||
}
|
||||
if (input.quantity !== undefined) {
|
||||
procurementPatch.quantity = input.quantity;
|
||||
}
|
||||
if (input.unitPrice !== undefined) {
|
||||
procurementPatch.price = input.unitPrice;
|
||||
}
|
||||
if (input.unitCost !== undefined) {
|
||||
procurementPatch.cost = input.unitCost;
|
||||
}
|
||||
if (
|
||||
input.customerDescription !== undefined &&
|
||||
input.customerDescription !== null
|
||||
) {
|
||||
procurementPatch.customerDescription = input.customerDescription;
|
||||
}
|
||||
|
||||
const existingFields = Array.isArray(existingProcurement.customFields)
|
||||
? existingProcurement.customFields.map((field) => ({ ...field }))
|
||||
: [];
|
||||
|
||||
let updatedFields = existingFields as Array<Record<string, unknown>>;
|
||||
if (
|
||||
input.procurementNotes !== undefined &&
|
||||
input.procurementNotes !== null
|
||||
) {
|
||||
updatedFields = upsertCustomTextField(
|
||||
updatedFields,
|
||||
PROCUREMENT_NOTES_FIELD_ID,
|
||||
"Procurement Notes",
|
||||
input.procurementNotes,
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.productNarrative !== undefined &&
|
||||
input.productNarrative !== null
|
||||
) {
|
||||
updatedFields = upsertCustomTextField(
|
||||
updatedFields,
|
||||
PRODUCT_NARRATIVE_FIELD_ID,
|
||||
"Product Narrative",
|
||||
input.productNarrative,
|
||||
);
|
||||
}
|
||||
if (
|
||||
(input.procurementNotes !== undefined &&
|
||||
input.procurementNotes !== null) ||
|
||||
(input.productNarrative !== undefined &&
|
||||
input.productNarrative !== null)
|
||||
) {
|
||||
procurementPatch.customFields = updatedFields;
|
||||
}
|
||||
|
||||
if (Object.keys(procurementPatch).length > 0) {
|
||||
updatedProcurement =
|
||||
await opportunity.updateProcurementProductByForecastItem(
|
||||
productId,
|
||||
procurementPatch,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let updatedForecast = forecastJson;
|
||||
if (Object.keys(forecastPatch).length > 0) {
|
||||
const patched = await opportunity.updateProduct(productId, forecastPatch);
|
||||
updatedForecast = patched.toJson();
|
||||
}
|
||||
|
||||
const updatedFields = Array.isArray(updatedProcurement?.customFields)
|
||||
? updatedProcurement.customFields
|
||||
: [];
|
||||
const procurementNotes =
|
||||
updatedFields.find(
|
||||
(field: any) => field?.id === PROCUREMENT_NOTES_FIELD_ID,
|
||||
)?.value ?? null;
|
||||
const productNarrative =
|
||||
updatedFields.find(
|
||||
(field: any) => field?.id === PRODUCT_NARRATIVE_FIELD_ID,
|
||||
)?.value ?? null;
|
||||
|
||||
const quantity =
|
||||
updatedProcurement?.quantity ?? updatedForecast.quantity ?? null;
|
||||
const unitPrice = updatedProcurement?.price ?? null;
|
||||
const unitCost = updatedProcurement?.cost ?? null;
|
||||
|
||||
const response = apiResponse.successful("Product updated successfully!", {
|
||||
...updatedForecast,
|
||||
productDescription:
|
||||
updatedProcurement?.description ?? updatedForecast.productDescription,
|
||||
customerDescription:
|
||||
updatedProcurement?.customerDescription ??
|
||||
updatedForecast.customerDescription ??
|
||||
null,
|
||||
quantity,
|
||||
unitPrice,
|
||||
unitCost,
|
||||
procurementNotes,
|
||||
productNarrative,
|
||||
});
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
const commitQuoteSchema = z
|
||||
.object({
|
||||
lineItemPricing: z.boolean().optional(),
|
||||
includeQuoteNarrative: z.boolean().optional(),
|
||||
includeItemNarratives: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/quote/commit */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/quote/commit"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json().catch(() => undefined);
|
||||
|
||||
const opts = commitQuoteSchema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const user = c.get("user");
|
||||
|
||||
const quote = await item.commitQuote(opts ?? {}, user);
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Quote committed successfully!",
|
||||
quote.toJson({ includeRegenData: true, includeRegenParams: true }),
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.commit"] }),
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { injectPdfMetadata } from "../../../../../modules/pdf-utils";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
|
||||
const VALID_FETCH_ACTIONS = ["download", "print"] as const;
|
||||
type FetchAction = (typeof VALID_FETCH_ACTIONS)[number];
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/download?fetchAction=download|print */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/quote/:quoteId/download"],
|
||||
async (c) => {
|
||||
const quoteId = c.req.param("quoteId");
|
||||
const user = c.get("user");
|
||||
const fetchAction = c.req.query("fetchAction") as FetchAction | undefined;
|
||||
|
||||
if (!fetchAction || !VALID_FETCH_ACTIONS.includes(fetchAction)) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidFetchAction",
|
||||
message: `Query parameter 'fetchAction' is required and must be one of: ${VALID_FETCH_ACTIONS.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const downloadedAt = new Date().toISOString();
|
||||
|
||||
const quote = await generatedQuotes.recordDownload(quoteId, {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
fetchAction,
|
||||
});
|
||||
|
||||
// Inject download-time metadata into the PDF's document properties
|
||||
const pdfWithMetadata = await injectPdfMetadata(quote.quoteFile, {
|
||||
downloadedAt,
|
||||
downloadedById: user.id,
|
||||
downloadedByName: user.name ?? undefined,
|
||||
downloadedByEmail: user.email ?? undefined,
|
||||
});
|
||||
|
||||
const response = apiResponse.successful("Quote downloaded successfully!", {
|
||||
id: quote.id,
|
||||
quoteFileName: quote.quoteFileName,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: Buffer.from(pdfWithMetadata).toString("base64"),
|
||||
});
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.download"] }),
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/quotes */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/quotes"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const includeRegenData = c.req.query("includeRegenData") === "true";
|
||||
const includeRegenParams = c.req.query("includeRegenParams") === "true";
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const quotes = await generatedQuotes.fetchByOpportunity(item.id);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Committed quotes fetched successfully!",
|
||||
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams })),
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/quotes/downloads */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/quotes/downloads"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
const quotes = await generatedQuotes.fetchByOpportunity(opportunity.id);
|
||||
|
||||
const data = quotes.map((quote) => ({
|
||||
quoteId: quote.id,
|
||||
quoteFileName: quote.quoteFileName,
|
||||
createdById: quote.createdById,
|
||||
createdAt: quote.createdAt,
|
||||
downloads: quote.downloads,
|
||||
}));
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Quote download history fetched successfully!",
|
||||
data,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.fetch_downloads"] }),
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/preview */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/quote/:quoteId/preview"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const quoteId = c.req.param("quoteId");
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const quote = await generatedQuotes.fetch(quoteId);
|
||||
|
||||
const regenData =
|
||||
quote.quoteRegenData && typeof quote.quoteRegenData === "object"
|
||||
? (quote.quoteRegenData as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const options =
|
||||
regenData.options && typeof regenData.options === "object"
|
||||
? (regenData.options as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const creator = await quote.fetchCreatedBy();
|
||||
|
||||
const previewBuffer = await item.generateQuote({
|
||||
lineItemPricing: options.lineItemPricing as boolean | undefined,
|
||||
includeQuoteNarrative: options.includeQuoteNarrative as
|
||||
| boolean
|
||||
| undefined,
|
||||
includeItemNarratives: options.includeItemNarratives as
|
||||
| boolean
|
||||
| undefined,
|
||||
showPreview: true,
|
||||
metadata: {
|
||||
quoteId: quote.id,
|
||||
createdById: quote.createdById ?? undefined,
|
||||
createdByName: creator?.name ?? undefined,
|
||||
createdByEmail: creator?.email ?? undefined,
|
||||
createdAt: quote.createdAt?.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const previewBase64 = Buffer.from(previewBuffer).toString("base64");
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Quote preview generated successfully!",
|
||||
{
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
},
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.preview"] }),
|
||||
);
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../../middleware/authorization";
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/refresh */
|
||||
export default createRoute(
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../managers/opportunities";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/count */
|
||||
export default createRoute(
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../managers/opportunities";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/sales/opportunities */
|
||||
export default createRoute(
|
||||
@@ -18,13 +19,23 @@ export default createRoute(
|
||||
? await opportunities.search(search, page, rpp, { includeClosed })
|
||||
: await opportunities.fetchPages(page, rpp, { includeClosed });
|
||||
|
||||
const totalRecords = await opportunities.count({
|
||||
openOnly: !includeClosed,
|
||||
});
|
||||
const totalRecords = search
|
||||
? await opportunities.searchCount(search, { includeClosed })
|
||||
: await opportunities.count({ openOnly: !includeClosed });
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
data.map((item) =>
|
||||
processObjectValuePerms(
|
||||
item.toJson(),
|
||||
"obj.opportunity",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunities fetched successfully!",
|
||||
data.map((item) => item.toJson()),
|
||||
gatedData,
|
||||
{
|
||||
pagination: {
|
||||
previousPage: page <= 1 ? null : page - 1,
|
||||
@@ -57,6 +57,7 @@ v1.route("/permissions", require("./routers/permissionRouter").default);
|
||||
v1.route("/unifi", require("./routers/unifiRouter").default);
|
||||
v1.route("/procurement", require("./routers/procurementRouter").default);
|
||||
v1.route("/sales", require("./routers/salesRouter").default);
|
||||
v1.route("/cw", require("./routers/cwRouter").default);
|
||||
app.route("/v1", v1);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Socket } from "socket.io";
|
||||
import { attachSocketEventPermissions } from "../middleware/authorization";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
|
||||
const LIVE_QUOTE_PREVIEW_PERMISSION = "sales.opportunity.fetch";
|
||||
|
||||
export const registerLiveQuotePreviewHandlers = (socket: Socket) => {
|
||||
attachSocketEventPermissions(socket, {
|
||||
"opp:live_quote_preview": [LIVE_QUOTE_PREVIEW_PERMISSION],
|
||||
});
|
||||
|
||||
const registeredLivePreviewEvents = new Set<string>();
|
||||
|
||||
socket.on(
|
||||
"opp:live_quote_preview",
|
||||
async (
|
||||
payload: { id?: string | number },
|
||||
ack?: (response: { ok: boolean; event?: string; error?: string }) => void,
|
||||
) => {
|
||||
const oppId = payload?.id;
|
||||
const normalizedId =
|
||||
typeof oppId === "string" || typeof oppId === "number"
|
||||
? `${oppId}`
|
||||
: "";
|
||||
|
||||
if (!normalizedId) {
|
||||
if (ack) return ack({ ok: false, error: "Missing opportunity id" });
|
||||
socket.emit("opp:live_quote_preview:error", {
|
||||
message: "Missing opportunity id",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dataEvent = `opp:live_quote_preview:${normalizedId}:data`;
|
||||
const previewEvent = `opp:live_quote_preview:${normalizedId}:preview`;
|
||||
const roomName = `opp:live_quote_preview:${normalizedId}`;
|
||||
|
||||
if (!registeredLivePreviewEvents.has(dataEvent)) {
|
||||
registeredLivePreviewEvents.add(dataEvent);
|
||||
socket.join(roomName);
|
||||
|
||||
socket.on(dataEvent, async (data: any) => {
|
||||
socket.to(roomName).emit(dataEvent, data);
|
||||
|
||||
try {
|
||||
const opportunity = await opportunities.fetchRecord(normalizedId);
|
||||
const opts =
|
||||
data?.options && typeof data.options === "object"
|
||||
? data.options
|
||||
: data;
|
||||
|
||||
const previewBuffer = await opportunity.generateQuote({
|
||||
lineItemPricing: opts?.lineItemPricing,
|
||||
includeQuoteNarrative: opts?.includeQuoteNarrative,
|
||||
includeItemNarratives: opts?.includeItemNarratives,
|
||||
logoPath: opts?.logoPath,
|
||||
showPreview: true,
|
||||
});
|
||||
|
||||
const previewBase64 = Buffer.from(previewBuffer).toString("base64");
|
||||
|
||||
socket.to(roomName).emit(previewEvent, {
|
||||
id: normalizedId,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
});
|
||||
|
||||
socket.to(roomName).emit(dataEvent, {
|
||||
id: normalizedId,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
});
|
||||
|
||||
socket.emit(previewEvent, {
|
||||
id: normalizedId,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
});
|
||||
|
||||
socket.emit(dataEvent, {
|
||||
id: normalizedId,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
});
|
||||
} catch (err: any) {
|
||||
socket.emit("opp:live_quote_preview:error", {
|
||||
message: err?.message ?? "Failed to generate live quote preview",
|
||||
id: normalizedId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (ack) return ack({ ok: true, event: dataEvent });
|
||||
|
||||
socket.emit("opp:live_quote_preview:ready", {
|
||||
id: normalizedId,
|
||||
event: dataEvent,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { setupSecureNamespace } from "./secure";
|
||||
|
||||
export const setupSockets = () => {
|
||||
setupSecureNamespace();
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Socket } from "socket.io";
|
||||
import UserController from "../../../controllers/UserController";
|
||||
|
||||
type SecureSocket = Socket & {
|
||||
data: {
|
||||
user?: UserController;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export const attachSocketEventPermissions = (
|
||||
socket: Socket,
|
||||
eventPermissions: Record<string, string[]>,
|
||||
): boolean => {
|
||||
const user = (socket.data?.user as UserController | undefined) ?? undefined;
|
||||
if (!user) return false;
|
||||
|
||||
socket.use(async (packet, packetNext) => {
|
||||
const eventName = packet[0];
|
||||
|
||||
if (typeof eventName !== "string") return packetNext();
|
||||
|
||||
const eventRequiredPermissions = eventPermissions[eventName] ?? [];
|
||||
if (eventRequiredPermissions.length === 0) return packetNext();
|
||||
|
||||
const eventChecks = await Promise.all(
|
||||
eventRequiredPermissions.map((permission) =>
|
||||
user.hasPermission(permission),
|
||||
),
|
||||
);
|
||||
|
||||
if (eventChecks.includes(false)) {
|
||||
return packetNext(new Error("Forbidden: insufficient permissions"));
|
||||
}
|
||||
|
||||
return packetNext();
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const socketAuthMiddleware = (permParams?: {
|
||||
permissions?: string[];
|
||||
eventPermissions?: Record<string, string[]>;
|
||||
}) => {
|
||||
return async (socket: SecureSocket, next: (err?: Error) => void) => {
|
||||
const user = socket.data.user;
|
||||
if (!user) return next(new Error("Unauthorized"));
|
||||
|
||||
const requiredPermissions = permParams?.permissions ?? [];
|
||||
|
||||
if (requiredPermissions.length > 0) {
|
||||
const permissionChecks = await Promise.all(
|
||||
requiredPermissions.map((permission) => user.hasPermission(permission)),
|
||||
);
|
||||
|
||||
if (permissionChecks.includes(false)) {
|
||||
return next(new Error("Forbidden: insufficient permissions"));
|
||||
}
|
||||
}
|
||||
|
||||
const eventPermissions = permParams?.eventPermissions;
|
||||
|
||||
if (eventPermissions) {
|
||||
const attached = attachSocketEventPermissions(socket, eventPermissions);
|
||||
if (!attached) return next(new Error("Unauthorized"));
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Namespace } from "socket.io";
|
||||
import { io, prisma } from "../../constants";
|
||||
import { sessions } from "../../managers/sessions";
|
||||
import { socketAuthMiddleware } from "./middleware/authorization";
|
||||
import { registerLiveQuotePreviewHandlers } from "./events/liveQuotePreview";
|
||||
|
||||
const SESSION_ENFORCEMENT_INTERVAL_MS = 60 * 1000;
|
||||
const MAX_TIMEOUT_MS = 2_147_483_647;
|
||||
|
||||
const AUTH_HEADER_REGEX =
|
||||
/^(Bearer|Key)\s([a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+)$/;
|
||||
|
||||
const resolveAuthorization = (handshake: {
|
||||
auth?: Record<string, unknown>;
|
||||
headers?: Record<string, unknown>;
|
||||
}): string | null => {
|
||||
const headerAuth = handshake.headers?.authorization;
|
||||
if (typeof headerAuth === "string" && headerAuth.length > 0)
|
||||
return headerAuth;
|
||||
|
||||
const authAuthorization = handshake.auth?.authorization;
|
||||
if (typeof authAuthorization === "string" && authAuthorization.length > 0)
|
||||
return authAuthorization;
|
||||
|
||||
const authToken = handshake.auth?.token;
|
||||
if (typeof authToken === "string" && authToken.length > 0)
|
||||
return `Bearer ${authToken}`;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const setupSecureNamespace = (): Namespace => {
|
||||
const secureNamespace = io.of("/secure");
|
||||
|
||||
secureNamespace.use(async (socket, next) => {
|
||||
try {
|
||||
const authorization = resolveAuthorization(socket.handshake as any);
|
||||
if (!authorization)
|
||||
return next(new Error("Unauthorized: missing authorization"));
|
||||
|
||||
const components = authorization.match(AUTH_HEADER_REGEX);
|
||||
if (!components)
|
||||
return next(new Error("Unauthorized: invalid authorization format"));
|
||||
|
||||
const authValue = components[2] ?? "";
|
||||
const session = await sessions.fetch({ accessToken: authValue });
|
||||
const user = await session.fetchUser();
|
||||
|
||||
socket.data.user = user;
|
||||
socket.data.session = session;
|
||||
|
||||
return next();
|
||||
} catch {
|
||||
return next(new Error("Unauthorized"));
|
||||
}
|
||||
});
|
||||
|
||||
secureNamespace.use(socketAuthMiddleware());
|
||||
|
||||
secureNamespace.on("connection", (socket) => {
|
||||
const sessionId = socket.data.session?.id as string | undefined;
|
||||
const sessionExpiresAt = socket.data.session?.expires
|
||||
? new Date(socket.data.session.expires).getTime()
|
||||
: null;
|
||||
|
||||
const disconnectForSession = () => {
|
||||
if (socket.disconnected) return;
|
||||
socket.emit("secure:session:expired");
|
||||
socket.disconnect(true);
|
||||
};
|
||||
|
||||
let expiryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleExpiryDisconnect = () => {
|
||||
if (sessionExpiresAt === null) return;
|
||||
|
||||
const remainingMs = sessionExpiresAt - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
disconnectForSession();
|
||||
return;
|
||||
}
|
||||
|
||||
const delayMs = Math.min(remainingMs, MAX_TIMEOUT_MS);
|
||||
expiryTimeout = setTimeout(scheduleExpiryDisconnect, delayMs);
|
||||
};
|
||||
|
||||
scheduleExpiryDisconnect();
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
if (!sessionId) {
|
||||
disconnectForSession();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await prisma.session.findFirst({
|
||||
where: { id: sessionId },
|
||||
select: { id: true, expires: true, invalidatedAt: true },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
disconnectForSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.invalidatedAt) {
|
||||
disconnectForSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.expires.getTime() <= Date.now()) {
|
||||
disconnectForSession();
|
||||
}
|
||||
}, SESSION_ENFORCEMENT_INTERVAL_MS);
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
clearInterval(interval);
|
||||
if (expiryTimeout) clearTimeout(expiryTimeout);
|
||||
});
|
||||
|
||||
registerLiveQuotePreviewHandlers(socket);
|
||||
|
||||
socket.emit("secure:connected", {
|
||||
userId: socket.data.user?.id ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
return secureNamespace;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/unifi/site/:id */
|
||||
export default createRoute(
|
||||
@@ -10,9 +11,16 @@ export default createRoute(
|
||||
["/site/:id"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
site,
|
||||
"obj.unifiSite",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Site Fetched Successfully!",
|
||||
site,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/unifi/sites */
|
||||
export default createRoute(
|
||||
@@ -10,9 +11,16 @@ export default createRoute(
|
||||
["/sites"],
|
||||
async (c) => {
|
||||
const sites = await unifiSites.fetchAll();
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
sites.map((site) =>
|
||||
processObjectValuePerms(site, "obj.unifiSite", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Sites Fetched Successfully!",
|
||||
sites,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -2,16 +2,20 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
// /v1/user/@me
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/@me"],
|
||||
(c) => {
|
||||
const response = apiResponse.successful(
|
||||
"Fetched user.",
|
||||
async (c) => {
|
||||
const gatedData = await processObjectValuePerms(
|
||||
c.get("user")?.toJson(),
|
||||
"obj.user",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful("Fetched user.", gatedData);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ scopes: ["user.read"] }),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { users } from "../../managers/users";
|
||||
import GenericError from "../../Errors/GenericError";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/user/users/:identifier */
|
||||
export default createRoute(
|
||||
@@ -21,9 +22,15 @@ export default createRoute(
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
user.toJson(),
|
||||
"obj.user",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"User Fetched Successfully!",
|
||||
user.toJson(),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { users } from "../../managers/users";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/user/users */
|
||||
export default createRoute(
|
||||
@@ -11,11 +12,16 @@ export default createRoute(
|
||||
|
||||
async (c) => {
|
||||
const allUsers = await users.fetchAllUsers();
|
||||
const usersArray = allUsers.map((u) => u.toJson());
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
allUsers.map((u) =>
|
||||
processObjectValuePerms(u.toJson(), "obj.user", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Users Fetched Successfully!",
|
||||
usersArray,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { users } from "../../managers/users";
|
||||
import GenericError from "../../Errors/GenericError";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/user/users/:identifier/roles */
|
||||
export default createRoute(
|
||||
@@ -22,11 +23,20 @@ export default createRoute(
|
||||
});
|
||||
|
||||
const roles = await user.fetchRoles();
|
||||
const rolesArray = roles.map((r) => r.toJson({ viewPermissions: true }));
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
roles.map((r) =>
|
||||
processObjectValuePerms(
|
||||
r.toJson({ viewPermissions: true }),
|
||||
"obj.role",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"User Roles Fetched Successfully!",
|
||||
rolesArray,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Server } from "socket.io";
|
||||
import { Server as Engine } from "@socket.io/bun-engine";
|
||||
import axios from "axios";
|
||||
import { UnifiClient } from "./modules/unifi-api/UnifiClient";
|
||||
import { attachCwApiLogger } from "./modules/cw-utils/cwApiLogger";
|
||||
import Redis from "ioredis";
|
||||
|
||||
const connectionString = `${process.env.DATABASE_URL}`;
|
||||
const adapter = new PrismaPg({ connectionString });
|
||||
@@ -22,6 +24,10 @@ export const API_BASE_URL =
|
||||
|
||||
export const prisma = new PrismaClient({ adapter });
|
||||
|
||||
// Redis Client
|
||||
|
||||
export const redis = new Redis(process.env.REDIS_URL!);
|
||||
|
||||
export const sessionDuration = 30 * 24 * 60 * 60000;
|
||||
export const accessTokenDuration = "10min";
|
||||
export const refreshTokenDuration = "30d";
|
||||
@@ -76,8 +82,11 @@ const connectWiseApi = axios.create({
|
||||
clientId: `${process.env.CW_CLIENT_ID}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 30_000, // 30 s — prevents indefinite hangs on CW API
|
||||
});
|
||||
|
||||
attachCwApiLogger(connectWiseApi);
|
||||
|
||||
export { connectWiseApi };
|
||||
|
||||
// Unifi API Constants
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
CWActivity,
|
||||
CWActivityCustomField,
|
||||
CWPatchOperation,
|
||||
CWCreateActivity,
|
||||
} from "../modules/cw-utils/activities/activity.types";
|
||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import { fetchActivity } from "../modules/cw-utils/activities/fetchActivity";
|
||||
|
||||
/**
|
||||
* Activity Controller
|
||||
*
|
||||
* Domain model class that encapsulates a ConnectWise Activity entity.
|
||||
* Activities are not persisted locally — all data is sourced directly
|
||||
* from the ConnectWise API.
|
||||
*/
|
||||
export class ActivityController {
|
||||
public readonly cwActivityId: number;
|
||||
public name: string;
|
||||
public notes: string | null;
|
||||
|
||||
public typeName: string | null;
|
||||
public typeCwId: number | null;
|
||||
public statusName: string | null;
|
||||
public statusCwId: number | null;
|
||||
|
||||
public companyCwId: number | null;
|
||||
public companyName: string | null;
|
||||
public companyIdentifier: string | null;
|
||||
public contactCwId: number | null;
|
||||
public contactName: string | null;
|
||||
|
||||
public phoneNumber: string | null;
|
||||
public email: string | null;
|
||||
|
||||
public opportunityCwId: number | null;
|
||||
public opportunityName: string | null;
|
||||
public ticketCwId: number | null;
|
||||
public ticketName: string | null;
|
||||
public agreementCwId: number | null;
|
||||
public agreementName: string | null;
|
||||
public campaignCwId: number | null;
|
||||
public campaignName: string | null;
|
||||
|
||||
public assignToCwId: number | null;
|
||||
public assignToName: string | null;
|
||||
public assignToIdentifier: string | null;
|
||||
|
||||
public scheduleStatusCwId: number | null;
|
||||
public scheduleStatusName: string | null;
|
||||
public reminderCwId: number | null;
|
||||
public reminderName: string | null;
|
||||
public whereCwId: number | null;
|
||||
public whereName: string | null;
|
||||
|
||||
public dateStart: Date | null;
|
||||
public dateEnd: Date | null;
|
||||
public notifyFlag: boolean;
|
||||
|
||||
public currencyCwId: number | null;
|
||||
public currencyName: string | null;
|
||||
|
||||
public mobileGuid: string | null;
|
||||
public customFields: CWActivityCustomField[];
|
||||
|
||||
public cwLastUpdated: Date | null;
|
||||
public cwDateEntered: Date | null;
|
||||
public cwEnteredBy: string | null;
|
||||
public cwUpdatedBy: string | null;
|
||||
|
||||
constructor(data: CWActivity) {
|
||||
this.cwActivityId = data.id;
|
||||
this.name = data.name;
|
||||
this.notes = data.notes ?? null;
|
||||
|
||||
this.typeName = data.type?.name ?? null;
|
||||
this.typeCwId = data.type?.id ?? null;
|
||||
this.statusName = data.status?.name ?? null;
|
||||
this.statusCwId = data.status?.id ?? null;
|
||||
|
||||
this.companyCwId = data.company?.id ?? null;
|
||||
this.companyName = data.company?.name ?? null;
|
||||
this.companyIdentifier = data.company?.identifier ?? null;
|
||||
this.contactCwId = data.contact?.id ?? null;
|
||||
this.contactName = data.contact?.name ?? null;
|
||||
|
||||
this.phoneNumber = data.phoneNumber ?? null;
|
||||
this.email = data.email ?? null;
|
||||
|
||||
this.opportunityCwId = data.opportunity?.id ?? null;
|
||||
this.opportunityName = data.opportunity?.name ?? null;
|
||||
this.ticketCwId = data.ticket?.id ?? null;
|
||||
this.ticketName = data.ticket?.name ?? null;
|
||||
this.agreementCwId = data.agreement?.id ?? null;
|
||||
this.agreementName = data.agreement?.name ?? null;
|
||||
this.campaignCwId = data.campaign?.id ?? null;
|
||||
this.campaignName = data.campaign?.name ?? null;
|
||||
|
||||
this.assignToCwId = data.assignTo?.id ?? null;
|
||||
this.assignToName = data.assignTo?.name ?? null;
|
||||
this.assignToIdentifier = data.assignTo?.identifier ?? null;
|
||||
|
||||
this.scheduleStatusCwId = data.scheduleStatus?.id ?? null;
|
||||
this.scheduleStatusName = data.scheduleStatus?.name ?? null;
|
||||
this.reminderCwId = data.reminder?.id ?? null;
|
||||
this.reminderName = data.reminder?.name ?? null;
|
||||
this.whereCwId = data.where?.id ?? null;
|
||||
this.whereName = data.where?.name ?? null;
|
||||
|
||||
this.dateStart = data.dateStart ? new Date(data.dateStart) : null;
|
||||
this.dateEnd = data.dateEnd ? new Date(data.dateEnd) : null;
|
||||
this.notifyFlag = data.notifyFlag ?? false;
|
||||
|
||||
this.currencyCwId = data.currency?.id ?? null;
|
||||
this.currencyName = data.currency?.name ?? null;
|
||||
|
||||
this.mobileGuid = data.mobileGuid ?? null;
|
||||
this.customFields = data.customFields ?? [];
|
||||
|
||||
this.cwLastUpdated = data._info?.lastUpdated
|
||||
? new Date(data._info.lastUpdated)
|
||||
: null;
|
||||
this.cwDateEntered = data._info?.dateEntered
|
||||
? new Date(data._info.dateEntered)
|
||||
: null;
|
||||
this.cwEnteredBy = data._info?.enteredBy ?? null;
|
||||
this.cwUpdatedBy = data._info?.updatedBy ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh from ConnectWise
|
||||
*
|
||||
* Fetches the latest activity data from CW and returns
|
||||
* a new ActivityController instance with updated state.
|
||||
*/
|
||||
public async refreshFromCW(): Promise<ActivityController> {
|
||||
const cwData = await fetchActivity(this.cwActivityId);
|
||||
return new ActivityController(cwData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw CW data
|
||||
*
|
||||
* Returns the raw ConnectWise activity object.
|
||||
*/
|
||||
public async fetchCwData(): Promise<CWActivity> {
|
||||
return fetchActivity(this.cwActivityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update in ConnectWise
|
||||
*
|
||||
* Applies JSON Patch operations to this activity in ConnectWise
|
||||
* and returns a new controller with the updated data.
|
||||
*/
|
||||
public async update(
|
||||
operations: CWPatchOperation[],
|
||||
): Promise<ActivityController> {
|
||||
const updated = await activityCw.update(this.cwActivityId, operations);
|
||||
return new ActivityController(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete from ConnectWise
|
||||
*
|
||||
* Deletes this activity in ConnectWise.
|
||||
*/
|
||||
public async delete(): Promise<void> {
|
||||
await activityCw.delete(this.cwActivityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Activity (static factory)
|
||||
*
|
||||
* Creates a new activity in ConnectWise and returns a controller instance.
|
||||
*/
|
||||
public static async create(
|
||||
data: CWCreateActivity,
|
||||
): Promise<ActivityController> {
|
||||
const created = await activityCw.create(data);
|
||||
return new ActivityController(created);
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Serializes the activity into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
return {
|
||||
cwActivityId: this.cwActivityId,
|
||||
name: this.name,
|
||||
notes: this.notes,
|
||||
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
|
||||
status: this.statusCwId
|
||||
? { id: this.statusCwId, name: this.statusName }
|
||||
: null,
|
||||
company: this.companyCwId
|
||||
? {
|
||||
id: this.companyCwId,
|
||||
identifier: this.companyIdentifier,
|
||||
name: this.companyName,
|
||||
}
|
||||
: null,
|
||||
contact: this.contactCwId
|
||||
? { id: this.contactCwId, name: this.contactName }
|
||||
: null,
|
||||
phoneNumber: this.phoneNumber,
|
||||
email: this.email,
|
||||
opportunity: this.opportunityCwId
|
||||
? { id: this.opportunityCwId, name: this.opportunityName }
|
||||
: null,
|
||||
ticket: this.ticketCwId
|
||||
? { id: this.ticketCwId, name: this.ticketName }
|
||||
: null,
|
||||
agreement: this.agreementCwId
|
||||
? { id: this.agreementCwId, name: this.agreementName }
|
||||
: null,
|
||||
campaign: this.campaignCwId
|
||||
? { id: this.campaignCwId, name: this.campaignName }
|
||||
: null,
|
||||
assignTo: this.assignToCwId
|
||||
? {
|
||||
id: this.assignToCwId,
|
||||
identifier: this.assignToIdentifier,
|
||||
name: this.assignToName,
|
||||
}
|
||||
: null,
|
||||
scheduleStatus: this.scheduleStatusCwId
|
||||
? { id: this.scheduleStatusCwId, name: this.scheduleStatusName }
|
||||
: null,
|
||||
reminder: this.reminderCwId
|
||||
? { id: this.reminderCwId, name: this.reminderName }
|
||||
: null,
|
||||
where: this.whereCwId
|
||||
? { id: this.whereCwId, name: this.whereName }
|
||||
: null,
|
||||
dateStart: this.dateStart,
|
||||
dateEnd: this.dateEnd,
|
||||
notifyFlag: this.notifyFlag,
|
||||
currency: this.currencyCwId
|
||||
? { id: this.currencyCwId, name: this.currencyName }
|
||||
: null,
|
||||
mobileGuid: this.mobileGuid,
|
||||
customFields: this.customFields,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
cwDateEntered: this.cwDateEntered,
|
||||
cwEnteredBy: this.cwEnteredBy,
|
||||
cwUpdatedBy: this.cwUpdatedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ export class CatalogItemController {
|
||||
public readonly cwCatalogId: number;
|
||||
public readonly identifier: string | null;
|
||||
|
||||
public category: string | null;
|
||||
public categoryCwId: number | null;
|
||||
public subcategory: string | null;
|
||||
public subcategoryCwId: number | null;
|
||||
|
||||
public manufacturer: string | null;
|
||||
public manufactureCwId: number | null;
|
||||
public partNumber: string | null;
|
||||
@@ -55,6 +60,10 @@ export class CatalogItemController {
|
||||
this.internalNotes = itemData.internalNotes;
|
||||
this.cwCatalogId = itemData.cwCatalogId;
|
||||
this.identifier = itemData.identifier;
|
||||
this.category = itemData.category;
|
||||
this.categoryCwId = itemData.categoryCwId;
|
||||
this.subcategory = itemData.subcategory;
|
||||
this.subcategoryCwId = itemData.subcategoryCwId;
|
||||
this.manufacturer = itemData.manufacturer;
|
||||
this.manufactureCwId = itemData.manufactureCwId;
|
||||
this.partNumber = itemData.partNumber;
|
||||
@@ -196,6 +205,10 @@ export class CatalogItemController {
|
||||
description: this.description,
|
||||
customerDescription: this.customerDescription,
|
||||
internalNotes: this.internalNotes,
|
||||
category: this.category,
|
||||
categoryCwId: this.categoryCwId,
|
||||
subcategory: this.subcategory,
|
||||
subcategoryCwId: this.subcategoryCwId,
|
||||
manufacturer: this.manufacturer,
|
||||
manufactureCwId: this.manufactureCwId,
|
||||
partNumber: this.partNumber,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Company } from "../../generated/prisma/client";
|
||||
import { connectWiseApi } from "../constants";
|
||||
import { fetchCwCompanyById } from "../modules/cw-utils/fetchCompany";
|
||||
import { fetchCompanyConfigurations } from "../modules/cw-utils/configurations/fetchCompanyConfigurations";
|
||||
import { updateCwInternalCompany } from "../modules/cw-utils/updateCompany";
|
||||
import {
|
||||
fetchCompanySites,
|
||||
fetchCompanySite,
|
||||
serializeCwSite,
|
||||
} from "../modules/cw-utils/sites/companySites";
|
||||
import { Company as CWCompany, Contact } from "../types/ConnectWiseTypes";
|
||||
|
||||
/**
|
||||
@@ -16,7 +22,7 @@ export class CompanyController {
|
||||
public name: string;
|
||||
public readonly cw_Identifier: string;
|
||||
public readonly cw_CompanyId: number;
|
||||
public readonly cw_Data?: {
|
||||
public cw_Data?: {
|
||||
company: CWCompany;
|
||||
defaultContact: Contact | null;
|
||||
allContacts: Contact[];
|
||||
@@ -30,6 +36,41 @@ export class CompanyController {
|
||||
this.cw_Data = cwData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate CW Data
|
||||
*
|
||||
* Fetches and populates the full ConnectWise company data
|
||||
* (company, default contact, all contacts) if not already loaded.
|
||||
*
|
||||
* @returns {ThisType}
|
||||
*/
|
||||
public async hydrateCwData() {
|
||||
if (this.cw_Data) return this;
|
||||
|
||||
const cwCompany = await fetchCwCompanyById(this.cw_CompanyId);
|
||||
if (!cwCompany) return this;
|
||||
|
||||
const allContactsData = await connectWiseApi.get(
|
||||
`${cwCompany._info.contacts_href}&pageSize=1000`,
|
||||
);
|
||||
|
||||
// Derive default contact from allContacts instead of a separate CW call
|
||||
const defaultContactId = cwCompany.defaultContact?.id;
|
||||
const defaultContactData = defaultContactId
|
||||
? ((allContactsData.data as any[]).find(
|
||||
(c: any) => c.id === defaultContactId,
|
||||
) ?? null)
|
||||
: null;
|
||||
|
||||
this.cw_Data = {
|
||||
company: cwCompany,
|
||||
defaultContact: defaultContactData,
|
||||
allContacts: allContactsData.data,
|
||||
};
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Internal Company Data from ConnectWise
|
||||
*
|
||||
@@ -71,6 +112,30 @@ export class CompanyController {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company Sites
|
||||
*
|
||||
* Retrieves all sites for this company from ConnectWise
|
||||
* and returns them as serialized site objects.
|
||||
*/
|
||||
public async fetchSites() {
|
||||
const sites = await fetchCompanySites(this.cw_CompanyId);
|
||||
return sites.map(serializeCwSite);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company Site by ID
|
||||
*
|
||||
* Retrieves a single site by its ConnectWise site ID
|
||||
* and returns a serialized site object.
|
||||
*
|
||||
* @param cwSiteId - The ConnectWise site ID
|
||||
*/
|
||||
public async fetchSite(cwSiteId: number) {
|
||||
const site = await fetchCompanySite(this.cw_CompanyId, cwSiteId);
|
||||
return serializeCwSite(site);
|
||||
}
|
||||
|
||||
public toJson(opts?: {
|
||||
includeAddress: boolean;
|
||||
includePrimaryContact: boolean;
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
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 customerDescription: string | null;
|
||||
public productNarrative: string | null;
|
||||
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.customerDescription = data.customerDescription ?? null;
|
||||
this.productNarrative =
|
||||
data.customFields?.find((f) => f.id === 46)?.value?.toString() ?? null;
|
||||
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.
|
||||
*/
|
||||
/**
|
||||
* Apply Procurement Custom Fields
|
||||
*
|
||||
* Enriches this forecast product with custom field data from the
|
||||
* procurement products endpoint (the forecast endpoint does not
|
||||
* return customFields).
|
||||
*/
|
||||
public applyProcurementCustomFields(data: {
|
||||
customFields?: Array<{ id: number; value?: unknown }>;
|
||||
}): void {
|
||||
const narrative = data.customFields
|
||||
?.find((f) => f.id === 46)
|
||||
?.value?.toString();
|
||||
if (narrative) {
|
||||
this.productNarrative = narrative;
|
||||
}
|
||||
}
|
||||
|
||||
public applyCancellationData(data: {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective Quantity
|
||||
*
|
||||
* Returns the quantity adjusted for cancellations (minimum 0).
|
||||
*/
|
||||
public get effectiveQuantity(): number {
|
||||
if (this.cancellationType === "full") return 0;
|
||||
return Math.max(0, this.quantity - this.quantityCancelled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective Revenue
|
||||
*
|
||||
* Returns the revenue adjusted proportionally for cancelled units.
|
||||
*/
|
||||
public get effectiveRevenue(): number {
|
||||
if (this.cancellationType === "full" || this.quantity <= 0) return 0;
|
||||
const unitPrice = this.revenue / this.quantity;
|
||||
return unitPrice * this.effectiveQuantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective Cost
|
||||
*
|
||||
* Returns the cost adjusted proportionally for cancelled units.
|
||||
*/
|
||||
public get effectiveCost(): number {
|
||||
if (this.cancellationType === "full" || this.quantity <= 0) return 0;
|
||||
const unitCost = this.cost / this.quantity;
|
||||
return unitCost * this.effectiveQuantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelled
|
||||
*
|
||||
* 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,
|
||||
customerDescription: this.customerDescription,
|
||||
productNarrative: this.productNarrative,
|
||||
productClass: this.productClass,
|
||||
forecastType: this.forecastType,
|
||||
revenue: this.revenue,
|
||||
cost: this.cost,
|
||||
margin: this.margin,
|
||||
profit: this.profit,
|
||||
effectiveQuantity: this.effectiveQuantity,
|
||||
effectiveRevenue: this.effectiveRevenue,
|
||||
effectiveCost: this.effectiveCost,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
GeneratedQuotes,
|
||||
Opportunity,
|
||||
Role,
|
||||
User,
|
||||
} from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { OpportunityController } from "./OpportunityController";
|
||||
import UserController from "./UserController";
|
||||
|
||||
export class GeneratedQuoteController {
|
||||
public readonly id: string;
|
||||
|
||||
public quoteRegenData: unknown;
|
||||
public quoteRegenParams: unknown;
|
||||
public quoteRegenHash: string;
|
||||
|
||||
public downloads: unknown[];
|
||||
|
||||
public quoteFile: Uint8Array;
|
||||
public quoteFileName: string;
|
||||
|
||||
public opportunityId: string;
|
||||
public createdById: string | null;
|
||||
|
||||
public createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
private _opportunity: OpportunityController | null;
|
||||
private _createdBy: UserController | null;
|
||||
|
||||
constructor(
|
||||
data: GeneratedQuotes & {
|
||||
opportunity?: Opportunity | null;
|
||||
createdBy?: (User & { roles: Role[] }) | null;
|
||||
},
|
||||
) {
|
||||
this.id = data.id;
|
||||
|
||||
this.quoteRegenData = data.quoteRegenData;
|
||||
this.quoteRegenParams = data.quoteRegenParams;
|
||||
this.quoteRegenHash = data.quoteRegenHash;
|
||||
|
||||
this.downloads = Array.isArray(data.downloads)
|
||||
? (data.downloads as unknown[])
|
||||
: [];
|
||||
|
||||
this.quoteFile = data.quoteFile;
|
||||
this.quoteFileName = data.quoteFileName;
|
||||
|
||||
this.opportunityId = data.opportunityId;
|
||||
this.createdById = data.createdById;
|
||||
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
|
||||
this._opportunity = data.opportunity
|
||||
? new OpportunityController(data.opportunity)
|
||||
: null;
|
||||
|
||||
this._createdBy = data.createdBy
|
||||
? new UserController(data.createdBy)
|
||||
: null;
|
||||
}
|
||||
|
||||
public async fetchOpportunity(): Promise<OpportunityController | null> {
|
||||
if (this._opportunity) return this._opportunity;
|
||||
|
||||
const opportunity = await prisma.opportunity.findFirst({
|
||||
where: { id: this.opportunityId },
|
||||
});
|
||||
|
||||
if (!opportunity) return null;
|
||||
|
||||
this._opportunity = new OpportunityController(opportunity);
|
||||
return this._opportunity;
|
||||
}
|
||||
|
||||
public async fetchCreatedBy(): Promise<UserController | null> {
|
||||
if (this._createdBy) return this._createdBy;
|
||||
if (!this.createdById) return null;
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: this.createdById },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
this._createdBy = new UserController(user);
|
||||
return this._createdBy;
|
||||
}
|
||||
|
||||
public toJson(opts?: {
|
||||
includeFile?: boolean;
|
||||
encodeFileAsBase64?: boolean;
|
||||
includeRegenData?: boolean;
|
||||
includeRegenParams?: boolean;
|
||||
includeDownloads?: boolean;
|
||||
includeOpportunity?: boolean;
|
||||
includeCreatedBy?: boolean;
|
||||
}): Record<string, any> {
|
||||
return {
|
||||
id: this.id,
|
||||
quoteFileName: this.quoteFileName,
|
||||
quoteRegenHash: this.quoteRegenHash,
|
||||
opportunityId: this.opportunityId,
|
||||
createdById: this.createdById,
|
||||
downloads: opts?.includeDownloads ? this.downloads : undefined,
|
||||
quoteRegenData: opts?.includeRegenData ? this.quoteRegenData : undefined,
|
||||
quoteRegenParams: opts?.includeRegenParams
|
||||
? this.quoteRegenParams
|
||||
: undefined,
|
||||
quoteFile: !opts?.includeFile
|
||||
? undefined
|
||||
: opts?.encodeFileAsBase64
|
||||
? Buffer.from(this.quoteFile).toString("base64")
|
||||
: this.quoteFile,
|
||||
opportunity:
|
||||
opts?.includeOpportunity && this._opportunity
|
||||
? this._opportunity.toJson()
|
||||
: undefined,
|
||||
createdBy:
|
||||
opts?.includeCreatedBy && this._createdBy
|
||||
? this._createdBy.toJson()
|
||||
: undefined,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user