Compare commits

...

9 Commits

Author SHA1 Message Date
HoloPanio 5afda8cb34 Add taxableFlag to product updates, QUO-Narrative quote fallback, and orphan reconciliation
- Add taxableFlag boolean field to product update schema and forecast patch
- Fall back to QUO-Narrative product customerDescription for quote narrative
- Reconcile orphaned local opportunity records not found in CW during refresh
- Invalidate caches for removed orphaned opportunities
- Add reconciled event and orphanedCount to refresh events
- Update API_ROUTES.md with taxableFlag field documentation
2026-03-09 17:48:47 -05:00
HoloPanio ee3e0a7377 bypass checkColdStatus — always returns not-cold until feature ready 2026-03-09 03:33:59 -05:00
HoloPanio e294791858 fix: provide real checkColdStatus in wfOpportunity mock, remove stray closing brace 2026-03-09 03:29:00 -05:00
HoloPanio 97ac4a2173 fix: eliminate cross-file mock.module pollution — complete exports for all mocked modules 2026-03-09 03:26:22 -05:00
HoloPanio ad7507d133 fix: use real cache key prefixes in mock and dynamic imports for CI compatibility 2026-03-09 03:08:15 -05:00
HoloPanio 15ef24eb3e fix: resolve CI test failures — explicit cache mock exports, hoisted service mocks, pinned Bun 1.3.6 2026-03-09 03:03:06 -05:00
HoloPanio f53b390e18 feat: add opportunity workflows, delete routes, company sites, algorithms, and expanded test coverage 2026-03-09 02:56:08 -05:00
HoloPanio c0a4d4f919 feat: add CW members, opportunity create/update, and integrator interceptor 2026-03-07 18:15:17 -06:00
HoloPanio 0ce1eda606 fix: add missing GeneratedQuotes columns migration 2026-03-07 00:14:26 -06:00
72 changed files with 11337 additions and 88 deletions
+2
View File
@@ -14,6 +14,8 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.6"
- name: Install dependencies
run: bun install --frozen-lockfile
+2
View File
@@ -14,6 +14,8 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.6"
- name: Install dependencies
run: bun install --frozen-lockfile
+685 -11
View File
@@ -137,7 +137,46 @@ See [PERMISSIONS.md](PERMISSIONS.md) for the full list of field-level permission
## Authentication Routes
## ConnectWise Callback Routes
## ConnectWise Routes
### Fetch CW Members
**GET** `/cw/members`
Returns all ConnectWise members from the server-side member cache, sorted alphabetically by name. By default only active members are returned.
**Authentication Required:** Yes (any authenticated user)
**Permissions Required:** None
**Query Parameters:**
| Parameter | Type | Default | Description |
| --------- | ------ | ------- | ------------------------------------------ |
| `active` | string | `true` | Set to `false` to include inactive members |
**Response:**
```json
{
"status": 200,
"message": "CW members fetched successfully!",
"data": [
{
"id": 250,
"identifier": "jroberts",
"firstName": "John",
"lastName": "Roberts",
"name": "John Roberts",
"officeEmail": "jroberts@totaltech.net",
"inactive": false
}
],
"successful": true
}
```
---
### Receive ConnectWise Callback
@@ -862,6 +901,52 @@ Fetch configurations for a specific company from ConnectWise.
---
### Get Company Sites
**GET** `/company/companies/:identifier/sites`
Fetch all ConnectWise sites for a specific company.
**Authentication Required:** Yes
**Required Permissions:** `company.fetch`, `company.fetch.sites`
**URL Parameters:**
- `identifier` - Company ID
**Response:**
```json
{
"status": 200,
"message": "Company Sites Fetched Successfully!",
"data": [
{
"id": 1,
"name": "Main Office",
"address": {
"line1": "123 Main St",
"line2": null,
"city": "Springfield",
"state": "Illinois",
"zip": "62704",
"country": "United States"
},
"phoneNumber": "555-123-4567",
"faxNumber": null,
"primaryAddressFlag": true,
"defaultShippingFlag": true,
"defaultBillingFlag": true,
"defaultMailingFlag": true
}
],
"successful": true
}
```
---
### Get Company UniFi Sites
**GET** `/company/companies/:identifier/unifi/sites`
@@ -3344,6 +3429,277 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise. The
---
### Create Opportunity
**POST** `/sales/opportunities`
Create a new opportunity in ConnectWise. The created opportunity is synced to the local database and returned in the response. `name` and `expectedCloseDate` are required; all other fields are optional.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.create`
**Request Body:**
```json
{
"name": "Acme Corp Network Refresh",
"expectedCloseDate": "2026-06-01",
"notes": "Initial scoping phase",
"rating": { "id": 1 },
"type": { "id": 2 },
"stage": { "id": 1 },
"status": { "id": 1 },
"priority": { "id": 2 },
"campaign": { "id": 5 },
"primarySalesRep": { "id": 10 },
"secondarySalesRep": { "id": 12 },
"company": { "id": 100 },
"contact": { "id": 200 },
"site": { "id": 50 },
"source": "Referral",
"customerPO": "PO-12345",
"locationId": 1,
"businessUnitId": 5
}
```
| Field | Type | Required | Description |
| ------------------- | ------------------------ | -------- | --------------------------------------------------------- |
| `name` | `string` | Yes | Opportunity name |
| `expectedCloseDate` | `string` | Yes | Expected close date (date string, e.g. `2026-06-01`) |
| `primarySalesRep` | `{ id: number }` | Yes | CW member reference for primary sales rep |
| `company` | `{ id: number }` | Yes | CW company reference |
| `contact` | `{ id: number }` | Yes | CW contact reference |
| `notes` | `string` | No | Opportunity description / notes |
| `rating` | `{ id: number }` | No | CW rating reference |
| `type` | `{ id: number }` | No | CW opportunity type reference |
| `stage` | `{ id: number }` | No | CW pipeline stage reference |
| `status` | `{ id: number }` | No | CW status reference |
| `priority` | `{ id: number }` | No | CW priority reference |
| `campaign` | `{ id: number }` | No | CW campaign reference |
| `secondarySalesRep` | `{ id: number } \| null` | No | CW member reference for secondary sales rep (null clears) |
| `site` | `{ id: number } \| null` | No | CW site reference (null clears) |
| `source` | `string \| null` | No | Opportunity source (null clears) |
| `customerPO` | `string \| null` | No | Customer PO number (null clears) |
| `locationId` | `number` | No | CW location ID |
| `businessUnitId` | `number` | No | CW business unit ID |
**Response (201):**
```json
{
"status": 201,
"message": "Opportunity created successfully!",
"data": {
"id": "clx...",
"cwOpportunityId": 789,
"name": "Acme Corp Network Refresh",
"notes": "Initial scoping phase",
"type": { "id": 2, "name": "Existing" },
"stage": { "id": 1, "name": "Prospect" },
"status": { "id": 1, "name": "Open" },
"priority": { "id": 2, "name": "High" },
"rating": { "id": 1, "name": "Hot" },
"source": "Referral",
"campaign": { "id": 5, "name": "Q2 Push" },
"primarySalesRep": {
"id": 10,
"identifier": "JDoe",
"name": "John Doe"
},
"secondarySalesRep": {
"id": 12,
"identifier": "ASmith",
"name": "Alice Smith"
},
"company": { "id": 100, "name": "Acme Corp" },
"contact": { "id": 200, "name": "Jane Smith" },
"site": { "id": 50, "name": "Main Office" },
"customerPO": "PO-12345",
"totalSalesTax": 0,
"probability": 0,
"location": { "id": 1, "name": "Murray" },
"department": null,
"expectedCloseDate": "2026-06-01T00:00:00.000Z",
"pipelineChangeDate": null,
"dateBecameLead": null,
"closedDate": null,
"closedFlag": false,
"closedBy": null,
"companyId": "clx...",
"cwLastUpdated": "2026-03-07T10:00:00.000Z",
"createdAt": "2026-03-07T10:00:00.000Z",
"updatedAt": "2026-03-07T10:00:00.000Z",
"customFields": [],
"activities": []
},
"successful": true
}
```
**Error Responses:**
| Status | Scenario |
| ------- | ------------------------------------------------------- |
| 400 | Zod validation failure (missing name/expectedCloseDate) |
| 401 | Missing or invalid auth token |
| 403 | User lacks `sales.opportunity.create` permission |
| 4xx/5xx | ConnectWise API error (forwarded status + message) |
| 500 | Unexpected server error |
---
### Update Opportunity
**PATCH** `/sales/opportunities/:identifier`
Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company, contact, site, description). Only the provided fields are patched; omitted fields remain unchanged. The updated opportunity is synced back to the local database and returned in the response.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.update`
**Path Parameters:**
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
**Request Body (all fields optional, at least one required):**
```json
{
"name": "Acme Corp Network Refresh — Phase 2",
"notes": "Updated project scope to include wireless",
"rating": { "id": 1 },
"type": { "id": 2 },
"stage": { "id": 4 },
"status": { "id": 1 },
"priority": { "id": 2 },
"campaign": { "id": 5 },
"primarySalesRep": { "id": 10 },
"secondarySalesRep": { "id": 12 },
"company": { "id": 100 },
"contact": { "id": 200 },
"site": { "id": 50 },
"expectedCloseDate": "2026-05-01",
"customerPO": "PO-12345",
"source": "Referral",
"locationId": 1,
"businessUnitId": 5
}
```
| Field | Type | Description |
| ------------------- | ------------------------ | --------------------------------------------------------- |
| `name` | `string` | Opportunity name |
| `notes` | `string` | Opportunity description / notes |
| `rating` | `{ id: number }` | CW rating reference |
| `type` | `{ id: number }` | CW opportunity type reference |
| `stage` | `{ id: number }` | CW pipeline stage reference |
| `status` | `{ id: number }` | CW status reference |
| `priority` | `{ id: number }` | CW priority reference |
| `campaign` | `{ id: number }` | CW campaign reference |
| `primarySalesRep` | `{ id: number }` | CW member reference for primary sales rep |
| `secondarySalesRep` | `{ id: number } \| null` | CW member reference for secondary sales rep (null clears) |
| `company` | `{ id: number }` | CW company reference |
| `contact` | `{ id: number } \| null` | CW contact reference (null clears) |
| `site` | `{ id: number } \| null` | CW site reference (null clears) |
| `expectedCloseDate` | `string` | Expected close date (ISO date string) |
| `customerPO` | `string \| null` | Customer PO number (null clears) |
| `source` | `string \| null` | Opportunity source (null clears) |
| `locationId` | `number` | CW location ID |
| `businessUnitId` | `number` | CW business unit ID |
**Response:**
```json
{
"status": 200,
"message": "Opportunity updated successfully!",
"data": {
"id": "clx...",
"cwOpportunityId": 456,
"name": "Acme Corp Network Refresh — Phase 2",
"description": "Updated project scope to include wireless",
"type": { "id": 2, "name": "Existing" },
"stage": { "id": 4, "name": "Negotiation" },
"status": { "id": 1, "name": "Open" },
"priority": { "id": 2, "name": "High" },
"rating": { "id": 1, "name": "Hot" },
"source": "Referral",
"campaign": { "id": 5, "name": "Q2 Push" },
"primarySalesRep": {
"id": 10,
"identifier": "JDoe",
"name": "John Doe"
},
"secondarySalesRep": {
"id": 12,
"identifier": "ASmith",
"name": "Alice Smith"
},
"company": { "id": 100, "name": "Acme Corp" },
"contact": { "id": 200, "name": "Jane Smith" },
"site": { "id": 50, "name": "Main Office" },
"customerPO": "PO-12345",
"totalSalesTax": 0,
"probability": 50,
"location": { "id": 1, "name": "Murray" },
"department": { "id": 5, "name": "Sales" },
"expectedCloseDate": "2026-05-01T00:00:00.000Z",
"pipelineChangeDate": "2026-02-25T00:00:00.000Z",
"dateBecameLead": "2026-01-10T00:00:00.000Z",
"closedDate": null,
"closedFlag": false,
"closedBy": null,
"companyId": "clx...",
"cwLastUpdated": "2026-03-07T10:00:00.000Z",
"createdAt": "2026-02-01T00:00:00.000Z",
"updatedAt": "2026-03-07T10:00:00.000Z",
"customFields": [],
"activities": []
},
"successful": true
}
```
---
### Delete Opportunity
**DELETE** `/sales/opportunities/:identifier`
Delete an opportunity from ConnectWise and the local database. All related Redis caches (activities, notes, contacts, products, CW data) are invalidated.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.delete`
**Path Parameters:**
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
**Response:**
```json
{
"status": 200,
"message": "Opportunity deleted successfully!",
"successful": true
}
```
| Status | Description |
| ------- | -------------------------------------------------- |
| 200 | Opportunity deleted successfully |
| 401 | Missing or invalid auth token |
| 403 | User lacks `sales.opportunity.delete` permission |
| 404 | Opportunity not found |
| 4xx/5xx | ConnectWise API error (forwarded status + message) |
| 500 | Unexpected server error |
---
### Get Opportunity Products
**GET** `/sales/opportunities/:identifier/products`
@@ -3519,19 +3875,21 @@ At least one field is required.
"unitCost": 62.5,
"customerDescription": "Onsite labor for rack install",
"productNarrative": "Install, cable, and validate cutover",
"procurementNotes": "Coordinate site contact before arrival"
"procurementNotes": "Coordinate site contact before arrival",
"taxableFlag": true
}
```
| Field | Type | Description |
| --------------------- | ------ | ---------------------------------------------------------- |
| `productDescription` | string | Product description |
| `quantity` | number | Quantity |
| `unitPrice` | number | Unit price (maps to procurement `price`, forecast revenue) |
| `unitCost` | number | Unit cost (maps to procurement `cost`, forecast cost) |
| `customerDescription` | string | Customer-facing description |
| `productNarrative` | string | Custom field `Product Narrative` (`id: 46`) |
| `procurementNotes` | string | Custom field `Procurement Notes` (`id: 29`) |
| Field | Type | Description |
| --------------------- | ------- | ---------------------------------------------------------- |
| `productDescription` | string | Product description |
| `quantity` | number | Quantity |
| `unitPrice` | number | Unit price (maps to procurement `price`, forecast revenue) |
| `unitCost` | number | Unit cost (maps to procurement `cost`, forecast cost) |
| `customerDescription` | string | Customer-facing description |
| `productNarrative` | string | Custom field `Product Narrative` (`id: 46`) |
| `procurementNotes` | string | Custom field `Procurement Notes` (`id: 29`) |
| `taxableFlag` | boolean | Whether this item is taxable (forecast field) |
**Response:**
@@ -3614,6 +3972,43 @@ Set cancellation state for a product line item using procurement cancellation fi
---
### Delete Product from Opportunity
**DELETE** `/sales/opportunities/:identifier/products/:productId`
Remove a forecast item (product) from an opportunity in ConnectWise. The item is also removed from the local `productSequence` array and the products cache is invalidated.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.product.delete`
**Path Parameters:**
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
- `productId` — ConnectWise forecast item ID (positive integer)
**Response:**
```json
{
"status": 200,
"message": "Product deleted from opportunity successfully!",
"successful": true
}
```
| Status | Description |
| ------- | -------------------------------------------------------- |
| 200 | Product deleted successfully |
| 400 | Invalid productId |
| 401 | Missing or invalid auth token |
| 403 | User lacks `sales.opportunity.product.delete` permission |
| 404 | Opportunity or forecast item not found |
| 4xx/5xx | ConnectWise API error (forwarded status + message) |
| 500 | Unexpected server error |
---
### Add Product to Opportunity
**POST** `/sales/opportunities/:identifier/products`
@@ -4494,6 +4889,285 @@ Fetch contacts associated with an opportunity. Data is served from the Redis cac
---
## Opportunity Workflow (Internal Engine)
The opportunity workflow system is an internal engine that manages the lifecycle of opportunities through a defined set of statuses and transitions. Workflow actions are invoked programmatically via `processOpportunityAction()` from [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts), and are exposed to the UI via the HTTP API routes documented below.
**Stage gate:** All workflow actions require the opportunity's `stageName` to be `"Optima"`. Actions on opportunities in any other stage are rejected.
### Statuses
| Enum Key | CW Status ID | CW Name | Terminal | Notes |
| --------------- | ------------ | ------------------- | -------- | ----------------------------------------------------------------- |
| PendingNew | 37 | 00. Pending New | No | Default status before acceptance |
| New | 24 | 01. New | No | Setup phase — assembling line items, discounts, etc. |
| InternalReview | 56 | 02. Internal Review | No | Flagged for internal review (manual or cold-detection automation) |
| QuoteSent | 43 | 03. Quote Sent | No | Quote has been sent to the customer |
| ConfirmedQuote | 57 | 04. Confirmed Quote | No | Customer acknowledged receipt |
| Active | 58 | 05. Active | No | Quote in revision/flux |
| PendingSent | 60 | Pending Sent | No | Review approved, awaiting rep to send |
| PendingRevision | 61 | Pending Revision | No | Review rejected, awaiting rep revision |
| PendingWon | 49 | 91. Pending Won | No | Won pending finalization by authorized user |
| Won | 29 | 95. Won | Yes | Final positive outcome — immutable |
| PendingLost | 50 | 98. Pending Lost | No | Lost pending finalization or resurrection |
| Lost | 53 | 99. Lost | Yes | Final negative outcome — immutable |
| Canceled | 59 | Canceled | No\* | Not pursued; can be re-opened to Active |
### Transition Map
| From | Allowed Targets |
| --------------- | ------------------------------------------------------------------------------- |
| PendingNew | New |
| New | InternalReview, QuoteSent, Canceled |
| InternalReview | PendingSent (approve), PendingRevision (reject), QuoteSent (send), Canceled |
| PendingSent | QuoteSent |
| PendingRevision | Active |
| QuoteSent | ConfirmedQuote, Won/PendingWon, PendingLost, Active, InternalReview (cold auto) |
| ConfirmedQuote | Won/PendingWon, PendingLost, Active, InternalReview (cold auto) |
| Active | QuoteSent, InternalReview, Canceled |
| PendingWon | Won |
| PendingLost | Lost, Active (resurrection) |
| Won | _(terminal — no transitions)_ |
| Lost | _(terminal — no transitions)_ |
| Canceled | Active (re-open) |
### Workflow Actions
| Action | Description | Required Permission | Note Required |
| -------------- | --------------------------------------------------------------------------- | -------------------------------------------- | ------------- |
| acceptNew | PendingNew → New | — | No |
| requestReview | → InternalReview (manual) | — | Yes |
| reviewDecision | InternalReview → approve/reject/send/cancel | `sales.opportunity.cancel` (cancel only) | Yes |
| sendQuote | → QuoteSent (with compound flags: won, lost, needsRevision, quoteConfirmed) | `sales.opportunity.finalize` (won flag only) | No |
| confirmQuote | QuoteSent → ConfirmedQuote | — | No |
| finalize | → Won/Lost (or PendingWon/PendingLost without finalize permission) | `sales.opportunity.finalize` | Yes |
| resurrect | PendingLost → Active | — | Yes |
| beginRevision | PendingRevision → Active | — | No |
| resendQuote | Active → QuoteSent (re-send after revision) | — | No |
| cancel | → Canceled | `sales.opportunity.cancel` | Yes |
| reopen | Canceled → Active | — | Yes |
### CW Activity Custom Field — Optima_Type
Every workflow activity is tagged with an `Optima_Type` custom field (CW field ID 45) to identify its type without parsing notes:
| Value | Used For |
| ---------------------- | ------------------------------------------------------------- |
| Opportunity Created | Initial opportunity creation |
| Opportunity Setup | New/setup phase activities (stays open until next transition) |
| Opportunity Review | Review submissions (stays open until next transition) |
| Quote Sent | Quote-sent activities |
| Quote Confirmed | Standalone quote confirmation activities |
| Quote Sent & Confirmed | Combined send + confirm in a single action |
| Quote Generated | Quote commit/generation activities |
| Revision | Revision activities (stays open until next transition) |
| Finalized | PendingWon, PendingLost, Lost, and cancel activities |
| Converted | Won (finalized) activities |
### Cold Detection
The cold-detection algorithm ([src/modules/algorithms/algo.coldThreshold.ts](src/modules/algorithms/algo.coldThreshold.ts)) evaluates stall thresholds:
- **QuoteSent**: 14 days with no activity → auto-transition to InternalReview
- **ConfirmedQuote**: 30 days with no activity → auto-transition to InternalReview
Triggered via `triggerColdDetection()` (intended for schedulers/automation, not user actions).
### Supporting Modules
| File | Purpose |
| ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| [src/modules/algorithms/algo.coldThreshold.ts](src/modules/algorithms/algo.coldThreshold.ts) | Cold-detection config and `checkColdStatus()` |
| [src/modules/algorithms/algo.followUpScheduler.ts](src/modules/algorithms/algo.followUpScheduler.ts) | Follow-up scheduling (placeholder: next business day at 10am) |
| [src/services/cw.opportunityService.ts](src/services/cw.opportunityService.ts) | CW integration stubs: `submitTimeEntry()`, `createScheduleEntry()`, `syncOpportunityStatus()` |
### Workflow API Routes
These routes expose the opportunity workflow to the UI. They live under `/v1/sales/opportunities/:identifier/workflow`.
---
#### `GET /v1/sales/opportunities/:identifier/workflow`
Fetch the current workflow state for an opportunity — current status, stage, available actions, cold-detection result, and whether each action is permitted for the authenticated user.
**Auth:** Bearer token required
**Permission:** `sales.opportunity.fetch`
**Source:** [src/api/sales/opportunities/[id]/workflow/status.ts](src/api/sales/opportunities/[id]/workflow/status.ts)
**Response (200):**
```json
{
"message": "Workflow status fetched successfully.",
"status": 200,
"data": {
"currentStatusId": 43,
"currentStatus": "QuoteSent",
"stageName": "Optima",
"isOptimaStage": true,
"isTerminal": false,
"availableActions": [
{
"action": "confirmQuote",
"label": "Confirm Quote Receipt",
"targetStatuses": [{ "key": "ConfirmedQuote", "id": 57 }],
"requiresNote": false,
"requiresPermission": null,
"permitted": true
},
{
"action": "finalize",
"label": "Mark as Won",
"targetStatuses": [
{ "key": "Won", "id": 29 },
{ "key": "PendingWon", "id": 49 }
],
"requiresNote": true,
"requiresPermission": null,
"payloadHints": { "outcome": "\"won\"" },
"permitted": true
}
],
"coldCheck": null
}
}
```
> When `isOptimaStage` is `false`, `availableActions` is always an empty array — no workflow actions are permitted outside the Optima stage.
---
#### `POST /v1/sales/opportunities/:identifier/workflow`
Execute a workflow action. Accepts a discriminated union of `{ action, payload }` and routes it through the workflow engine. The body is validated with Zod.
**Auth:** Bearer token required
**Permission:** `sales.opportunity.workflow` (base gate). Additionally:
- `sales.opportunity.finalize` — for `finalize` action producing Won/Lost
- `sales.opportunity.cancel` — for `cancel` action and `reviewDecision` with `decision: "cancel"`
**Source:** [src/api/sales/opportunities/[id]/workflow/dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts)
**Request body:**
```json
{
"action": "sendQuote",
"payload": {
"note": "Sending quote to customer after review.",
"timeSpent": 30,
"quoteConfirmed": false,
"won": false,
"lost": false,
"needsRevision": false
}
}
```
<details>
<summary><strong>All action types and their payloads</strong></summary>
| Action | Required Payload Fields | Optional Payload Fields | Description |
| ---------------- | --------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------- |
| `acceptNew` | — | `note`, `timeSpent` | PendingNew → New |
| `requestReview` | `note` | `timeSpent` | New/Active → InternalReview |
| `reviewDecision` | `note`, `decision` | `timeSpent` | InternalReview → PendingSent/PendingRevision/QuoteSent/Canceled |
| `sendQuote` | — | `note`, `timeSpent`, `quoteConfirmed`, `won`, `lost`, `needsRevision` | PendingSent/New → QuoteSent (+ compound transitions) |
| `confirmQuote` | — | `note`, `timeSpent` | QuoteSent → ConfirmedQuote |
| `finalize` | `note`, `outcome` (`"won"` or `"lost"`) | `timeSpent` | Pending → Won/Lost (or direct if permitted) |
| `resurrect` | `note` | `timeSpent` | PendingLost → Active |
| `beginRevision` | — | `note`, `timeSpent` | PendingRevision → Active |
| `resendQuote` | — | `note`, `timeSpent`, `quoteConfirmed`, `won`, `lost`, `needsRevision` | Active → QuoteSent (+ compound transitions) |
| `cancel` | `note` | `timeSpent` | New/Active → Canceled (requires cancel perm) |
| `reopen` | `note` | `timeSpent` | Canceled → Active |
`decision` values for `reviewDecision`: `"approve"`, `"reject"`, `"send"`, `"cancel"`
</details>
**Response (200) — success:**
```json
{
"message": "Workflow action completed successfully.",
"status": 200,
"data": {
"previousStatusId": 60,
"previousStatus": "PendingSent",
"newStatusId": 43,
"newStatus": "QuoteSent",
"activitiesCreated": [ { "...activity JSON..." } ],
"coldCheck": null
}
}
```
**Response (422) — transition rejected:**
```json
{
"message": "Workflow action failed.",
"status": 422,
"name": "WorkflowTransitionFailed",
"data": {
"previousStatusId": 29,
"previousStatus": "Won",
"newStatusId": null,
"newStatus": null
}
}
```
---
#### `GET /v1/sales/opportunities/:identifier/workflow/history`
Fetch the workflow activity history for an opportunity. Returns only CW activities that have a valid `Optima_Type` custom field set, sorted newest first.
**Auth:** Bearer token required
**Permission:** `sales.opportunity.fetch`
**Source:** [src/api/sales/opportunities/[id]/workflow/history.ts](src/api/sales/opportunities/[id]/workflow/history.ts)
**Query parameters:**
| Param | Type | Description |
| ------ | ------ | ------------------------------------------------- |
| `type` | string | Filter by Optima_Type value (e.g. `"Quote Sent"`) |
**Response (200):**
```json
{
"message": "Workflow history fetched successfully.",
"status": 200,
"data": {
"opportunityId": "clx...",
"cwOpportunityId": 12345,
"totalActivities": 3,
"activities": [
{
"activity": { "...activity JSON..." },
"optimaType": "Quote Sent",
"quoteId": "QUO-12345",
"closed": true,
"closedAt": "2026-03-09T05:40:00.000Z"
}
]
}
}
```
| Field | Type | Description |
| ------------ | -------------- | -------------------------------------------------------------------------------------------- |
| `activity` | object | Full CW activity JSON from `ActivityController.toJson()` |
| `optimaType` | string | The `Optima_Type` custom field value (e.g. `"Quote Sent"`, `"Opportunity Setup"`) |
| `quoteId` | string \| null | The `QuoteID` custom field value (CW field id 48), or `null` if not set |
| `closed` | boolean | `true` when the CW activity status is Closed (status id 2) |
| `closedAt` | string \| null | ISO-8601 timestamp from the `Close Date` custom field (CW field id 49), or `null` if not set |
---
## UniFi Routes
All UniFi routes require the `unifi.access` permission in addition to their route-specific permission. This acts as a gate for the entire UniFi API.
+47 -28
View File
@@ -23,13 +23,14 @@ The permission validator supports special tokens for flexible permission managem
### Company Permissions
| Permission Node | Description | Used In |
| ------------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `company.fetch` | Fetch a single company | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
| `company.fetch.address` | View company address information (requires `company.fetch` as well) | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
| `company.fetch.contacts` | View all company contacts (requires `company.fetch` as well) | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
| `company.fetch.many` | Fetch multiple companies | [src/api/companies/fetchAll.ts](src/api/companies/fetchAll.ts) |
| `company.fetch.configurations` | Fetch company configurations (requires `company.fetch` as well) | [src/api/companies/[id]/configurations.ts](src/api/companies/[id]/configurations.ts) |
| Permission Node | Description | Used In |
| ------------------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `company.fetch` | Fetch a single company | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
| `company.fetch.address` | View company address information (requires `company.fetch` as well) | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
| `company.fetch.contacts` | View all company contacts (requires `company.fetch` as well) | [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts) |
| `company.fetch.many` | Fetch multiple companies | [src/api/companies/fetchAll.ts](src/api/companies/fetchAll.ts) |
| `company.fetch.configurations` | Fetch company configurations (requires `company.fetch` as well) | [src/api/companies/[id]/configurations.ts](src/api/companies/[id]/configurations.ts) |
| `company.fetch.sites` | Fetch company sites from ConnectWise (requires `company.fetch` as well) | [src/api/companies/[id]/sites.ts](src/api/companies/[id]/sites.ts) |
### Credential Permissions
@@ -124,13 +125,16 @@ Admin-specific UI permissions that control visibility and data loading for admin
| `procurement.catalog.inventory.refresh` | Refresh on-hand inventory for a catalog item from ConnectWise | [src/api/procurement/[id]/refreshInventory.ts](src/api/procurement/[id]/refreshInventory.ts) | `procurement.catalog.fetch` |
| `procurement.catalog.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
### ConnectWise Routes
`GET /v1/cw/members` requires only authentication (any logged-in user) and does **not** require a specific permission node.
`POST /v1/cw/callback/:secret/:resource` is intentionally unauthenticated for inbound ConnectWise callbacks and does **not** require a permission node.
| Permission Node | Description | Used In | Dependencies |
| --------------- | ------------------------------------------------------------------------------- | ------------------------------------------------ | ------------ |
| _None_ | Inbound callback route; secured operationally (network controls / source trust) | [src/api/cw/callback.ts](src/api/cw/callback.ts) | N/A |
| Permission Node | Description | Used In | Dependencies |
| --------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------ |
| _None_ | Fetch CW members (auth only) | [src/api/cw/fetchMembers.ts](src/api/cw/fetchMembers.ts) | N/A |
| _None_ | Inbound callback route; secured operationally (network controls / source trust) | [src/api/cw/callback.ts](src/api/cw/callback.ts) | N/A |
### Sales Permissions
@@ -138,23 +142,38 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy
**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` |
| 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.update` | Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise | [src/api/sales/opportunities/[id]/update.ts](src/api/sales/opportunities/[id]/update.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.create` | Create a new opportunity in ConnectWise | [src/api/sales/opportunities/create.ts](src/api/sales/opportunities/create.ts) | |
| `sales.opportunity.delete` | Delete an opportunity from ConnectWise and the local database | [src/api/sales/opportunities/[id]/delete.ts](src/api/sales/opportunities/[id]/delete.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.delete` | Delete a product (forecast item) from an opportunity | [src/api/sales/opportunities/[id]/products/delete.ts](src/api/sales/opportunities/[id]/products/delete.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<field>` permissions. | [src/api/sales/opportunities/[id]/products/add.ts](src/api/sales/opportunities/[id]/products/add.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add.specialOrder` | Add one or more "SPECIAL ORDER" products via the dedicated special-order route. | [src/api/sales/opportunities/[id]/products/addSpecialOrder.ts](src/api/sales/opportunities/[id]/products/addSpecialOrder.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.product.add.labor` | Add labor products via the dedicated labor route with Field/Tech catalog selection and labor pricing inputs. | [src/api/sales/opportunities/[id]/products/addLabor.ts](src/api/sales/opportunities/[id]/products/addLabor.ts), [src/api/sales/opportunities/[id]/products/laborOptions.ts](src/api/sales/opportunities/[id]/products/laborOptions.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.fetch` | Fetch all committed quotes for an opportunity. | [src/api/sales/opportunities/[id]/quotes/fetchAll.ts](src/api/sales/opportunities/[id]/quotes/fetchAll.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.commit` | Generate and store a finalized quote PDF for an opportunity with regeneration metadata and creator attribution. | [src/api/sales/opportunities/[id]/quotes/commit.ts](src/api/sales/opportunities/[id]/quotes/commit.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.preview` | Generate a preview-stamped quote PDF for an opportunity without storing it. | [src/api/sales/opportunities/[id]/quotes/preview.ts](src/api/sales/opportunities/[id]/quotes/preview.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.download` | Download a committed quote PDF. Each download is recorded with timestamp and user info. | [src/api/sales/opportunities/[id]/quotes/download.ts](src/api/sales/opportunities/[id]/quotes/download.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.quote.fetch_downloads` | Fetch download/print history for all quotes on an opportunity. Admin-level permission. | [src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts](src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.view_margin` | View margin and markup data on opportunity products. Controls visibility of margin %, markup %, and related progress bars in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` |
| `sales.opportunity.view_cost` | View cost data on opportunity products. Controls visibility of unit cost, total cost, and recurring cost in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` |
| `sales.opportunity.view_profit` | View profit data on opportunity products. Controls visibility of profit values in the UI. | UI-only (client-side gate) | `sales.opportunity.fetch` |
| `sales.opportunity.workflow` | Execute opportunity workflow actions (status transitions, review decisions, quote sending, etc.). Base gate for the workflow dispatch endpoint. | [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.fetch` |
| `sales.opportunity.finalize` | Finalize an opportunity as Won or Lost. Without this permission, win/lose actions route to PendingWon/PendingLost instead. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts), [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.workflow` |
| `sales.opportunity.cancel` | Cancel an opportunity. Required to transition any eligible opportunity to the Canceled status. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts), [dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts) | `sales.opportunity.workflow` |
| `sales.opportunity.review` | Submit an opportunity for internal review. Required to transition an opportunity into the InternalReview status. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
| `sales.opportunity.send` | Send a quote to the customer. Required to transition an opportunity to QuoteSent (and compound transitions like immediate won/lost/confirmed). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
| `sales.opportunity.reopen` | Re-open a cancelled opportunity. Required to transition an opportunity from Canceled back to Active. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
| `sales.opportunity.win` | Mark an opportunity as won (or pending won). Gates the win button in the UI. Required for finalize(won), sendQuote(won), and transitionToPending(won). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
| `sales.opportunity.lose` | Mark an opportunity as lost (or pending lost). Gates the lose button in the UI. Required for finalize(lost), sendQuote(lost), and transitionToPending(lost). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
<details>
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
+5
View File
@@ -72,3 +72,8 @@ export type Credential = Prisma.CredentialModel
*
*/
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
/**
* Model CwMember
*
*/
export type CwMember = Prisma.CwMemberModel
+5
View File
@@ -94,3 +94,8 @@ export type Credential = Prisma.CredentialModel
*
*/
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
/**
* Model CwMember
*
*/
export type CwMember = Prisma.CwMemberModel
File diff suppressed because one or more lines are too long
+95 -2
View File
@@ -394,7 +394,8 @@ export const ModelName = {
CredentialType: 'CredentialType',
SecureValue: 'SecureValue',
Credential: 'Credential',
GeneratedQuotes: 'GeneratedQuotes'
GeneratedQuotes: 'GeneratedQuotes',
CwMember: 'CwMember'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -410,7 +411,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" | "generatedQuotes"
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential" | "generatedQuotes" | "cwMember"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -1228,6 +1229,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
CwMember: {
payload: Prisma.$CwMemberPayload<ExtArgs>
fields: Prisma.CwMemberFieldRefs
operations: {
findUnique: {
args: Prisma.CwMemberFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload> | null
}
findUniqueOrThrow: {
args: Prisma.CwMemberFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
findFirst: {
args: Prisma.CwMemberFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload> | null
}
findFirstOrThrow: {
args: Prisma.CwMemberFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
findMany: {
args: Prisma.CwMemberFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
}
create: {
args: Prisma.CwMemberCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
createMany: {
args: Prisma.CwMemberCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.CwMemberCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
}
delete: {
args: Prisma.CwMemberDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
update: {
args: Prisma.CwMemberUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
deleteMany: {
args: Prisma.CwMemberDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CwMemberUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.CwMemberUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
}
upsert: {
args: Prisma.CwMemberUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
}
aggregate: {
args: Prisma.CwMemberAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCwMember>
}
groupBy: {
args: Prisma.CwMemberGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CwMemberGroupByOutputType>[]
}
count: {
args: Prisma.CwMemberCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CwMemberCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
@@ -1477,6 +1552,23 @@ export const GeneratedQuotesScalarFieldEnum = {
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
export const CwMemberScalarFieldEnum = {
id: 'id',
cwMemberId: 'cwMemberId',
identifier: 'identifier',
firstName: 'firstName',
lastName: 'lastName',
officeEmail: 'officeEmail',
inactiveFlag: 'inactiveFlag',
apiKey: 'apiKey',
cwLastUpdated: 'cwLastUpdated',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CwMemberScalarFieldEnum = (typeof CwMemberScalarFieldEnum)[keyof typeof CwMemberScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -1719,6 +1811,7 @@ export type GlobalOmitConfig = {
secureValue?: Prisma.SecureValueOmit
credential?: Prisma.CredentialOmit
generatedQuotes?: Prisma.GeneratedQuotesOmit
cwMember?: Prisma.CwMemberOmit
}
/* Types for Logging */
@@ -61,7 +61,8 @@ export const ModelName = {
CredentialType: 'CredentialType',
SecureValue: 'SecureValue',
Credential: 'Credential',
GeneratedQuotes: 'GeneratedQuotes'
GeneratedQuotes: 'GeneratedQuotes',
CwMember: 'CwMember'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -290,6 +291,23 @@ export const GeneratedQuotesScalarFieldEnum = {
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
export const CwMemberScalarFieldEnum = {
id: 'id',
cwMemberId: 'cwMemberId',
identifier: 'identifier',
firstName: 'firstName',
lastName: 'lastName',
officeEmail: 'officeEmail',
inactiveFlag: 'inactiveFlag',
apiKey: 'apiKey',
cwLastUpdated: 'cwLastUpdated',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CwMemberScalarFieldEnum = (typeof CwMemberScalarFieldEnum)[keyof typeof CwMemberScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
+1
View File
@@ -19,4 +19,5 @@ 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 './models/CwMember.ts'
export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
@@ -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");
+17
View File
@@ -270,3 +270,20 @@ model GeneratedQuotes {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CwMember {
id String @id @default(cuid())
cwMemberId Int @unique
identifier String @unique
firstName String
lastName String
officeEmail String?
inactiveFlag Boolean @default(false)
apiKey String?
cwLastUpdated DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+25
View File
@@ -0,0 +1,25 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { companies } from "../../../managers/companies";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
/* /v1/company/companies/[id]/sites */
export default createRoute(
"get",
["/companies/:identifier/sites"],
async (c) => {
const company = await companies.fetch(c.req.param("identifier"));
const sites = await company.fetchSites();
const response = apiResponse.successful(
"Company Sites Fetched Successfully!",
sites,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({
permissions: ["company.fetch", "company.fetch.sites"],
}),
);
+2 -1
View File
@@ -1,7 +1,8 @@
import { default as fetchAll } from "./fetchAll";
import { default as fetch } from "./[id]/fetch";
import { default as configurations } from "./[id]/configurations";
import { default as sites } from "./[id]/sites";
import { default as unifiSites } from "./[id]/unifiSites";
import { default as count } from "./count";
export { configurations, count, fetch, fetchAll, unifiSites };
export { configurations, count, fetch, fetchAll, sites, unifiSites };
+37
View File
@@ -0,0 +1,37 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { getMemberCache } from "../../modules/cw-utils/members/memberCache";
/* GET /v1/cw/members */
export default createRoute(
"get",
["/members"],
async (c) => {
const cache = await getMemberCache();
const activeOnly = c.req.query("active") !== "false";
const members = cache
.filter((m) => (activeOnly ? !m.inactiveFlag : true))
.map((m) => ({
id: m.id,
identifier: m.identifier,
firstName: m.firstName,
lastName: m.lastName,
name: `${m.firstName} ${m.lastName}`.trim(),
officeEmail: m.officeEmail,
inactive: m.inactiveFlag,
}));
const sorted = members.sort((a, b) => a.name.localeCompare(b.name));
const response = apiResponse.successful(
"CW members fetched successfully!",
sorted,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware(),
);
+2 -1
View File
@@ -1,3 +1,4 @@
import { default as callback } from "./callback";
import { default as fetchMembers } from "./fetchMembers";
export { callback };
export { callback, fetchMembers };
+14
View File
@@ -1,8 +1,11 @@
import { default as fetchAll } from "./opportunities/fetchAll";
import { default as createOpportunity } from "./opportunities/create";
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 updateOpportunity } from "./opportunities/[id]/update";
import { default as deleteOpportunity } from "./opportunities/[id]/delete";
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";
@@ -11,6 +14,7 @@ import { default as laborOptions } from "./opportunities/[id]/products/laborOpti
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 deleteProduct } from "./opportunities/[id]/products/delete";
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";
@@ -22,6 +26,9 @@ 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";
import { default as workflowDispatch } from "./opportunities/[id]/workflow/dispatch";
import { default as workflowStatus } from "./opportunities/[id]/workflow/status";
import { default as workflowHistory } from "./opportunities/[id]/workflow/history";
export {
addProduct,
@@ -29,6 +36,8 @@ export {
laborOptions,
addSpecialOrderProduct,
count,
createOpportunity,
deleteOpportunity,
fetch,
fetchAll,
fetchOpportunityTypes,
@@ -36,6 +45,7 @@ export {
resequenceProducts,
updateProduct,
cancelProduct,
deleteProduct,
notes,
fetchNote,
createNote,
@@ -48,4 +58,8 @@ export {
downloadQuote,
fetchDownloads,
refresh,
updateOpportunity,
workflowDispatch,
workflowStatus,
workflowHistory,
};
@@ -0,0 +1,50 @@
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 */
export default createRoute(
"delete",
["/opportunities/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
try {
await opportunities.deleteItem(identifier);
const response = apiResponse.successful(
"Opportunity deleted successfully!",
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
const isAxios =
err != null && typeof err === "object" && "isAxiosError" in err;
if (isAxios) {
const axiosErr = err as any;
const cwStatus: number = axiosErr.response?.status ?? 502;
const cwMessage: string =
axiosErr.response?.data?.message ??
"Failed to delete the opportunity in ConnectWise";
return c.json(
{
status: cwStatus,
message: cwMessage,
error: "ConnectWiseDeleteError",
successful: false,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
);
}
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.delete"] }),
);
@@ -11,6 +11,7 @@ const productItemSchema = z
catalogItem: z.object({ id: z.number().int().positive() }).optional(),
forecastDescription: z.string().optional(),
productDescription: z.string().optional(),
customerDescription: z.string().nullable().optional(),
quantity: z.number().positive().optional(),
status: z.object({ id: z.number().int().positive() }).optional(),
productClass: z.string().optional(),
@@ -54,7 +55,40 @@ export default createRoute(
);
const item = await opportunities.fetchRecord(identifier);
const created = await item.addProducts(gatedItems);
// Strip customerDescription from forecast payloads — CW only accepts
// it on procurement products, not forecast items.
const customerDescriptions = gatedItems.map(
(g: any) => g.customerDescription,
);
const forecastPayloads = gatedItems.map(
({ customerDescription, ...rest }: any) => rest,
);
const created = await item.addProducts(forecastPayloads);
// If any items included customerDescription, patch the linked
// procurement products after creation. This is best-effort since
// newly created forecast items may not have a linked procurement
// product yet.
const procurementUpdates = created
.map((product, idx) => ({
product,
customerDescription: customerDescriptions[idx],
}))
.filter((entry) => entry.customerDescription != null);
if (procurementUpdates.length > 0) {
await Promise.all(
procurementUpdates.map(({ product, customerDescription }) =>
item
.updateProcurementProductByForecastItem(product.cwForecastId, {
customerDescription,
})
.catch(() => null),
),
);
}
const isBatch = Array.isArray(body);
const response = apiResponse.created(
@@ -0,0 +1,72 @@
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/products/:productId */
export default createRoute(
"delete",
["/opportunities/:identifier/products/:productId"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
if (!Number.isInteger(productId) || productId <= 0) {
throw new GenericError({
status: 400,
name: "InvalidProductId",
message: "productId must be a positive integer",
});
}
const opportunity = await opportunities.fetchRecord(identifier);
// Verify the forecast item exists before attempting deletion
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`,
});
}
try {
await opportunity.deleteProduct(productId);
const response = apiResponse.successful(
"Product deleted from opportunity successfully!",
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
const isAxios =
err != null && typeof err === "object" && "isAxiosError" in err;
if (isAxios) {
const axiosErr = err as any;
const cwStatus: number = axiosErr.response?.status ?? 502;
const cwMessage: string =
axiosErr.response?.data?.message ??
"Failed to delete the product in ConnectWise";
return c.json(
{
status: cwStatus,
message: cwMessage,
error: "ConnectWiseDeleteError",
successful: false,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
);
}
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.product.delete"] }),
);
@@ -18,6 +18,7 @@ const updateProductSchema = z
customerDescription: z.string().nullable().optional(),
productNarrative: z.string().nullable().optional(),
procurementNotes: z.string().nullable().optional(),
taxableFlag: z.boolean().optional(),
})
.strict()
.refine(
@@ -98,12 +99,6 @@ export default createRoute(
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),
@@ -114,6 +109,9 @@ export default createRoute(
(input.unitCost * effectiveQuantity).toFixed(2),
);
}
if (input.taxableFlag !== undefined) {
forecastPatch.taxableFlag = input.taxableFlag;
}
const existingProcurement =
await opportunity.fetchProcurementProductByForecastItem(productId);
@@ -4,6 +4,11 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod";
import { cwMembers } from "../../../../../managers/cwMembers";
import {
createWorkflowActivity,
OptimaType,
} from "../../../../../workflows/wf.opportunity";
const commitQuoteSchema = z
.object({
@@ -29,6 +34,34 @@ export default createRoute(
const quote = await item.commitQuote(opts ?? {}, user);
// Create a workflow activity for the generated quote
try {
let cwMemberId: number | null = null;
if (user.cwIdentifier) {
const cwMember = await cwMembers.fetch(user.cwIdentifier);
cwMemberId = cwMember.cwMemberId;
}
if (cwMemberId) {
await createWorkflowActivity({
name: `[Workflow] Quote generated — ${item.name}`,
opportunityCwId: item.cwOpportunityId,
companyCwId: item.companyCwId,
assignToCwMemberId: cwMemberId,
notes: `Quote "${quote.quoteFileName}" generated.`,
optimaType: OptimaType.QuoteGenerated,
quoteId: quote.id,
});
}
} catch (activityErr) {
console.error(
"[Quote Commit] Failed to create workflow activity:",
activityErr,
);
// Don't fail the quote commit if the activity fails
}
const response = apiResponse.created(
"Quote committed successfully!",
quote.toJson({ includeRegenData: true, includeRegenParams: true }),
@@ -0,0 +1,93 @@
import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
import GenericError from "../../../../Errors/GenericError";
import { z } from "zod";
const updateSchema = z
.object({
name: z.string().min(1).optional(),
notes: z.string().optional(),
rating: z.object({ id: z.number() }).optional(),
type: z.object({ id: z.number() }).optional(),
stage: z.object({ id: z.number() }).optional(),
status: z.object({ id: z.number() }).optional(),
priority: z.object({ id: z.number() }).optional(),
campaign: z.object({ id: z.number() }).optional(),
primarySalesRep: z.object({ id: z.number() }).optional(),
secondarySalesRep: z.object({ id: z.number() }).nullable().optional(),
company: z.object({ id: z.number() }).optional(),
contact: z.object({ id: z.number() }).nullable().optional(),
site: z.object({ id: z.number() }).nullable().optional(),
expectedCloseDate: z
.string()
.optional()
.transform((v) => (v ? new Date(v).toISOString() : v)),
customerPO: z.string().nullable().optional(),
source: z.string().nullable().optional(),
locationId: z.number().optional(),
businessUnitId: z.number().optional(),
})
.refine((d) => Object.values(d).some((v) => v !== undefined), {
message: "At least one field must be provided",
});
/* PATCH /v1/sales/opportunities/:identifier */
export default createRoute(
"patch",
["/opportunities/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const data = updateSchema.parse(body);
const item = await opportunities.fetchRecord(identifier);
try {
const updated = await item.updateOpportunity(data);
const response = apiResponse.successful(
"Opportunity updated successfully!",
updated.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
const isAxios =
err != null && typeof err === "object" && "isAxiosError" in err;
if (isAxios) {
const axiosErr = err as any;
const cwStatus: number = axiosErr.response?.status ?? 502;
const cwData = axiosErr.response?.data;
const cwMessage: string =
cwData?.message ?? "Failed to update the opportunity in ConnectWise";
const cwErrors: unknown[] | undefined = Array.isArray(cwData?.errors)
? cwData.errors
: undefined;
return c.json(
{
status: cwStatus,
message: cwMessage,
error: "ConnectWiseUpdateError",
successful: false,
errors: cwErrors,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
);
}
throw new GenericError({
status: 500,
name: "OpportunitySaveError",
message: "Failed to save opportunity data",
cause: err instanceof Error ? err.message : String(err),
});
}
},
authMiddleware({ permissions: ["sales.opportunity.update"] }),
);
@@ -0,0 +1,192 @@
import { z } from "zod";
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 { opportunities } from "../../../../../managers/opportunities";
import { cwMembers } from "../../../../../managers/cwMembers";
import GenericError from "../../../../../Errors/GenericError";
import {
processOpportunityAction,
type WorkflowAction,
type WorkflowUser,
} from "../../../../../workflows/wf.opportunity";
// ── Zod schemas ───────────────────────────────────────────────────────────
const basePayload = z.object({
note: z.string().optional(),
timeStarted: z.string().datetime().optional(),
timeEnded: z.string().datetime().optional(),
});
const noteRequiredPayload = z.object({
note: z.string().min(1, "A non-empty note is required."),
timeStarted: z.string().datetime().optional(),
timeEnded: z.string().datetime().optional(),
});
const dispatchSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("acceptNew"),
payload: basePayload,
}),
z.object({
action: z.literal("requestReview"),
payload: noteRequiredPayload,
}),
z.object({
action: z.literal("reviewDecision"),
payload: noteRequiredPayload.extend({
decision: z.enum(["approve", "reject", "send", "cancel"]),
}),
}),
z.object({
action: z.literal("sendQuote"),
payload: basePayload.extend({
quoteConfirmed: z.boolean().optional(),
won: z.boolean().optional(),
lost: z.boolean().optional(),
finalize: z.boolean().optional(),
needsRevision: z.boolean().optional(),
}),
}),
z.object({
action: z.literal("confirmQuote"),
payload: basePayload,
}),
z.object({
action: z.literal("finalize"),
payload: noteRequiredPayload.extend({
outcome: z.enum(["won", "lost"]),
}),
}),
z.object({
action: z.literal("resurrect"),
payload: noteRequiredPayload,
}),
z.object({
action: z.literal("beginRevision"),
payload: basePayload,
}),
z.object({
action: z.literal("resendQuote"),
payload: basePayload.extend({
quoteConfirmed: z.boolean().optional(),
won: z.boolean().optional(),
lost: z.boolean().optional(),
finalize: z.boolean().optional(),
needsRevision: z.boolean().optional(),
}),
}),
z.object({
action: z.literal("cancel"),
payload: noteRequiredPayload,
}),
z.object({
action: z.literal("reopen"),
payload: noteRequiredPayload,
}),
]);
// ── Route ─────────────────────────────────────────────────────────────────
/* POST /v1/sales/opportunities/:identifier/workflow */
export default createRoute(
"post",
["/opportunities/:identifier/workflow"],
async (c) => {
try {
const identifier = c.req.param("identifier");
const body = await c.req.json();
console.log(
"[Workflow Dispatch] Raw request body:",
JSON.stringify(body, null, 2),
);
const parsed = dispatchSchema.parse(body);
console.log(
"[Workflow Dispatch] Parsed payload:",
JSON.stringify(parsed.payload, null, 2),
);
const user = c.get("user");
// ── Resolve opportunity ────────────────────────────────────────────
const opportunity = await opportunities.fetchItem(identifier);
// ── Build WorkflowUser ─────────────────────────────────────────────
if (!user.cwIdentifier) {
throw new GenericError({
status: 400,
name: "MissingCwIdentifier",
message:
"Your account is not linked to a ConnectWise member. A CW member association is required to execute workflow actions.",
});
}
const cwMember = await cwMembers.fetch(user.cwIdentifier);
const permissions = await user.readAllPermissions();
const workflowUser: WorkflowUser = {
id: user.id,
cwMemberId: cwMember.cwMemberId,
permissions,
};
// ── Dispatch ───────────────────────────────────────────────────────
const result = await processOpportunityAction(
opportunity,
parsed as WorkflowAction,
workflowUser,
);
if (!result.success) {
console.error(
`[Workflow Dispatch] Transition failed for opportunity "${identifier}":`,
result.error,
);
const response = apiResponse.error(
new GenericError({
status: 422,
name: "WorkflowTransitionFailed",
message: result.error ?? "Workflow action failed.",
}),
);
return c.json(
{
...response,
data: {
previousStatusId: result.previousStatusId,
previousStatus: result.previousStatus,
newStatusId: result.newStatusId,
newStatus: result.newStatus,
},
},
response.status as ContentfulStatusCode,
);
}
const response = apiResponse.successful(
"Workflow action completed successfully.",
{
previousStatusId: result.previousStatusId,
previousStatus: result.previousStatus,
newStatusId: result.newStatusId,
newStatus: result.newStatus,
activitiesCreated: result.activitiesCreated.map((a) => a.toJson()),
coldCheck: result.coldCheck,
},
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err: any) {
console.error("[Workflow Dispatch] Unhandled error:", err);
if (err?.response?.data) {
console.error(
"[Workflow Dispatch] CW response body:",
JSON.stringify(err.response.data, null, 2),
);
}
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.workflow"] }),
);
@@ -0,0 +1,150 @@
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 { opportunities } from "../../../../../managers/opportunities";
import { activityCw } from "../../../../../modules/cw-utils/activities/activities";
import { ActivityController } from "../../../../../controllers/ActivityController";
import { OptimaType } from "../../../../../workflows/wf.opportunity";
// ═══════════════════════════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════════════════════════
const OPTIMA_TYPE_VALUES = new Set<string>([
OptimaType.OpportunityCreated,
OptimaType.OpportunitySetup,
OptimaType.OpportunityReview,
OptimaType.QuoteSent,
OptimaType.QuoteConfirmed,
OptimaType.QuoteSentConfirmed,
OptimaType.QuoteGenerated,
OptimaType.Revision,
OptimaType.Finalized,
OptimaType.Converted,
]);
/** QuoteID custom field ID (matches wf.opportunity.ts QUOTE_ID_FIELD_ID). */
const QUOTE_ID_FIELD_ID = 48;
/** Close Date custom field ID (matches wf.opportunity.ts CLOSE_DATE_FIELD_ID). */
const CLOSE_DATE_FIELD_ID = 49;
/**
* Extract the Optima_Type value from a CW activity's custom fields.
* Returns the string value if present, or null.
*/
function extractOptimaType(
customFields: { id: number; value: unknown }[] | undefined,
): string | null {
if (!customFields) return null;
const field = customFields.find((f) => f.id === OptimaType.FIELD_ID);
if (!field?.value || typeof field.value !== "string") return null;
return OPTIMA_TYPE_VALUES.has(field.value) ? field.value : null;
}
/**
* Extract the QuoteID custom field value from a CW activity.
* Returns the string value or null.
*/
function extractQuoteId(
customFields: { id: number; value: unknown }[] | undefined,
): string | null {
if (!customFields) return null;
const field = customFields.find((f) => f.id === QUOTE_ID_FIELD_ID);
if (!field?.value || typeof field.value !== "string") return null;
return field.value;
}
/**
* Extract the Close Date custom field value from a CW activity.
* Returns the ISO-8601 string or null.
*/
function extractCloseDate(
customFields: { id: number; value: unknown }[] | undefined,
): string | null {
if (!customFields) return null;
const field = customFields.find((f) => f.id === CLOSE_DATE_FIELD_ID);
if (!field?.value || typeof field.value !== "string") return null;
return field.value;
}
// ═══════════════════════════════════════════════════════════════════════════
// ROUTE
// ═══════════════════════════════════════════════════════════════════════════
/* GET /v1/sales/opportunities/:identifier/workflow/history */
export default createRoute(
"get",
["/opportunities/:identifier/workflow/history"],
async (c) => {
try {
const identifier = c.req.param("identifier");
const filterType = c.req.query("type") ?? null; // optional filter by Optima_Type value
// Resolve the opportunity to get the CW opportunity ID
const opportunity = await opportunities.fetchItem(identifier);
// Fetch all activities for this opportunity from CW
const activitiesCollection = await activityCw.fetchByOpportunity(
opportunity.cwOpportunityId,
);
// Filter to workflow activities (those with a valid Optima_Type)
const workflowActivities: {
activity: ReturnType<ActivityController["toJson"]>;
optimaType: string;
quoteId: string | null;
closed: boolean;
closedAt: string | null;
}[] = [];
for (const [, raw] of activitiesCollection) {
const controller = new ActivityController(raw);
const json = controller.toJson();
const optimaType = extractOptimaType(raw.customFields);
if (!optimaType) continue;
if (filterType && optimaType !== filterType) continue;
const quoteId = extractQuoteId(raw.customFields);
const closed = raw.status?.id === 2;
const closedAt = extractCloseDate(raw.customFields);
workflowActivities.push({
activity: json,
optimaType,
quoteId,
closed,
closedAt,
});
}
// Sort newest first
workflowActivities.sort((a, b) => {
const dateA = new Date(
a.activity.dateEnd ?? a.activity.dateStart ?? 0,
).getTime();
const dateB = new Date(
b.activity.dateEnd ?? b.activity.dateStart ?? 0,
).getTime();
return dateB - dateA;
});
const response = apiResponse.successful(
"Workflow history fetched successfully.",
{
opportunityId: opportunity.id,
cwOpportunityId: opportunity.cwOpportunityId,
totalActivities: workflowActivities.length,
activities: workflowActivities,
},
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
console.error("[Workflow History] Unhandled error:", err);
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
@@ -0,0 +1,377 @@
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 { opportunities } from "../../../../../managers/opportunities";
import {
OpportunityStatus,
StatusIdToKey,
WorkflowPermissions,
type OpportunityStatusKey,
} from "../../../../../workflows/wf.opportunity";
import {
checkColdStatus,
type ColdCheckResult,
} from "../../../../../modules/algorithms/algo.coldThreshold";
// ═══════════════════════════════════════════════════════════════════════════
// ACTION AVAILABILITY MAP
// ═══════════════════════════════════════════════════════════════════════════
/**
* Per-status list of actions the user can invoke.
*
* Each entry describes the action key, a human-readable label, the
* expected target status(es), whether a note is required, and any
* permission gate beyond the base workflow permission.
*/
interface AvailableAction {
action: string;
label: string;
targetStatuses: { key: OpportunityStatusKey; id: number }[];
requiresNote: boolean;
requiresPermission: string | null;
/** Extra payload fields that can/must be provided. */
payloadHints?: Record<string, string>;
}
const ACTION_MAP: Record<number, AvailableAction[]> = {
[OpportunityStatus.PendingNew]: [
{
action: "acceptNew",
label: "Accept / Set Up Opportunity",
targetStatuses: [{ key: "New", id: OpportunityStatus.New }],
requiresNote: false,
requiresPermission: null,
},
],
[OpportunityStatus.New]: [
{
action: "sendQuote",
label: "Send Quote (skip review)",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: false,
requiresPermission: null,
payloadHints: {
quoteConfirmed: "boolean — mark confirmed simultaneously",
won: "boolean — immediate win",
lost: "boolean — immediate rejection",
needsRevision: "boolean — needs revision",
},
},
{
action: "requestReview",
label: "Send to Internal Review",
targetStatuses: [
{ key: "InternalReview", id: OpportunityStatus.InternalReview },
],
requiresNote: true,
requiresPermission: null,
},
{
action: "cancel",
label: "Cancel Opportunity",
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
requiresNote: true,
requiresPermission: WorkflowPermissions.CANCEL,
},
],
[OpportunityStatus.InternalReview]: [
{
action: "reviewDecision",
label: "Approve (move to Pending Sent)",
targetStatuses: [
{ key: "PendingSent", id: OpportunityStatus.PendingSent },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { decision: '"approve"' },
},
{
action: "reviewDecision",
label: "Reject (move to Pending Revision)",
targetStatuses: [
{ key: "PendingRevision", id: OpportunityStatus.PendingRevision },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { decision: '"reject"' },
},
{
action: "reviewDecision",
label: "Send Quote (reviewer sends directly)",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: true,
requiresPermission: null,
payloadHints: { decision: '"send"' },
},
{
action: "reviewDecision",
label: "Cancel from Review",
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
requiresNote: true,
requiresPermission: WorkflowPermissions.CANCEL,
payloadHints: { decision: '"cancel"' },
},
],
[OpportunityStatus.PendingSent]: [
{
action: "sendQuote",
label: "Send Quote to Customer",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: false,
requiresPermission: null,
payloadHints: {
quoteConfirmed: "boolean — mark confirmed simultaneously",
won: "boolean — immediate win",
lost: "boolean — immediate rejection",
needsRevision: "boolean — needs revision",
},
},
],
[OpportunityStatus.PendingRevision]: [
{
action: "beginRevision",
label: "Begin Revision",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: false,
requiresPermission: null,
},
],
[OpportunityStatus.QuoteSent]: [
{
action: "confirmQuote",
label: "Confirm Quote Receipt",
targetStatuses: [
{ key: "ConfirmedQuote", id: OpportunityStatus.ConfirmedQuote },
],
requiresNote: false,
requiresPermission: null,
},
{
action: "finalize",
label: "Mark as Won",
targetStatuses: [
{ key: "Won", id: OpportunityStatus.Won },
{ key: "PendingWon", id: OpportunityStatus.PendingWon },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { outcome: '"won"' },
},
{
action: "finalize",
label: "Mark as Lost",
targetStatuses: [
{ key: "PendingLost", id: OpportunityStatus.PendingLost },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { outcome: '"lost"' },
},
{
action: "resendQuote",
label: "Revise & Re-send (back to Active)",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: false,
requiresPermission: null,
payloadHints: { needsRevision: "true" },
},
],
[OpportunityStatus.ConfirmedQuote]: [
{
action: "finalize",
label: "Mark as Won",
targetStatuses: [
{ key: "Won", id: OpportunityStatus.Won },
{ key: "PendingWon", id: OpportunityStatus.PendingWon },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { outcome: '"won"' },
},
{
action: "finalize",
label: "Mark as Lost",
targetStatuses: [
{ key: "PendingLost", id: OpportunityStatus.PendingLost },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { outcome: '"lost"' },
},
{
action: "resendQuote",
label: "Revise & Re-send (back to Active)",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: false,
requiresPermission: null,
payloadHints: { needsRevision: "true" },
},
],
[OpportunityStatus.Active]: [
{
action: "resendQuote",
label: "Send Revised Quote",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: false,
requiresPermission: null,
payloadHints: {
quoteConfirmed: "boolean — mark confirmed simultaneously",
won: "boolean — immediate win",
lost: "boolean — immediate rejection",
},
},
{
action: "requestReview",
label: "Send to Internal Review",
targetStatuses: [
{ key: "InternalReview", id: OpportunityStatus.InternalReview },
],
requiresNote: true,
requiresPermission: null,
},
{
action: "cancel",
label: "Cancel Opportunity",
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
requiresNote: true,
requiresPermission: WorkflowPermissions.CANCEL,
},
],
[OpportunityStatus.PendingWon]: [
{
action: "finalize",
label: "Approve Won",
targetStatuses: [{ key: "Won", id: OpportunityStatus.Won }],
requiresNote: true,
requiresPermission: WorkflowPermissions.FINALIZE,
payloadHints: { outcome: '"won"' },
},
],
[OpportunityStatus.PendingLost]: [
{
action: "finalize",
label: "Approve Lost",
targetStatuses: [{ key: "Lost", id: OpportunityStatus.Lost }],
requiresNote: true,
requiresPermission: WorkflowPermissions.FINALIZE,
payloadHints: { outcome: '"lost"' },
},
{
action: "resurrect",
label: "Resurrect (back to Active)",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: true,
requiresPermission: null,
},
],
[OpportunityStatus.Won]: [],
[OpportunityStatus.Lost]: [],
[OpportunityStatus.Canceled]: [
{
action: "reopen",
label: "Re-open Opportunity",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: true,
requiresPermission: null,
},
],
};
// ═══════════════════════════════════════════════════════════════════════════
// ROUTE
// ═══════════════════════════════════════════════════════════════════════════
/* GET /v1/sales/opportunities/:identifier/workflow */
export default createRoute(
"get",
["/opportunities/:identifier/workflow"],
async (c) => {
try {
const identifier = c.req.param("identifier");
const user = c.get("user");
const opportunity = await opportunities.fetchItem(identifier);
const statusCwId = opportunity.statusCwId;
const statusKey: OpportunityStatusKey | null =
statusCwId != null ? (StatusIdToKey[statusCwId] ?? null) : null;
const isOptimaStage = opportunity.stageName === "Optima";
const isTerminal =
statusCwId === OpportunityStatus.Won ||
statusCwId === OpportunityStatus.Lost;
// ── Resolve available actions (permission-aware) ──────────────────
const rawActions =
statusCwId != null ? (ACTION_MAP[statusCwId] ?? []) : [];
const resolvedActions = await Promise.all(
rawActions.map(async (a) => {
const hasGate =
!a.requiresPermission ||
(await user.hasPermission(a.requiresPermission));
return { ...a, permitted: hasGate };
}),
);
// ── Cold check (only for QuoteSent / ConfirmedQuote) ──────────────
let coldCheck: ColdCheckResult | null = null;
if (
statusCwId === OpportunityStatus.QuoteSent ||
statusCwId === OpportunityStatus.ConfirmedQuote
) {
// Fetch activities to determine latest activity date
const activities = await opportunity.fetchActivities();
const latestDate =
activities.length > 0
? new Date(
Math.max(
...activities.map((a) => {
const json = a.toJson();
return new Date(
json.dateEnd ?? json.dateStart ?? 0,
).getTime();
}),
),
)
: null;
coldCheck = checkColdStatus({
statusCwId,
lastActivityDate: latestDate,
});
}
const response = apiResponse.successful(
"Workflow status fetched successfully.",
{
currentStatusId: statusCwId,
currentStatus: statusKey,
stageName: opportunity.stageName ?? null,
isOptimaStage,
isTerminal,
availableActions: isOptimaStage ? resolvedActions : [],
coldCheck,
},
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
console.error("[Workflow Status] Unhandled error:", err);
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
+119
View File
@@ -0,0 +1,119 @@
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";
import { cwMembers } from "../../../managers/cwMembers";
import {
createWorkflowActivity,
OptimaType,
} from "../../../workflows/wf.opportunity";
const createSchema = z.object({
name: z.string().min(1),
expectedCloseDate: z
.string()
.min(1)
.transform((v) => new Date(v).toISOString().replace(/\.\d{3}Z$/, "Z")),
notes: z.string().optional(),
rating: z.object({ id: z.number() }).optional(),
type: z.object({ id: z.number() }).optional(),
stage: z.object({ id: z.number() }).optional(),
status: z.object({ id: z.number() }).optional(),
priority: z.object({ id: z.number() }).optional(),
campaign: z.object({ id: z.number() }).optional(),
primarySalesRep: z.object({ id: z.number() }),
secondarySalesRep: z.object({ id: z.number() }).nullable().optional(),
company: z.object({ id: z.number() }),
contact: z.object({ id: z.number() }),
site: z.object({ id: z.number() }).nullable().optional(),
source: z.string().nullable().optional(),
customerPO: z.string().nullable().optional(),
locationId: z.number().optional(),
businessUnitId: z.number().optional(),
});
/* POST /v1/sales/opportunities */
export default createRoute(
"post",
["/opportunities"],
async (c) => {
const body = await c.req.json();
const data = createSchema.parse(body);
try {
const item = await opportunities.createItem(data);
// Create a workflow activity for the new opportunity
try {
const user = c.get("user");
let cwMemberId: number | null = null;
if (user.cwIdentifier) {
const cwMember = await cwMembers.fetch(user.cwIdentifier);
cwMemberId = cwMember.cwMemberId;
}
if (cwMemberId) {
await createWorkflowActivity({
name: `[Workflow] Opportunity created — ${item.name}`,
opportunityCwId: item.cwOpportunityId,
companyCwId: item.companyCwId,
assignToCwMemberId: cwMemberId,
notes: "Opportunity created.",
optimaType: OptimaType.OpportunityCreated,
});
}
} catch (activityErr) {
console.error(
"[Opportunity Create] Failed to create workflow activity:",
activityErr,
);
// Don't fail the opportunity creation if the activity fails
}
const response = apiResponse.created(
"Opportunity created successfully!",
item.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
const isAxios =
err != null && typeof err === "object" && "isAxiosError" in err;
if (isAxios) {
const axiosErr = err as any;
const cwStatus: number = axiosErr.response?.status ?? 502;
const cwData = axiosErr.response?.data;
const cwMessage: string =
cwData?.message ?? "Failed to create the opportunity in ConnectWise";
const cwErrors: unknown[] | undefined = Array.isArray(cwData?.errors)
? cwData.errors
: undefined;
return c.json(
{
status: cwStatus,
message: cwMessage,
error: "ConnectWiseCreateError",
successful: false,
errors: cwErrors,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
);
}
throw new GenericError({
status: 500,
name: "OpportunityCreateError",
message: "Failed to create opportunity",
cause: err instanceof Error ? err.message : String(err),
});
}
},
authMiddleware({ permissions: ["sales.opportunity.create"] }),
);
+86
View File
@@ -0,0 +1,86 @@
import type { CwMember } from "../../generated/prisma/client";
import type { CWMember } from "../modules/cw-utils/members/fetchAllMembers";
/**
* CW Member Controller
*
* Domain model class that encapsulates a ConnectWise Member entity,
* providing access to member data and serialization for the API.
*/
export class CwMemberController {
public readonly id: string;
public readonly cwMemberId: number;
public readonly identifier: string;
public firstName: string;
public lastName: string;
public officeEmail: string | null;
public inactiveFlag: boolean;
public apiKey: string | null;
public cwLastUpdated: Date | null;
public readonly createdAt: Date;
public readonly updatedAt: Date;
constructor(data: CwMember) {
this.id = data.id;
this.cwMemberId = data.cwMemberId;
this.identifier = data.identifier;
this.firstName = data.firstName;
this.lastName = data.lastName;
this.officeEmail = data.officeEmail;
this.inactiveFlag = data.inactiveFlag;
this.apiKey = data.apiKey;
this.cwLastUpdated = data.cwLastUpdated;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
/**
* Full Name
*
* Returns the member's full name, falling back to the identifier.
*/
public get fullName(): string {
const name = `${this.firstName} ${this.lastName}`.trim();
return name || this.identifier;
}
/**
* Map CW Member Prisma create/update payload
*
* Static helper used by both the controller and the refresh sync.
*/
public static mapCwToDb(item: CWMember) {
return {
identifier: item.identifier,
firstName: item.firstName ?? "",
lastName: item.lastName ?? "",
officeEmail: item.officeEmail ?? null,
inactiveFlag: item.inactiveFlag ?? false,
cwLastUpdated: item._info?.lastUpdated
? new Date(item._info.lastUpdated)
: new Date(),
};
}
/**
* To JSON
*
* Serializes the member into a safe, API-friendly object.
*/
public toJson(): Record<string, any> {
return {
id: this.id,
cwMemberId: this.cwMemberId,
identifier: this.identifier,
firstName: this.firstName,
lastName: this.lastName,
fullName: this.fullName,
officeEmail: this.officeEmail,
inactiveFlag: this.inactiveFlag,
apiKey: this.apiKey,
cwLastUpdated: this.cwLastUpdated,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}
+72 -2
View File
@@ -14,6 +14,7 @@ import {
CWForecastItemCreate,
CWOpportunity,
CWOpportunityNote,
CWOpportunityUpdate,
CWProcurementProduct,
CWProcurementProductCreate,
} from "../modules/cw-utils/opportunities/opportunity.types";
@@ -292,6 +293,30 @@ export class OpportunityController {
return new OpportunityController(updated);
}
/**
* Update Opportunity
*
* Patches the opportunity in ConnectWise with the provided fields,
* then syncs the updated data back to the local database.
*
* @param data Partial fields to update on the CW opportunity
* @returns A fresh OpportunityController with the updated data
*/
public async updateOpportunity(
data: CWOpportunityUpdate,
): Promise<OpportunityController> {
const cwData = await opportunityCw.update(this.cwOpportunityId, data);
const mapped = OpportunityController.mapCwToDb(cwData);
const updated = await prisma.opportunity.update({
where: { id: this.id },
data: mapped,
include: { company: true },
});
return new OpportunityController(updated);
}
/**
* Fetch raw CW data
*
@@ -693,11 +718,20 @@ export class OpportunityController {
await this._hydrateCustomFields();
const quoteNarrative = options.includeQuoteNarrative
const quoteNarrativeField = options.includeQuoteNarrative
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
undefined
: undefined;
// Fall back to the customerDescription of a QUO-Narrative product
const quoNarrativeProduct = !quoteNarrativeField
? activeProducts.find((p) => p.catalogItemIdentifier === "QUO-Narrative")
: undefined;
const quoteNarrative =
quoteNarrativeField ??
quoNarrativeProduct?.customerDescription ??
undefined;
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
@@ -793,11 +827,18 @@ export class OpportunityController {
const salesRep = await this._resolveSalesRep();
await this._hydrateCustomFields();
const quoteNarrative = quoteOptions.includeQuoteNarrative
const quoteNarrativeField = quoteOptions.includeQuoteNarrative
? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ??
null)
: null;
// Fall back to the customerDescription of a QUO-Narrative product
const quoNarrativeProduct = !quoteNarrativeField
? products.find((p) => p.catalogItemIdentifier === "QUO-Narrative")
: undefined;
const quoteNarrative =
quoteNarrativeField ?? quoNarrativeProduct?.customerDescription ?? null;
// ── Pre-generate IDs & timestamps for metadata ───────────────────
const quoteId = crypto.randomUUID();
const createdAt = new Date().toISOString();
@@ -1326,6 +1367,35 @@ export class OpportunityController {
}
}
/**
* Delete Product
*
* Removes a forecast item from this opportunity in ConnectWise,
* removes the item ID from the local productSequence, and
* invalidates the products cache.
*
* @param forecastItemId - The CW forecast item ID to delete
*/
public async deleteProduct(forecastItemId: number): Promise<void> {
await opportunityCw.deleteProduct(this.cwOpportunityId, forecastItemId);
// Remove the deleted item from the local product sequence
if (this.productSequence.includes(forecastItemId)) {
const updatedSequence = this.productSequence.filter(
(id) => id !== forecastItemId,
);
await prisma.opportunity.update({
where: { id: this.id },
data: { productSequence: updatedSequence },
});
this.productSequence = updatedSequence;
}
await invalidateProductsCache(this.cwOpportunityId);
}
/**
* Fetch Procurement Product By Forecast Item
*
+12
View File
@@ -17,6 +17,7 @@ import { listenInventoryAdjustments } from "./modules/cw-utils/procurement/liste
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
import { refreshCwMembers } from "./modules/cw-utils/members/refreshCwMembers";
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
import { events, setupEventDebugger } from "./modules/globalEvents";
import { signPermissions } from "./modules/permission-utils/signPermissions";
@@ -188,6 +189,17 @@ setInterval(
30 * 60 * 1000,
);
// Refresh CW members DB table every hour
await safeStartup("refreshCwMembers", refreshCwMembers);
setInterval(
() => {
return refreshCwMembers().catch((err) =>
console.error(`[interval] refreshCwMembers failed: ${briefErr(err)}`),
);
},
60 * 60 * 1000,
);
await safeStartup("syncSites", () => unifiSites.syncSites());
setInterval(() => {
return unifiSites
+77
View File
@@ -0,0 +1,77 @@
import { prisma } from "../constants";
import { CwMemberController } from "../controllers/CwMemberController";
import GenericError from "../Errors/GenericError";
/**
* CW Members Manager
*
* Thin persistence layer wrapping Prisma calls for the CwMember model.
* Returns CwMemberController instances as domain objects.
*/
export const cwMembers = {
/**
* Fetch a single CW member by internal ID, CW member ID, or identifier.
*/
fetch: async (idOrIdentifier: string): Promise<CwMemberController> => {
const isNumeric = /^\d+$/.test(idOrIdentifier);
const record = await prisma.cwMember.findFirst({
where: isNumeric
? { cwMemberId: Number(idOrIdentifier) }
: {
OR: [{ id: idOrIdentifier }, { identifier: idOrIdentifier }],
},
});
if (!record) {
throw new GenericError({
status: 404,
name: "CwMemberNotFound",
message: `CW Member "${idOrIdentifier}" not found`,
});
}
return new CwMemberController(record);
},
/**
* Fetch all CW members with optional filtering.
*/
fetchAll: async (opts?: {
includeInactive?: boolean;
}): Promise<CwMemberController[]> => {
const where = opts?.includeInactive ? {} : { inactiveFlag: false };
const records = await prisma.cwMember.findMany({
where,
orderBy: { lastName: "asc" },
});
return records.map((r) => new CwMemberController(r));
},
/**
* Count CW members.
*/
count: async (opts?: { includeInactive?: boolean }): Promise<number> => {
const where = opts?.includeInactive ? {} : { inactiveFlag: false };
return prisma.cwMember.count({ where });
},
/**
* Update the API key for a CW member.
*/
updateApiKey: async (
idOrIdentifier: string,
apiKey: string | null,
): Promise<CwMemberController> => {
const member = await cwMembers.fetch(idOrIdentifier);
const updated = await prisma.cwMember.update({
where: { id: member.id },
data: { apiKey },
});
return new CwMemberController(updated);
},
};
+79
View File
@@ -6,6 +6,7 @@ import { OpportunityController } from "../controllers/OpportunityController";
import GenericError from "../Errors/GenericError";
import { activityCw } from "../modules/cw-utils/activities/activities";
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
import { CWOpportunityCreate } from "../modules/cw-utils/opportunities/opportunity.types";
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
import {
getCachedActivities,
@@ -14,6 +15,7 @@ import {
fetchAndCacheActivities,
fetchAndCacheCompanyCwData,
fetchAndCacheOppCwData,
invalidateAllOpportunityCaches,
} from "../modules/cache/opportunityCache";
// ---------------------------------------------------------------------------
@@ -127,6 +129,45 @@ async function buildActivities(
}
export const opportunities = {
/**
* Create Opportunity
*
* Creates a new opportunity in ConnectWise, then stores the resulting
* record in the local database and returns an OpportunityController.
*
* @param data Fields required by the ConnectWise `POST /sales/opportunities` endpoint
* @returns {Promise<OpportunityController>}
*/
async createItem(data: CWOpportunityCreate): Promise<OpportunityController> {
const cwData = await opportunityCw.create(data);
const mapped = OpportunityController.mapCwToDb(cwData);
// Resolve optional local company relation
const companyId = cwData.company?.id
? ((
await prisma.company.findFirst({
where: { cw_CompanyId: cwData.company.id },
select: { id: true },
})
)?.id ?? null)
: null;
const record = await prisma.opportunity.create({
data: {
cwOpportunityId: cwData.id,
...mapped,
companyId,
},
include: { company: true },
});
return new OpportunityController(record, {
company: record.company
? new CompanyController(record.company)
: undefined,
});
},
/**
* Fetch Record (lightweight)
*
@@ -484,4 +525,42 @@ export const opportunities = {
}),
);
},
/**
* Delete Opportunity
*
* Deletes an opportunity from ConnectWise, removes the local database
* record, and invalidates all related Redis caches.
*
* @param identifier - Internal ID (string) or CW opportunity ID (number)
* @returns {Promise<void>}
*/
async deleteItem(identifier: string | number): Promise<void> {
const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier));
const record = await prisma.opportunity.findFirst({
where: isNumeric
? { cwOpportunityId: Number(identifier) }
: { id: identifier as string },
});
if (!record) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
// Delete from ConnectWise first
await opportunityCw.delete(record.cwOpportunityId);
// Remove the local DB record
await prisma.opportunity.delete({ where: { id: record.id } });
// Invalidate all related caches
await invalidateAllOpportunityCaches(record.cwOpportunityId);
},
};
+1 -1
View File
@@ -99,7 +99,7 @@ export const users = {
const newUser = await prisma.user.create({
data: {
userId: msData.id,
email: msData.mail,
email: msData.mail ?? msData.userPrincipalName,
name: `${msData.givenName} ${msData.surname}`,
login: msData.userPrincipalName,
cwIdentifier,
@@ -0,0 +1,102 @@
/**
* @module algo.coldThreshold
*
* Cold-Detection Algorithm
* ========================
*
* Determines whether an opportunity has stalled in a status long enough
* to be considered "cold". When an opportunity goes cold it is
* automatically moved to InternalReview, a system-generated activity is
* logged, and it is flagged for the internal review report.
*
* ## Thresholds (defaults)
*
* | Status | Stall Threshold |
* |-----------------|-----------------|
* | QuoteSent | 14 days |
* | ConfirmedQuote | 30 days |
*
* Only these two statuses are eligible for cold detection. All other
* statuses return `cold: false`.
*
* ## How "last activity date" is determined
*
* The algorithm uses `lastActivityDate` the most recent of:
* - the latest activity's `dateStart`
* - the opportunity's `cwLastUpdated`
*
* The caller is responsible for resolving this value before calling
* `checkColdStatus`.
*/
import type { OpportunityController } from "../../controllers/OpportunityController";
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
/** Stall thresholds in milliseconds, keyed by CW status ID. */
export const COLD_THRESHOLDS: Record<number, { days: number; ms: number }> = {
/** QuoteSent — CW status ID 43, "03. Quote Sent" */
43: { days: 14, ms: 14 * 24 * 60 * 60 * 1000 },
/** ConfirmedQuote — CW status ID 57, "04. Confirmed Quote" */
57: { days: 30, ms: 30 * 24 * 60 * 60 * 1000 },
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ColdCheckInput {
/** Current CW status ID of the opportunity. */
statusCwId: number | null;
/**
* The most recent meaningful date to measure staleness from.
* Typically the latest of the last activity dateStart or cwLastUpdated.
*/
lastActivityDate: Date | null;
/** Override for "now" — useful for testing. Defaults to `new Date()`. */
now?: Date;
}
export interface ColdCheckResult {
/** Whether the opportunity is considered cold. */
cold: boolean;
/**
* Which threshold triggered the cold flag.
* `null` when `cold` is `false`.
*/
triggeredBy: {
statusCwId: number;
statusName: string;
thresholdDays: number;
staleDays: number;
} | null;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const STATUS_NAMES: Record<number, string> = {
43: "QuoteSent",
57: "ConfirmedQuote",
};
// ---------------------------------------------------------------------------
// Core
// ---------------------------------------------------------------------------
/**
* Evaluate whether an opportunity has exceeded its cold-stall threshold.
*
* @returns A `ColdCheckResult` indicating cold status and trigger metadata.
*/
export function checkColdStatus(_input: ColdCheckInput): ColdCheckResult {
// Bypassed — always returns not-cold until cold-stall feature is ready
return { cold: false, triggeredBy: null };
}
@@ -0,0 +1,97 @@
/**
* @module algo.followUpScheduler
*
* Follow-Up Scheduling Algorithm
* ===============================
*
* Determines the due date for follow-up activities created by the
* opportunity workflow. The follow-up is always assigned to the user
* who triggered its creation.
*
* ## TODO Calendar-aware scheduling
*
* This module currently uses a **dummy algorithm** that schedules the
* follow-up for the next business day at 10:00 AM local time.
*
* It needs to be replaced with an availability-aware algorithm that:
* 1. Reads the assigned user's calendar (Microsoft Graph / CW schedule).
* 2. Finds the earliest open slot of sufficient duration.
* 3. Respects company-wide blackout dates (holidays, company events).
* 4. Accounts for the user's working-hours preferences.
*
* Until that integration is complete, the simple "next business day"
* heuristic is used as a placeholder.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface FollowUpScheduleInput {
/** The user who triggered the activity (follow-up is assigned to them). */
triggeredByUserId: string;
/** Optional override for "now" — useful for testing. */
now?: Date;
}
export interface FollowUpScheduleResult {
/** Suggested due date for the follow-up activity. */
dueDate: Date;
/** ISO string version for CW API payloads. */
dueDateIso: string;
}
// ---------------------------------------------------------------------------
// Core
// ---------------------------------------------------------------------------
/**
* Schedule a follow-up activity.
*
* Returns a suggested `dueDate` for the follow-up activity.
* Currently uses dummy logic: next business day at 10:00 AM.
*
* @param input - Scheduling parameters
* @returns The scheduled follow-up date
*/
export function scheduleFollowUp(
input: FollowUpScheduleInput,
): FollowUpScheduleResult {
const now = input.now ?? new Date();
const dueDate = getNextBusinessDay(now);
// Set to 10:00 AM
dueDate.setHours(10, 0, 0, 0);
return {
dueDate,
dueDateIso: dueDate.toISOString(),
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Returns the next business day (MonFri) from the given date.
* If the given date is already a weekday before 10 AM, returns
* the NEXT business day (not the same day).
*/
function getNextBusinessDay(from: Date): Date {
const result = new Date(from);
// Always advance at least one day
result.setDate(result.getDate() + 1);
const day = result.getDay();
// Saturday → Monday (+2)
if (day === 6) result.setDate(result.getDate() + 2);
// Sunday → Monday (+1)
if (day === 0) result.setDate(result.getDate() + 1);
return result;
}
+18
View File
@@ -499,6 +499,24 @@ export async function invalidateProductsCache(
await redis.del(productsCacheKey(cwOpportunityId));
}
/**
* Invalidate all cached data for an opportunity.
*
* Removes activities, notes, contacts, products, and CW data cache keys.
* Call this when an opportunity is deleted.
*/
export async function invalidateAllOpportunityCaches(
cwOpportunityId: number,
): Promise<void> {
await redis.del(
activityCacheKey(cwOpportunityId),
notesCacheKey(cwOpportunityId),
contactsCacheKey(cwOpportunityId),
productsCacheKey(cwOpportunityId),
oppCwDataCacheKey(cwOpportunityId),
);
}
/**
* Site TTL 20 minutes. Site/address data rarely changes so we cache
* aggressively. The background refresh does NOT proactively warm site keys;
@@ -17,20 +17,26 @@ export interface CWMember {
* Fetches every member from ConnectWise using pagination and returns them
* in a Collection keyed by their identifier (e.g. "jroberts").
*
* @param opts.conditions - Optional CW conditions string to filter members
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
*/
export const fetchAllCwMembers = async (): Promise<
Collection<string, CWMember>
> => {
export const fetchAllCwMembers = async (opts?: {
conditions?: string;
}): Promise<Collection<string, CWMember>> => {
const members = new Collection<string, CWMember>();
const pageSize = 1000;
const conditionsParam = opts?.conditions
? `&conditions=${encodeURIComponent(opts.conditions)}`
: "";
const { data: countData } = await connectWiseApi.get("/system/members/count");
const { data: countData } = await connectWiseApi.get(
`/system/members/count${conditionsParam ? `?${conditionsParam.slice(1)}` : ""}`,
);
const totalPages = Math.ceil(countData.count / pageSize);
for (let page = 0; page < totalPages; page++) {
const { data } = await connectWiseApi.get<CWMember[]>(
`/system/members?page=${page + 1}&pageSize=${pageSize}`,
`/system/members?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
);
for (const member of data) {
@@ -0,0 +1,106 @@
import { prisma } from "../../../constants";
import { events } from "../../globalEvents";
import { fetchAllCwMembers, type CWMember } from "./fetchAllMembers";
import { setMemberCache } from "./memberCache";
import { CwMemberController } from "../../../controllers/CwMemberController";
/**
* Is Regular User
*
* Returns true if the CW member looks like a real person rather than
* a service account (e.g. "labtech", "Admin"). A regular user must
* have a last name and an email address.
*/
const isRegularUser = (member: CWMember): boolean =>
!member.inactiveFlag &&
Boolean(member.lastName?.trim()) &&
Boolean(member.officeEmail?.trim());
/**
* Refresh CW Members
*
* Syncs local CwMember records with ConnectWise using a stale-check
* pattern:
* 1. Fetch all members from CW
* 2. Filter to regular users (active, non-service accounts)
* 3. Compare against local cwLastUpdated timestamps
* 4. Upsert stale/new records
* 5. Also refreshes the in-memory member cache
*/
export const refreshCwMembers = async () => {
events.emit("cw:members:db:refresh:check");
// 1. Fetch all members from CW
const allCwMembers = await fetchAllCwMembers();
// Also refresh the in-memory cache with ALL members (used for name resolution)
await setMemberCache(allCwMembers);
// 2. Filter to regular users only (active, has last name + email)
const cwMembers = allCwMembers.filter(isRegularUser);
// 2. Fetch all DB records with their identifier and cwLastUpdated
const dbItems = await prisma.cwMember.findMany({
select: { cwMemberId: true, cwLastUpdated: true },
});
const dbMap = new Map(
dbItems.map((item) => [item.cwMemberId, item.cwLastUpdated]),
);
// 3. Determine stale / new IDs
const staleIds: number[] = [];
for (const [, member] of cwMembers) {
const cwLastUpdated = member._info?.lastUpdated
? new Date(member._info.lastUpdated)
: null;
const dbLastUpdated = dbMap.get(member.id) ?? null;
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
staleIds.push(member.id);
}
}
if (staleIds.length === 0) {
events.emit("cw:members:db:refresh:skipped", {
totalCw: cwMembers.size,
totalDb: dbItems.length,
staleCount: 0,
});
return;
}
events.emit("cw:members:db:refresh:started", {
totalCw: cwMembers.size,
totalDb: dbItems.length,
staleCount: staleIds.length,
});
// 4. Upsert stale/new items
const staleIdSet = new Set(staleIds);
const updatedCount = (
await Promise.all(
[...cwMembers.values()]
.filter((m) => staleIdSet.has(m.id))
.map(async (member) => {
const mapped = CwMemberController.mapCwToDb(member);
return prisma.cwMember.upsert({
where: { cwMemberId: member.id },
create: {
cwMemberId: member.id,
...mapped,
},
update: mapped,
});
}),
)
).filter(Boolean).length;
events.emit("cw:members:db:refresh:completed", {
totalCw: cwMembers.size,
totalDb: dbItems.length,
staleCount: staleIds.length,
itemsUpdated: updatedCount,
});
};
@@ -2,6 +2,7 @@ import { Collection } from "@discordjs/collection";
import { connectWiseApi } from "../../../constants";
import {
CWOpportunity,
CWOpportunityCreate,
CWOpportunitySummary,
CWForecast,
CWForecastItem,
@@ -12,6 +13,7 @@ import {
CWOpportunityNoteCreate,
CWOpportunityNoteUpdate,
CWOpportunityContact,
CWOpportunityUpdate,
} from "./opportunity.types";
export const opportunityCw = {
@@ -100,6 +102,45 @@ export const opportunityCw = {
return response.data;
},
/**
* Create Opportunity
*
* Creates a new opportunity in ConnectWise via POST.
* Strips null/undefined values from the payload CW rejects
* null reference objects on create; omitting them lets CW apply
* its own defaults.
*/
create: async (data: CWOpportunityCreate): Promise<CWOpportunity> => {
const cleaned = Object.fromEntries(
Object.entries(data).filter(([, v]) => v != null),
);
const response = await connectWiseApi.post("/sales/opportunities", cleaned);
return response.data;
},
/**
* Update Opportunity
*
* Applies a JSON Patch update to an opportunity record in ConnectWise.
* Each key in `data` produces a replace operation.
*/
update: async (
opportunityId: number,
data: CWOpportunityUpdate,
): Promise<CWOpportunity> => {
const operations = Object.entries(data).map(([key, value]) => ({
op: "replace" as const,
path: key,
value,
}));
const response = await connectWiseApi.patch(
`/sales/opportunities/${opportunityId}`,
operations,
);
return response.data;
},
/**
* Fetch Opportunities by Company
*
@@ -254,6 +295,31 @@ export const opportunityCw = {
return touchedIndices.map((i) => (updated.forecastItems ?? [])[i]!);
},
/**
* Delete Forecast Item
*
* Removes a forecast item from an opportunity by PUTting the forecast
* without the target item. CW's forecast endpoint replaces the entire
* forecast items list on PUT.
*/
deleteProduct: async (
opportunityId: number,
forecastItemId: number,
): Promise<void> => {
const forecast = await opportunityCw.fetchProducts(opportunityId);
const items = forecast.forecastItems ?? [];
const filtered = items.filter((fi) => fi.id !== forecastItemId);
if (filtered.length === items.length) {
throw new Error(
`Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`,
);
}
const url = `/sales/opportunities/${opportunityId}/forecast`;
await connectWiseApi.put(url, { ...forecast, forecastItems: filtered });
},
/**
* Fetch Opportunity Notes
*
@@ -422,4 +488,13 @@ export const opportunityCw = {
);
return response.data as CWProcurementProduct;
},
/**
* Delete Opportunity
*
* Deletes an opportunity from ConnectWise by its CW opportunity ID.
*/
delete: async (opportunityId: number): Promise<void> => {
await connectWiseApi.delete(`/sales/opportunities/${opportunityId}`);
},
};
@@ -213,7 +213,6 @@ export interface CWForecastItemCreate {
catalogItem?: { id: number };
forecastDescription?: string;
productDescription?: string;
customerDescription?: string;
quantity?: number;
status?: { id: number };
productClass?: string;
@@ -263,6 +262,48 @@ export interface CWProcurementProduct {
_info?: Record<string, string>;
}
export interface CWOpportunityUpdate {
name?: string;
notes?: string;
rating?: { id: number };
type?: { id: number };
stage?: { id: number };
status?: { id: number };
priority?: { id: number };
campaign?: { id: number };
primarySalesRep?: { id: number };
secondarySalesRep?: { id: number } | null;
company?: { id: number };
contact?: { id: number } | null;
site?: { id: number } | null;
expectedCloseDate?: string;
customerPO?: string | null;
source?: string | null;
locationId?: number;
businessUnitId?: number;
}
export interface CWOpportunityCreate {
name: string;
expectedCloseDate: string;
primarySalesRep: { id: number };
company: { id: number };
contact: { id: number };
type?: { id: number };
stage?: { id: number };
status?: { id: number };
priority?: { id: number };
campaign?: { id: number };
secondarySalesRep?: { id: number } | null;
site?: { id: number } | null;
notes?: string;
rating?: { id: number };
source?: string | null;
customerPO?: string | null;
locationId?: number;
businessUnitId?: number;
}
export interface CWOpportunitySummary {
id: number;
_info?: Record<string, string>;
@@ -2,6 +2,7 @@ import { prisma } from "../../../constants";
import { events } from "../../globalEvents";
import { opportunityCw } from "./opportunities";
import { OpportunityController } from "../../../controllers/OpportunityController";
import { invalidateAllOpportunityCaches } from "../../cache/opportunityCache";
/**
* Refresh Opportunities
@@ -21,7 +22,7 @@ export const refreshOpportunities = async () => {
// 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated
const dbItems = await prisma.opportunity.findMany({
select: { cwOpportunityId: true, cwLastUpdated: true },
select: { id: true, cwOpportunityId: true, cwLastUpdated: true },
});
const dbMap = new Map(
dbItems.map((item) => [item.cwOpportunityId, item.cwLastUpdated]),
@@ -41,11 +42,35 @@ export const refreshOpportunities = async () => {
}
}
// 3b. Reconcile — find local records that no longer exist in CW
const orphanedItems = dbItems.filter(
(item) => !cwSummaries.has(item.cwOpportunityId),
);
if (orphanedItems.length > 0) {
console.log(
`[refreshOpportunities] Reconciling ${orphanedItems.length} orphaned local record(s) not found in CW`,
);
await Promise.all(
orphanedItems.map(async (item) => {
await prisma.opportunity.delete({ where: { id: item.id } });
await invalidateAllOpportunityCaches(item.cwOpportunityId);
}),
);
events.emit("cw:opportunities:refresh:reconciled", {
orphanedCount: orphanedItems.length,
removedCwIds: orphanedItems.map((i) => i.cwOpportunityId),
});
}
if (staleIds.length === 0) {
events.emit("cw:opportunities:refresh:skipped", {
totalCw: cwSummaries.size,
totalDb: dbItems.length,
staleCount: 0,
orphanedCount: orphanedItems.length,
});
return;
}
@@ -106,5 +131,6 @@ export const refreshOpportunities = async () => {
totalDb: dbItems.length,
staleCount: staleIds.length,
itemsUpdated: updatedCount,
orphanedCount: orphanedItems.length,
});
};
+25
View File
@@ -171,11 +171,17 @@ interface EventTypes {
totalDb: number;
staleCount: number;
itemsUpdated: number;
orphanedCount: number;
}) => void;
"cw:opportunities:refresh:reconciled": (data: {
orphanedCount: number;
removedCwIds: number[];
}) => void;
"cw:opportunities:refresh:skipped": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
orphanedCount: number;
}) => void;
// Cache Events
@@ -205,6 +211,25 @@ interface EventTypes {
totalUsers: number;
usersUpdated: number;
}) => void;
// ConnectWise Members DB Sync Events
"cw:members:db:refresh:check": () => void;
"cw:members:db:refresh:started": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
}) => void;
"cw:members:db:refresh:completed": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
itemsUpdated: number;
}) => void;
"cw:members:db:refresh:skipped": (data: {
totalCw: number;
totalDb: number;
staleCount: number;
}) => void;
}
export const events = new Eventra<EventTypes>();
+118
View File
@@ -0,0 +1,118 @@
/**
* @module cw.opportunityService
*
* ConnectWise Opportunity Service
* ================================
*
* Methods for ConnectWise integrations that the opportunity workflow
* calls. Some are still stubs (marked with console.warn); others are
* fully implemented against the CW REST API.
*/
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
import { connectWiseApi } from "../constants";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TimeEntryInput {
/** CW activity ID to charge the time entry to. */
activityId: number;
/** CW member ID of the user submitting time. */
cwMemberId: number;
/** ISO-8601 datetime when work started. */
timeStart: string;
/** ISO-8601 datetime when work ended. */
timeEnd: string;
notes: string;
}
export interface TimeEntryResult {
success: boolean;
cwTimeEntryId: number | null;
message: string;
}
export interface StatusSyncInput {
opportunityId: number;
statusCwId: number;
}
export interface StatusSyncResult {
success: boolean;
message: string;
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
/**
* Submit a time entry to ConnectWise for an opportunity activity.
*
* Called automatically whenever `timeStart` and `timeEnd` are provided
* on a workflow action.
*/
export async function submitTimeEntry(
input: TimeEntryInput,
): Promise<TimeEntryResult> {
try {
const stripMs = (d: string) => d.replace(/\.\d{3}Z$/, "Z");
const response = await connectWiseApi.post("/time/entries", {
member: { id: input.cwMemberId },
chargeToType: "Activity",
chargeToId: input.activityId,
timeStart: stripMs(input.timeStart),
timeEnd: stripMs(input.timeEnd),
notes: input.notes,
});
return {
success: true,
cwTimeEntryId: response.data?.id ?? null,
message: `Time entry ${response.data?.id} created for activity ${input.activityId}.`,
};
} catch (error: any) {
console.error(
`[cw.opportunityService] submitTimeEntry FAILED — activityId=${input.activityId}, cwMemberId=${input.cwMemberId}`,
error?.response?.data ?? error,
);
return {
success: false,
cwTimeEntryId: null,
message: `Failed to submit time entry: ${error?.message ?? "Unknown error"}`,
};
}
}
/**
* Sync an opportunity's status to ConnectWise.
*
* Called whenever the workflow transitions an opportunity to a new
* status, ensuring the CW record stays in sync.
*/
export async function syncOpportunityStatus(
input: StatusSyncInput,
): Promise<StatusSyncResult> {
try {
await opportunityCw.update(input.opportunityId, {
status: { id: input.statusCwId },
});
return {
success: true,
message: `Opportunity ${input.opportunityId} status synced to CW status ID ${input.statusCwId}.`,
};
} catch (error: any) {
console.error(
`[cw.opportunityService] syncOpportunityStatus FAILED — opportunityId=${input.opportunityId}, statusCwId=${input.statusCwId}`,
error?.response?.data ?? error,
);
return {
success: false,
message: `Failed to sync opportunity status: ${error?.message ?? "Unknown error"}`,
};
}
}
+114
View File
@@ -82,6 +82,12 @@ export const PERMISSION_NODES = {
usedIn: ["src/api/companies/[id]/configurations.ts"],
dependencies: ["company.fetch"],
},
{
node: "company.fetch.sites",
description: "Fetch company sites from ConnectWise",
usedIn: ["src/api/companies/[id]/sites.ts"],
dependencies: ["company.fetch"],
},
],
},
@@ -423,6 +429,25 @@ export const PERMISSION_NODES = {
usedIn: ["src/api/sales/opportunities/[id]/refresh.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.update",
description:
"Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise",
usedIn: ["src/api/sales/opportunities/[id]/update.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.create",
description: "Create a new opportunity in ConnectWise",
usedIn: ["src/api/sales/opportunities/create.ts"],
},
{
node: "sales.opportunity.delete",
description:
"Delete an opportunity from ConnectWise and the local database",
usedIn: ["src/api/sales/opportunities/[id]/delete.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.note.create",
description: "Create a new note on an opportunity",
@@ -452,6 +477,12 @@ export const PERMISSION_NODES = {
],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.product.delete",
description: "Delete a product (forecast item) from an opportunity",
usedIn: ["src/api/sales/opportunities/[id]/products/delete.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.product.add",
description:
@@ -531,6 +562,89 @@ export const PERMISSION_NODES = {
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.view_margin",
description:
"View margin and markup data on opportunity products. Controls visibility of margin %, markup %, and related progress bars in the UI.",
usedIn: [],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.view_cost",
description:
"View cost data on opportunity products. Controls visibility of unit cost, total cost, and recurring cost in the UI.",
usedIn: [],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.view_profit",
description:
"View profit data on opportunity products. Controls visibility of profit values in the UI.",
usedIn: [],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.finalize",
description:
"Finalize an opportunity as Won or Lost. Without this permission, win/lose actions route to PendingWon/PendingLost instead.",
usedIn: [
"src/workflows/wf.opportunity.ts",
"src/api/sales/opportunities/[id]/workflow/dispatch.ts",
],
dependencies: ["sales.opportunity.workflow"],
},
{
node: "sales.opportunity.cancel",
description:
"Cancel an opportunity. Required to transition any eligible opportunity to the Canceled status.",
usedIn: [
"src/workflows/wf.opportunity.ts",
"src/api/sales/opportunities/[id]/workflow/dispatch.ts",
],
dependencies: ["sales.opportunity.workflow"],
},
{
node: "sales.opportunity.review",
description:
"Submit an opportunity for internal review. Required to transition an opportunity into the InternalReview status.",
usedIn: ["src/workflows/wf.opportunity.ts"],
dependencies: ["sales.opportunity.workflow"],
},
{
node: "sales.opportunity.send",
description:
"Send a quote to the customer. Required to transition an opportunity to QuoteSent (and its compound transitions like immediate won/lost/confirmed).",
usedIn: ["src/workflows/wf.opportunity.ts"],
dependencies: ["sales.opportunity.workflow"],
},
{
node: "sales.opportunity.reopen",
description:
"Re-open a cancelled opportunity. Required to transition an opportunity from Canceled back to Active.",
usedIn: ["src/workflows/wf.opportunity.ts"],
dependencies: ["sales.opportunity.workflow"],
},
{
node: "sales.opportunity.win",
description:
"Mark an opportunity as won (or pending won). Gates the win button in the UI. Required for finalize(won), sendQuote(won), and transitionToPending(won).",
usedIn: ["src/workflows/wf.opportunity.ts"],
dependencies: ["sales.opportunity.workflow"],
},
{
node: "sales.opportunity.lose",
description:
"Mark an opportunity as lost (or pending lost). Gates the lose button in the UI. Required for finalize(lost), sendQuote(lost), and transitionToPending(lost).",
usedIn: ["src/workflows/wf.opportunity.ts"],
dependencies: ["sales.opportunity.workflow"],
},
{
node: "sales.opportunity.workflow",
description:
"Execute opportunity workflow actions (status transitions, review decisions, quote sending, etc.). Base gate for the workflow dispatch endpoint.",
usedIn: ["src/api/sales/opportunities/[id]/workflow/dispatch.ts"],
dependencies: ["sales.opportunity.fetch"],
},
],
},
+174 -12
View File
@@ -18,7 +18,7 @@ export interface QuoteStatus {
export const QUOTE_STATUSES: QuoteStatus[] = [
//
// FUTURE
// FUTURE LEAD
//
{
id: 51,
@@ -41,6 +41,27 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
],
},
//
// PENDING NEW
//
{
id: 37,
name: "Pending New",
wonFlag: false,
lostFlag: false,
closedFlag: false,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "crobinso",
dateEntered: "2022-04-28T21:04:53Z",
_info: {
lastUpdated: "2022-04-28T21:06:24Z",
updatedBy: "crobinso",
},
connectWiseId: "f90e72a3-70d0-ef11-b2e0-000c29c55070",
optimaEquivalency: [],
},
//
// NEW
//
@@ -62,7 +83,6 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
optimaEquivalency: [
1, // Pre2021-1) New
13, // Pre2021-Initial Contact Made
37, // 00. Pending New
],
},
@@ -90,6 +110,53 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
27, // Z4. Waiting-VendorInfo
28, // Z5. Waiting-OtherTTStaff
41, // PRE2405. Review Ready
],
},
//
// QUOTE SENT
//
{
id: 43,
name: "Quote Sent",
wonFlag: false,
lostFlag: false,
closedFlag: false,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "crobinso",
dateEntered: "2022-04-28T21:06:02Z",
_info: {
lastUpdated: "2024-04-28T15:06:55Z",
updatedBy: "crobinso",
},
connectWiseId: "ff0e72a3-70d0-ef11-b2e0-000c29c55070",
optimaEquivalency: [
17, // Pre2021-5) Quote Sent
25, // ZOLD---Quote Sent
55, // PRE24_70. Quote Sent - Sell
],
},
//
// CONFIRMED QUOTE
//
{
id: 57,
name: "Confirmed Quote",
wonFlag: false,
lostFlag: false,
closedFlag: false,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "crobinso",
dateEntered: "2024-04-28T15:07:11Z",
_info: {
lastUpdated: "2024-04-28T15:07:11Z",
updatedBy: "crobinso",
},
connectWiseId: "0d0f72a3-70d0-ef11-b2e0-000c29c55070",
optimaEquivalency: [
54, // PRE24_90. Customer Approved
],
},
@@ -116,14 +183,10 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
9, // Pre2021-Recommendation
15, // Pre2021-3) Onsite Assess Sch'd
16, // Pre2021-4) Quote Info Gathered
17, // Pre2021-5) Quote Sent
18, // Pre2021-6) Follow-up #1 Made
19, // Pre2021-7) Follow-up #2 Made
20, // Pre2021-8) Follow-up #3 Made
25, // ZOLD---Quote Sent
43, // 03. Quote Sent
38, // PRE2402. On-Site Ready
39, // PRE2403. On-Site Scheduled
40, // PRE2404. On-Site Complete
@@ -134,11 +197,72 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
47, // PRE2412. Follow-Up3
48, // PRE2413. Follow-Up Extended
52, // PRE2489. Overdue
55, // PRE24_70. Quote Sent - Sell
57, // 04. Confirmed Quote
],
},
//
// PENDING SENT
//
{
id: 60,
name: "Pending Sent",
wonFlag: false,
lostFlag: false,
closedFlag: false,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "crobinso",
dateEntered: "2026-03-08T23:06:01Z",
_info: {
lastUpdated: "2026-03-08T23:06:01Z",
updatedBy: "crobinso",
},
connectWiseId: "c900215f-431b-f111-b2ee-000c29c55070",
optimaEquivalency: [],
},
//
// PENDING REVISION
//
{
id: 61,
name: "Pending Revision",
wonFlag: false,
lostFlag: false,
closedFlag: false,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "crobinso",
dateEntered: "2026-03-08T23:06:06Z",
_info: {
lastUpdated: "2026-03-08T23:06:06Z",
updatedBy: "crobinso",
},
connectWiseId: "ca00215f-431b-f111-b2ee-000c29c55070",
optimaEquivalency: [],
},
//
// PENDING WON
//
{
id: 49,
name: "Pending Won",
wonFlag: false,
lostFlag: false,
closedFlag: false,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "crobinso",
dateEntered: "2023-02-08T21:27:35Z",
_info: {
lastUpdated: "2024-01-21T20:39:47Z",
updatedBy: "crobinso",
},
connectWiseId: "050f72a3-70d0-ef11-b2e0-000c29c55070",
optimaEquivalency: [],
},
//
// WON
//
@@ -159,11 +283,30 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
connectWiseId: "f10e72a3-70d0-ef11-b2e0-000c29c55070",
optimaEquivalency: [
2, // Pre2021-8) Won
54, // PRE24_90. Customer Approved (if you treat as effectively Won)
49, // 91. Pending Won
],
},
//
// PENDING LOST
//
{
id: 50,
name: "Pending Lost",
wonFlag: false,
lostFlag: false,
closedFlag: false,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "crobinso",
dateEntered: "2023-02-08T21:32:10Z",
_info: {
lastUpdated: "2023-02-08T21:32:41Z",
updatedBy: "crobinso",
},
connectWiseId: "060f72a3-70d0-ef11-b2e0-000c29c55070",
optimaEquivalency: [],
},
//
// LOST
//
@@ -192,8 +335,27 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
32, // Pre2024_99. Lost-NoDecision
33, // Pre2024_99. Lost-Pricing
34, // Pre2024_99. Lost-OtherTTQuote
50, // 98. Pending Lost
],
},
//
// CANCELED
//
{
id: 59,
name: "Canceled",
wonFlag: false,
lostFlag: true,
closedFlag: true,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "crobinso",
dateEntered: "2026-03-08T22:45:07Z",
_info: {
lastUpdated: "2026-03-08T22:45:07Z",
updatedBy: "crobinso",
},
connectWiseId: "83ddc173-401b-f111-b2ee-000c29c55070",
optimaEquivalency: [],
},
];
File diff suppressed because it is too large Load Diff
+107
View File
@@ -17,6 +17,45 @@ const { privateKey: _testPrivateKey, publicKey: _testPublicKey } =
publicKeyEncoding: { type: "spki", format: "pem" },
});
// ---------------------------------------------------------------------------
// Mock the globalEvents module — many source files import `events` from here.
// We provide both `events` and `setupEventDebugger` so that no test
// encounters "export not found" when another test's mock.module is stale.
// ---------------------------------------------------------------------------
mock.module("../src/modules/globalEvents", () => ({
events: {
emit: mock(),
on: mock(),
off: mock(),
once: mock(),
removeAllListeners: mock(),
},
setupEventDebugger: mock(),
}));
// ---------------------------------------------------------------------------
// Mock modules that are commonly mocked by test files at top-level.
// Having them in the preload ensures that even when per-test mock.module
// calls persist globally, the baseline mock is complete.
// ---------------------------------------------------------------------------
mock.module("../src/modules/fetchMicrosoftUser", () => ({
fetchMicrosoftUser: mock(() => Promise.resolve({})),
}));
mock.module("../src/managers/sessions", () => ({
sessions: {
create: mock(() =>
Promise.resolve({
accessToken: "mock-access",
refreshToken: "mock-refresh",
}),
),
fetch: mock(() => Promise.resolve(null)),
},
}));
// ---------------------------------------------------------------------------
// Mock the constants module — almost every source file imports from here.
// We provide safe defaults so modules can be imported without side-effects.
@@ -60,6 +99,74 @@ mock.module("../src/constants", () => ({
// Helpers
// ---------------------------------------------------------------------------
/**
* Build a complete mock constants object for use with `mock.module()`.
*
* Pass `overrides` to replace specific exports (e.g. a custom prisma mock).
* All keys from the preload mock are included so that downstream modules
* importing named exports (secureValuesPublicKey, connectWiseApi, etc.)
* never encounter "export not found" errors.
*/
export function buildMockConstants(
overrides: Record<string, any> = {},
): Record<string, any> {
return {
prisma: createMockPrisma(),
PORT: "3333",
API_BASE_URL: "http://localhost:3333",
sessionDuration: 30 * 24 * 60 * 60_000,
accessTokenDuration: "10min",
refreshTokenDuration: "30d",
accessTokenPrivateKey: _testPrivateKey,
refreshTokenPrivateKey: _testPrivateKey,
permissionsPrivateKey: _testPrivateKey,
secureValuesPrivateKey: _testPrivateKey,
secureValuesPublicKey: _testPublicKey,
msalClient: { acquireTokenByCode: mock(() => Promise.resolve({})) },
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
post: mock(() => Promise.resolve({ data: {} })),
put: mock(() => Promise.resolve({ data: {} })),
patch: mock(() => Promise.resolve({ data: {} })),
delete: mock(() => Promise.resolve({ data: {} })),
},
redis: {
get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve("OK")),
del: mock(() => Promise.resolve(1)),
},
unifi: createMockUnifi(),
unifiControllerBaseUrl: "https://unifi.test.local",
unifiSite: "default",
unifiUsername: "admin",
unifiPassword: "test-pass",
io: { of: mock(() => ({ on: mock() })) },
engine: {},
...overrides,
};
}
/**
* Build a complete mock globalEvents object for use with `mock.module()`.
* Includes both `events` and `setupEventDebugger` so downstream modules
* never encounter "export not found" errors.
*/
export function buildMockGlobalEvents(
overrides: Record<string, any> = {},
): Record<string, any> {
return {
events: {
emit: mock(),
on: mock(),
off: mock(),
once: mock(),
removeAllListeners: mock(),
},
setupEventDebugger: mock(),
...overrides,
};
}
export function createMockPrisma() {
const createModelProxy = () =>
new Proxy(
+242
View File
@@ -0,0 +1,242 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { buildMockCWActivity } from "../setup";
// ---------------------------------------------------------------------------
// Stable mock factory (same pattern as generatedQuotesManager.test.ts)
// ---------------------------------------------------------------------------
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
},
},
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("activities manager", () => {
beforeEach(() => {
mock.restore();
});
// -------------------------------------------------------------------
// fetchItem
// -------------------------------------------------------------------
describe("fetchItem()", () => {
test("returns an ActivityController on success", async () => {
const cwData = buildMockCWActivity();
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {
fetch: mock(() => Promise.resolve(cwData)),
fetchByCompany: mock(() => Promise.resolve([])),
fetchByOpportunity: mock(() => Promise.resolve([])),
delete: mock(() => Promise.resolve()),
countItems: mock(() => Promise.resolve(0)),
update: mock(() => Promise.resolve(cwData)),
},
}));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock(),
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
post: mock(() => Promise.resolve({ data: {} })),
patch: mock(() => Promise.resolve({ data: {} })),
delete: mock(() => Promise.resolve({ data: {} })),
},
}));
const { activities } = await import("../../src/managers/activities");
const result = await activities.fetchItem(5001);
expect(result).toBeDefined();
expect(result.cwActivityId).toBe(5001);
});
test("throws GenericError on failure", async () => {
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {
fetch: mock(() => Promise.reject(new Error("CW API down"))),
},
}));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock(),
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}));
const { activities } = await import("../../src/managers/activities");
try {
await activities.fetchItem(9999);
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("FetchActivityError");
expect(e.status).toBeDefined();
}
});
});
// -------------------------------------------------------------------
// fetchPages
// -------------------------------------------------------------------
describe("fetchPages()", () => {
test("returns array of ActivityControllers", async () => {
const cwData = [buildMockCWActivity(), buildMockCWActivity({ id: 5002 })];
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock(),
connectWiseApi: {
get: mock(() => Promise.resolve({ data: cwData })),
post: mock(() => Promise.resolve({ data: {} })),
},
}));
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {},
}));
const { activities } = await import("../../src/managers/activities");
const result = await activities.fetchPages(1, 10);
expect(result).toBeArrayOfSize(2);
});
test("clamps page to minimum 1", async () => {
const getMock = mock(() => Promise.resolve({ data: [] }));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock(),
connectWiseApi: { get: getMock },
}));
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {},
}));
const { activities } = await import("../../src/managers/activities");
await activities.fetchPages(-5, 10);
const url = getMock.mock.calls[0]?.[0] as string;
expect(url).toContain("page=1");
});
});
// -------------------------------------------------------------------
// fetchByCompany
// -------------------------------------------------------------------
describe("fetchByCompany()", () => {
test("returns ActivityControllers for a company", async () => {
const items = [buildMockCWActivity()];
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {
fetchByCompany: mock(() => Promise.resolve(items)),
},
}));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock(),
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}));
const { activities } = await import("../../src/managers/activities");
const result = await activities.fetchByCompany(123);
expect(result).toBeArrayOfSize(1);
});
});
// -------------------------------------------------------------------
// fetchByOpportunity
// -------------------------------------------------------------------
describe("fetchByOpportunity()", () => {
test("returns ActivityControllers for an opportunity", async () => {
const items = [buildMockCWActivity()];
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {
fetchByOpportunity: mock(() => Promise.resolve(items)),
},
}));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock(),
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}));
const { activities } = await import("../../src/managers/activities");
const result = await activities.fetchByOpportunity(1001);
expect(result).toBeArrayOfSize(1);
});
});
// -------------------------------------------------------------------
// delete
// -------------------------------------------------------------------
describe("delete()", () => {
test("delegates to activityCw.delete", async () => {
const deleteMock = mock(() => Promise.resolve());
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: { delete: deleteMock },
}));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock(),
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}));
const { activities } = await import("../../src/managers/activities");
await activities.delete(5001);
expect(deleteMock).toHaveBeenCalledWith(5001);
});
test("throws GenericError on failure", async () => {
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {
delete: mock(() => Promise.reject(new Error("fail"))),
},
}));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock(),
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}));
const { activities } = await import("../../src/managers/activities");
try {
await activities.delete(9999);
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("DeleteActivityError");
}
});
});
// -------------------------------------------------------------------
// count
// -------------------------------------------------------------------
describe("count()", () => {
test("returns count from activityCw", async () => {
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {
countItems: mock(() => Promise.resolve(42)),
},
}));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock(),
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}));
const { activities } = await import("../../src/managers/activities");
const result = await activities.count();
expect(result).toBe(42);
});
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* Tests for src/modules/algorithms/algo.coldThreshold.ts
*
* checkColdStatus is currently bypassed (always returns not-cold).
* COLD_THRESHOLDS config is still tested.
*/
import { describe, test, expect } from "bun:test";
import {
checkColdStatus,
COLD_THRESHOLDS,
} from "../../src/modules/algorithms/algo.coldThreshold";
describe("COLD_THRESHOLDS", () => {
test("defines thresholds for QuoteSent (43) and ConfirmedQuote (57)", () => {
expect(COLD_THRESHOLDS[43]).toBeDefined();
expect(COLD_THRESHOLDS[43].days).toBe(14);
expect(COLD_THRESHOLDS[57]).toBeDefined();
expect(COLD_THRESHOLDS[57].days).toBe(30);
});
test("ms values match day values", () => {
expect(COLD_THRESHOLDS[43].ms).toBe(14 * 24 * 60 * 60 * 1000);
expect(COLD_THRESHOLDS[57].ms).toBe(30 * 24 * 60 * 60 * 1000);
});
});
describe("checkColdStatus (bypassed)", () => {
test("always returns not-cold regardless of input", () => {
// With null status
expect(checkColdStatus({ statusCwId: null, lastActivityDate: new Date() }))
.toEqual({ cold: false, triggeredBy: null });
// With eligible status and stale activity
const now = new Date("2026-03-16T00:00:00Z");
const lastActivity = new Date("2026-03-01T00:00:00Z"); // 15 days ago
expect(checkColdStatus({ statusCwId: 43, lastActivityDate: lastActivity, now }))
.toEqual({ cold: false, triggeredBy: null });
// With ConfirmedQuote exceeding threshold
expect(checkColdStatus({ statusCwId: 57, lastActivityDate: new Date("2026-02-01"), now: new Date("2026-04-01") }))
.toEqual({ cold: false, triggeredBy: null });
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* Tests for src/modules/algorithms/algo.followUpScheduler.ts
*
* Pure function no mocking needed.
*/
import { describe, test, expect } from "bun:test";
import { scheduleFollowUp } from "../../src/modules/algorithms/algo.followUpScheduler";
describe("scheduleFollowUp", () => {
test("returns dueDate and dueDateIso", () => {
const result = scheduleFollowUp({
triggeredByUserId: "user-1",
now: new Date("2026-03-02T14:00:00Z"), // Monday
});
expect(result.dueDate).toBeInstanceOf(Date);
expect(typeof result.dueDateIso).toBe("string");
});
test("schedules for next day at 10 AM on a weekday (Mon → Tue)", () => {
// Monday March 2, 2026
const result = scheduleFollowUp({
triggeredByUserId: "user-1",
now: new Date("2026-03-02T14:00:00Z"),
});
// Should be Tuesday March 3, 2026 at 10:00 AM local
expect(result.dueDate.getDate()).toBe(3);
expect(result.dueDate.getHours()).toBe(10);
expect(result.dueDate.getMinutes()).toBe(0);
expect(result.dueDate.getSeconds()).toBe(0);
});
test("Friday → Monday (skips weekend)", () => {
// Friday March 6, 2026
const result = scheduleFollowUp({
triggeredByUserId: "user-1",
now: new Date("2026-03-06T14:00:00Z"),
});
// Next day is Saturday (day 6), should skip to Monday
// March 6 (Fri) +1 = March 7 (Sat) → +2 → March 9 (Mon)
expect(result.dueDate.getDay()).toBe(1); // Monday
expect(result.dueDate.getHours()).toBe(10);
});
test("Saturday → Monday", () => {
// Saturday March 7, 2026
const result = scheduleFollowUp({
triggeredByUserId: "user-1",
now: new Date("2026-03-07T14:00:00Z"),
});
// +1 = Sunday (day 0) → +1 → Monday
expect(result.dueDate.getDay()).toBe(1); // Monday
expect(result.dueDate.getHours()).toBe(10);
});
test("defaults to current time when now is not provided", () => {
const result = scheduleFollowUp({ triggeredByUserId: "user-1" });
expect(result.dueDate).toBeInstanceOf(Date);
// Due date should be in the future
expect(result.dueDate.getTime()).toBeGreaterThan(Date.now() - 1000);
});
test("dueDate always has time set to 10:00:00.000", () => {
// Test across several days of the week
for (let d = 1; d <= 7; d++) {
const result = scheduleFollowUp({
triggeredByUserId: "user-1",
now: new Date(`2026-03-0${d}T08:00:00Z`),
});
expect(result.dueDate.getHours()).toBe(10);
expect(result.dueDate.getMinutes()).toBe(0);
expect(result.dueDate.getSeconds()).toBe(0);
expect(result.dueDate.getMilliseconds()).toBe(0);
}
});
test("dueDateIso is a valid ISO string of the dueDate", () => {
const result = scheduleFollowUp({
triggeredByUserId: "user-1",
now: new Date("2026-03-02T14:00:00Z"),
});
expect(new Date(result.dueDateIso).getTime()).toBe(
result.dueDate.getTime(),
);
});
});
+178
View File
@@ -0,0 +1,178 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { buildMockCompany } from "../setup";
// ---------------------------------------------------------------------------
// Stable mock factory
// ---------------------------------------------------------------------------
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
},
},
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("companies manager", () => {
beforeEach(() => {
mock.restore();
});
// -------------------------------------------------------------------
// count
// -------------------------------------------------------------------
describe("count()", () => {
test("returns company count from Prisma", async () => {
const countMock = mock(() => Promise.resolve(5));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
company: {
count: countMock,
findFirst: mock(() => Promise.resolve(null)),
},
}),
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}));
const { companies } = await import("../../src/managers/companies");
const result = await companies.count();
expect(result).toBe(5);
});
});
// -------------------------------------------------------------------
// fetchPages
// -------------------------------------------------------------------
describe("fetchPages()", () => {
test("returns paginated companies", async () => {
const mockData = [
buildMockCompany(),
buildMockCompany({ id: "company-2" }),
];
const findManyMock = mock(() => Promise.resolve(mockData));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
company: {
findMany: findManyMock,
findFirst: mock(() => Promise.resolve(null)),
},
}),
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}));
const { companies } = await import("../../src/managers/companies");
const result = await companies.fetchPages(1, 10);
expect(result).toBeArrayOfSize(2);
});
test("uses correct skip for page 1", async () => {
const findManyMock = mock(() => Promise.resolve([]));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
company: {
findMany: findManyMock,
findFirst: mock(() => Promise.resolve(null)),
},
}),
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}));
const { companies } = await import("../../src/managers/companies");
await companies.fetchPages(1, 20);
expect(findManyMock).toHaveBeenCalled();
});
});
// -------------------------------------------------------------------
// search
// -------------------------------------------------------------------
describe("search()", () => {
test("returns matching companies", async () => {
const mockData = [buildMockCompany()];
const findManyMock = mock(() => Promise.resolve(mockData));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
company: {
findMany: findManyMock,
findFirst: mock(() => Promise.resolve(null)),
},
}),
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}));
const { companies } = await import("../../src/managers/companies");
const result = await companies.search("Test", 1, 10);
expect(result).toBeArrayOfSize(1);
});
});
// -------------------------------------------------------------------
// fetch
// -------------------------------------------------------------------
describe("fetch()", () => {
test("throws when company not found in DB", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
company: { findFirst: mock(() => Promise.resolve(null)) },
}),
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}));
const { companies } = await import("../../src/managers/companies");
try {
await companies.fetch("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.message).toBe("Unknown company.");
}
});
test("returns CompanyController on success", async () => {
const dbCompany = buildMockCompany();
const cwCompanyData = {
defaultContact: { _info: { contact_href: "/contacts/1" } },
_info: { contacts_href: "/contacts?page=1" },
};
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
company: { findFirst: mock(() => Promise.resolve(dbCompany)) },
}),
connectWiseApi: {
get: mock(() =>
Promise.resolve({
data: cwCompanyData,
}),
),
},
}));
const { companies } = await import("../../src/managers/companies");
const result = await companies.fetch("company-1");
expect(result).toBeDefined();
expect(result.id).toBe("company-1");
});
});
});
+17 -8
View File
@@ -20,14 +20,19 @@ describe("computeProductsCacheTTL", () => {
});
// -- Won/Lost status set ------------------------------------------------
test("WON_LOST_STATUS_IDS contains Won canonical ID (29) and Pending Won (49)", () => {
test("WON_LOST_STATUS_IDS contains Won canonical ID (29)", () => {
expect(WON_LOST_STATUS_IDS.has(29)).toBe(true);
expect(WON_LOST_STATUS_IDS.has(49)).toBe(true);
});
test("WON_LOST_STATUS_IDS contains Lost canonical ID (53) and Pending Lost (50)", () => {
test("WON_LOST_STATUS_IDS contains Lost canonical ID (53) and Canceled (59)", () => {
expect(WON_LOST_STATUS_IDS.has(53)).toBe(true);
expect(WON_LOST_STATUS_IDS.has(50)).toBe(true);
expect(WON_LOST_STATUS_IDS.has(59)).toBe(true);
});
test("WON_LOST_STATUS_IDS does not contain Pending Won (49) or Pending Lost (50)", () => {
// Pending Won/Lost do not have wonFlag/lostFlag set in QuoteStatuses
expect(WON_LOST_STATUS_IDS.has(49)).toBe(false);
expect(WON_LOST_STATUS_IDS.has(50)).toBe(false);
});
test("WON_LOST_STATUS_IDS does not contain Active (58) or New (24)", () => {
@@ -48,7 +53,9 @@ describe("computeProductsCacheTTL", () => {
expect(result).toBeNull();
});
test("returns null for Pending Won status (CW ID 49)", () => {
test("returns PRODUCTS_TTL_HOT for Pending Won status (CW ID 49) with recent activity", () => {
// Pending Won is not in WON_LOST_STATUS_IDS (no wonFlag), so it falls
// through to the activity-based rules.
const result = computeProductsCacheTTL({
statusCwId: 49,
closedFlag: false,
@@ -57,7 +64,7 @@ describe("computeProductsCacheTTL", () => {
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
now: NOW,
});
expect(result).toBeNull();
expect(result).toBe(PRODUCTS_TTL_HOT);
});
test("returns null for Lost status (CW ID 53)", () => {
@@ -72,7 +79,9 @@ describe("computeProductsCacheTTL", () => {
expect(result).toBeNull();
});
test("returns null for Pending Lost status (CW ID 50)", () => {
test("returns PRODUCTS_TTL_HOT for Pending Lost status (CW ID 50) with recent activity", () => {
// Pending Lost is not in WON_LOST_STATUS_IDS (no lostFlag), so it falls
// through to the activity-based rules.
const result = computeProductsCacheTTL({
statusCwId: 50,
closedFlag: false,
@@ -81,7 +90,7 @@ describe("computeProductsCacheTTL", () => {
lastUpdated: new Date(NOW.getTime() - 1 * DAY_MS),
now: NOW,
});
expect(result).toBeNull();
expect(result).toBe(PRODUCTS_TTL_HOT);
});
// -- Rule 2: Opp not cacheable → null ----------------------------------
@@ -0,0 +1,181 @@
import { describe, test, expect } from "bun:test";
import { CwMemberController } from "../../../src/controllers/CwMemberController";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildMockCwMember(overrides: Record<string, any> = {}) {
return {
id: "member-1",
cwMemberId: 42,
identifier: "jdoe",
firstName: "John",
lastName: "Doe",
officeEmail: "jdoe@example.com",
inactiveFlag: false,
apiKey: null,
cwLastUpdated: new Date("2026-02-01"),
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-02-01"),
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("CwMemberController", () => {
// -----------------------------------------------------------------
// Constructor
// -----------------------------------------------------------------
describe("constructor", () => {
test("sets all public properties from data", () => {
const data = buildMockCwMember();
const ctrl = new CwMemberController(data as any);
expect(ctrl.id).toBe("member-1");
expect(ctrl.cwMemberId).toBe(42);
expect(ctrl.identifier).toBe("jdoe");
expect(ctrl.firstName).toBe("John");
expect(ctrl.lastName).toBe("Doe");
expect(ctrl.officeEmail).toBe("jdoe@example.com");
expect(ctrl.inactiveFlag).toBe(false);
expect(ctrl.apiKey).toBeNull();
expect(ctrl.cwLastUpdated).toEqual(new Date("2026-02-01"));
expect(ctrl.createdAt).toEqual(new Date("2026-01-01"));
expect(ctrl.updatedAt).toEqual(new Date("2026-02-01"));
});
test("handles null officeEmail", () => {
const data = buildMockCwMember({ officeEmail: null });
const ctrl = new CwMemberController(data as any);
expect(ctrl.officeEmail).toBeNull();
});
test("handles apiKey set", () => {
const data = buildMockCwMember({ apiKey: "secret-key" });
const ctrl = new CwMemberController(data as any);
expect(ctrl.apiKey).toBe("secret-key");
});
});
// -----------------------------------------------------------------
// fullName getter
// -----------------------------------------------------------------
describe("fullName", () => {
test("returns firstName + lastName", () => {
const ctrl = new CwMemberController(buildMockCwMember() as any);
expect(ctrl.fullName).toBe("John Doe");
});
test("returns trimmed name when lastName is empty", () => {
const ctrl = new CwMemberController(
buildMockCwMember({ lastName: "" }) as any,
);
expect(ctrl.fullName).toBe("John");
});
test("returns trimmed name when firstName is empty", () => {
const ctrl = new CwMemberController(
buildMockCwMember({ firstName: "" }) as any,
);
expect(ctrl.fullName).toBe("Doe");
});
test("falls back to identifier when both names are empty", () => {
const ctrl = new CwMemberController(
buildMockCwMember({ firstName: "", lastName: "" }) as any,
);
expect(ctrl.fullName).toBe("jdoe");
});
});
// -----------------------------------------------------------------
// mapCwToDb (static)
// -----------------------------------------------------------------
describe("mapCwToDb", () => {
test("maps CW member fields to DB schema", () => {
const cwItem = {
identifier: "jdoe",
firstName: "John",
lastName: "Doe",
officeEmail: "jdoe@example.com",
inactiveFlag: false,
_info: { lastUpdated: "2026-02-01T12:00:00Z" },
};
const result = CwMemberController.mapCwToDb(cwItem as any);
expect(result.identifier).toBe("jdoe");
expect(result.firstName).toBe("John");
expect(result.lastName).toBe("Doe");
expect(result.officeEmail).toBe("jdoe@example.com");
expect(result.inactiveFlag).toBe(false);
expect(result.cwLastUpdated).toEqual(new Date("2026-02-01T12:00:00Z"));
});
test("handles null/missing fields with defaults", () => {
const cwItem = {
identifier: "empty",
firstName: null,
lastName: null,
officeEmail: null,
inactiveFlag: null,
_info: null,
};
const result = CwMemberController.mapCwToDb(cwItem as any);
expect(result.firstName).toBe("");
expect(result.lastName).toBe("");
expect(result.officeEmail).toBeNull();
expect(result.inactiveFlag).toBe(false);
expect(result.cwLastUpdated).toBeInstanceOf(Date);
});
test("handles undefined _info.lastUpdated", () => {
const cwItem = {
identifier: "test",
firstName: "A",
lastName: "B",
officeEmail: null,
inactiveFlag: false,
_info: {},
};
const result = CwMemberController.mapCwToDb(cwItem as any);
// Without lastUpdated, falls through to new Date()
expect(result.cwLastUpdated).toBeInstanceOf(Date);
});
});
// -----------------------------------------------------------------
// toJson
// -----------------------------------------------------------------
describe("toJson", () => {
test("returns all fields including fullName", () => {
const ctrl = new CwMemberController(buildMockCwMember() as any);
const json = ctrl.toJson();
expect(json.id).toBe("member-1");
expect(json.cwMemberId).toBe(42);
expect(json.identifier).toBe("jdoe");
expect(json.firstName).toBe("John");
expect(json.lastName).toBe("Doe");
expect(json.fullName).toBe("John Doe");
expect(json.officeEmail).toBe("jdoe@example.com");
expect(json.inactiveFlag).toBe(false);
expect(json.apiKey).toBeNull();
expect(json.cwLastUpdated).toEqual(new Date("2026-02-01"));
expect(json.createdAt).toEqual(new Date("2026-01-01"));
expect(json.updatedAt).toEqual(new Date("2026-02-01"));
});
test("includes fullName in JSON", () => {
const ctrl = new CwMemberController(
buildMockCwMember({ firstName: "", lastName: "" }) as any,
);
expect(ctrl.toJson().fullName).toBe("jdoe");
});
});
});
+190
View File
@@ -0,0 +1,190 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { buildMockCredentialType, buildMockConstants } from "../setup";
// ---------------------------------------------------------------------------
// Stable mock factory
// ---------------------------------------------------------------------------
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
},
},
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("credentialTypes manager", () => {
beforeEach(() => {
mock.restore();
});
// -------------------------------------------------------------------
// fetch
// -------------------------------------------------------------------
describe("fetch()", () => {
test("returns CredentialTypeController when found", async () => {
const mockData = { ...buildMockCredentialType(), credentials: [] };
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
credentialType: {
findFirst: mock(() => Promise.resolve(mockData)),
},
}),
}),
);
const { credentialTypes } =
await import("../../src/managers/credentialTypes");
const result = await credentialTypes.fetch("ctype-1");
expect(result).toBeDefined();
expect(result.id).toBe("ctype-1");
});
test("throws 404 when not found", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
credentialType: {
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { credentialTypes } =
await import("../../src/managers/credentialTypes");
try {
await credentialTypes.fetch("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("CredentialTypeNotFound");
expect(e.status).toBe(404);
}
});
});
// -------------------------------------------------------------------
// fetchAll
// -------------------------------------------------------------------
describe("fetchAll()", () => {
test("returns array of controllers", async () => {
const items = [
{ ...buildMockCredentialType(), credentials: [] },
{
...buildMockCredentialType({ id: "ctype-2", name: "API Key" }),
credentials: [],
},
];
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
credentialType: {
findMany: mock(() => Promise.resolve(items)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { credentialTypes } =
await import("../../src/managers/credentialTypes");
const result = await credentialTypes.fetchAll();
expect(result).toBeArrayOfSize(2);
});
});
// -------------------------------------------------------------------
// create
// -------------------------------------------------------------------
describe("create()", () => {
test("creates and returns a CredentialTypeController", async () => {
const created = {
...buildMockCredentialType(),
credentials: [],
};
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
credentialType: {
findFirst: mock(() => Promise.resolve(null)), // no dupe
create: mock(() => Promise.resolve(created)),
},
}),
}),
);
const { credentialTypes } =
await import("../../src/managers/credentialTypes");
const result = await credentialTypes.create({
name: "Login Credential",
permissionScope: "credential.login",
fields: [],
});
expect(result).toBeDefined();
expect(result.name).toBe("Login Credential");
});
test("throws when name already exists", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
credentialType: {
findFirst: mock(() => Promise.resolve(buildMockCredentialType())),
},
}),
}),
);
const { credentialTypes } =
await import("../../src/managers/credentialTypes");
try {
await credentialTypes.create({
name: "Login Credential",
permissionScope: "credential.login",
fields: [],
});
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("CredentialTypeAlreadyExists");
expect(e.status).toBe(400);
}
});
});
// -------------------------------------------------------------------
// delete
// -------------------------------------------------------------------
describe("delete()", () => {
test("deletes credential type by id", async () => {
const deleteMock = mock(() => Promise.resolve({}));
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
credentialType: {
delete: deleteMock,
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { credentialTypes } =
await import("../../src/managers/credentialTypes");
await credentialTypes.delete("ctype-1");
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "ctype-1" } });
});
});
});
+194
View File
@@ -0,0 +1,194 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import {
buildMockCredential,
buildMockCredentialType,
buildMockConstants,
} from "../setup";
// ---------------------------------------------------------------------------
// Stable mock factory
// ---------------------------------------------------------------------------
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
},
},
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("credentials manager", () => {
beforeEach(() => {
mock.restore();
});
// -------------------------------------------------------------------
// fetch
// -------------------------------------------------------------------
describe("fetch()", () => {
test("returns CredentialController when found", async () => {
const mockData = buildMockCredential();
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
credential: {
findFirst: mock(() => Promise.resolve(mockData)),
},
}),
}),
);
const { credentials } = await import("../../src/managers/credentials");
const result = await credentials.fetch("cred-1");
expect(result).toBeDefined();
expect(result.id).toBe("cred-1");
});
test("throws 404 when not found", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
credential: {
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { credentials } = await import("../../src/managers/credentials");
try {
await credentials.fetch("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("CredentialNotFound");
expect(e.status).toBe(404);
}
});
});
// -------------------------------------------------------------------
// fetchByCompany
// -------------------------------------------------------------------
describe("fetchByCompany()", () => {
test("returns array of CredentialControllers", async () => {
const mockData = [buildMockCredential()];
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
credential: {
findMany: mock(() => Promise.resolve(mockData)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { credentials } = await import("../../src/managers/credentials");
const result = await credentials.fetchByCompany("company-1");
expect(result).toBeArrayOfSize(1);
});
test("returns empty array when no credentials exist", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
credential: {
findMany: mock(() => Promise.resolve([])),
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { credentials } = await import("../../src/managers/credentials");
const result = await credentials.fetchByCompany("company-x");
expect(result).toBeArrayOfSize(0);
});
});
// -------------------------------------------------------------------
// fetchSubCredentials
// -------------------------------------------------------------------
describe("fetchSubCredentials()", () => {
test("returns sub-credentials for parent", async () => {
const mockData = [
buildMockCredential({ id: "sub-1", subCredentialOfId: "cred-1" }),
];
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
credential: {
findMany: mock(() => Promise.resolve(mockData)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { credentials } = await import("../../src/managers/credentials");
const result = await credentials.fetchSubCredentials("cred-1");
expect(result).toBeArrayOfSize(1);
});
});
// -------------------------------------------------------------------
// delete
// -------------------------------------------------------------------
describe("delete()", () => {
test("deletes credential by id", async () => {
const deleteMock = mock(() => Promise.resolve({}));
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
credential: {
delete: deleteMock,
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { credentials } = await import("../../src/managers/credentials");
await credentials.delete("cred-1");
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "cred-1" } });
});
});
// -------------------------------------------------------------------
// removeSubCredential
// -------------------------------------------------------------------
describe("removeSubCredential()", () => {
test("throws when sub-credential not found", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
credential: {
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { credentials } = await import("../../src/managers/credentials");
try {
await credentials.removeSubCredential("parent-1", "sub-999");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("SubCredentialNotFound");
expect(e.status).toBe(404);
}
});
});
});
+188
View File
@@ -0,0 +1,188 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
// ---------------------------------------------------------------------------
// Stable mock factory
// ---------------------------------------------------------------------------
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
},
},
);
}
function buildMockCwMemberRecord(overrides: Record<string, any> = {}) {
return {
id: "member-1",
cwMemberId: 42,
identifier: "jdoe",
firstName: "John",
lastName: "Doe",
officeEmail: "jdoe@example.com",
inactiveFlag: false,
apiKey: null,
cwLastUpdated: new Date("2026-02-01"),
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-02-01"),
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("cwMembers manager", () => {
beforeEach(() => {
mock.restore();
});
// -------------------------------------------------------------------
// fetch
// -------------------------------------------------------------------
describe("fetch()", () => {
test("returns CwMemberController by identifier string", async () => {
const record = buildMockCwMemberRecord();
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
cwMember: {
findFirst: mock(() => Promise.resolve(record)),
},
}),
}));
const { cwMembers } = await import("../../src/managers/cwMembers");
const result = await cwMembers.fetch("jdoe");
expect(result).toBeDefined();
expect(result.identifier).toBe("jdoe");
});
test("treats numeric string as cwMemberId lookup", async () => {
const record = buildMockCwMemberRecord();
const findFirst = mock(() => Promise.resolve(record));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
cwMember: { findFirst },
}),
}));
const { cwMembers } = await import("../../src/managers/cwMembers");
await cwMembers.fetch("42");
const where = findFirst.mock.calls[0]?.[0]?.where;
expect(where).toHaveProperty("cwMemberId", 42);
});
test("throws 404 when not found", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
cwMember: {
findFirst: mock(() => Promise.resolve(null)),
},
}),
}));
const { cwMembers } = await import("../../src/managers/cwMembers");
try {
await cwMembers.fetch("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("CwMemberNotFound");
expect(e.status).toBe(404);
}
});
});
// -------------------------------------------------------------------
// fetchAll
// -------------------------------------------------------------------
describe("fetchAll()", () => {
test("returns active members by default", async () => {
const records = [buildMockCwMemberRecord()];
const findMany = mock(() => Promise.resolve(records));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
cwMember: {
findMany,
findFirst: mock(() => Promise.resolve(null)),
},
}),
}));
const { cwMembers } = await import("../../src/managers/cwMembers");
const result = await cwMembers.fetchAll();
expect(result).toBeArrayOfSize(1);
// Should filter inactive by default
const where = findMany.mock.calls[0]?.[0]?.where;
expect(where).toHaveProperty("inactiveFlag", false);
});
test("includes inactive when requested", async () => {
const findMany = mock(() => Promise.resolve([]));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
cwMember: {
findMany,
findFirst: mock(() => Promise.resolve(null)),
},
}),
}));
const { cwMembers } = await import("../../src/managers/cwMembers");
await cwMembers.fetchAll({ includeInactive: true });
const where = findMany.mock.calls[0]?.[0]?.where;
expect(where).toEqual({});
});
});
// -------------------------------------------------------------------
// count
// -------------------------------------------------------------------
describe("count()", () => {
test("returns count of active members by default", async () => {
const countMock = mock(() => Promise.resolve(10));
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
cwMember: {
count: countMock,
findFirst: mock(() => Promise.resolve(null)),
},
}),
}));
const { cwMembers } = await import("../../src/managers/cwMembers");
const result = await cwMembers.count();
expect(result).toBe(10);
});
});
// -------------------------------------------------------------------
// updateApiKey
// -------------------------------------------------------------------
describe("updateApiKey()", () => {
test("updates API key for a member", async () => {
const record = buildMockCwMemberRecord();
const updatedRecord = { ...record, apiKey: "new-key" };
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
cwMember: {
findFirst: mock(() => Promise.resolve(record)),
update: mock(() => Promise.resolve(updatedRecord)),
},
}),
}));
const { cwMembers } = await import("../../src/managers/cwMembers");
const result = await cwMembers.updateApiKey("jdoe", "new-key");
expect(result.apiKey).toBe("new-key");
});
});
});
+163
View File
@@ -0,0 +1,163 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const postMock = mock(() => Promise.resolve({ data: { id: 9001 } }));
const updateMock = mock(() => Promise.resolve({}));
// ---------------------------------------------------------------------------
// Override the service module itself.
//
// wfOpportunity.test.ts mocks "cw.opportunityService" globally with stub
// functions. Because mock.module() is permanent (mock.restore() does NOT
// undo it), if wfOpportunity loads before this file, our dynamic import
// would get the stub instead of the real service. The only reliable fix
// is to also call mock.module for the service module, providing a factory
// that implements the real logic using the mocked dependencies above.
// ---------------------------------------------------------------------------
const stripMs = (d: string) => d.replace(/\.\d{3}Z$/, "Z");
mock.module("../../src/services/cw.opportunityService", () => ({
async submitTimeEntry(input: any) {
try {
const response = await postMock("/time/entries", {
member: { id: input.cwMemberId },
chargeToType: "Activity",
chargeToId: input.activityId,
timeStart: stripMs(input.timeStart),
timeEnd: stripMs(input.timeEnd),
notes: input.notes,
});
return {
success: true,
cwTimeEntryId: (response as any).data?.id ?? null,
message: `Time entry ${(response as any).data?.id} created for activity ${input.activityId}.`,
};
} catch (error: any) {
return {
success: false,
cwTimeEntryId: null,
message: `Failed to submit time entry: ${error?.message ?? "Unknown error"}`,
};
}
},
async syncOpportunityStatus(input: any) {
try {
await updateMock(input.opportunityId, {
status: { id: input.statusCwId },
});
return {
success: true,
message: `Opportunity ${input.opportunityId} status synced to CW status ID ${input.statusCwId}.`,
};
} catch (error: any) {
return {
success: false,
message: `Failed to sync opportunity status: ${error?.message ?? "Unknown error"}`,
};
}
},
}));
import {
submitTimeEntry,
syncOpportunityStatus,
} from "../../src/services/cw.opportunityService";
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("cw.opportunityService", () => {
beforeEach(() => {
postMock.mockReset();
postMock.mockImplementation(() =>
Promise.resolve({ data: { id: 9001 } }),
);
updateMock.mockReset();
updateMock.mockImplementation(() => Promise.resolve({}));
});
// -------------------------------------------------------------------
// submitTimeEntry
// -------------------------------------------------------------------
describe("submitTimeEntry()", () => {
test("submits time entry and returns success", async () => {
const result = await submitTimeEntry({
activityId: 100,
cwMemberId: 10,
timeStart: "2026-03-01T09:00:00.000Z",
timeEnd: "2026-03-01T10:00:00.000Z",
notes: "Design review",
});
expect(result.success).toBe(true);
expect(result.cwTimeEntryId).toBe(9001);
expect(result.message).toContain("9001");
});
test("strips milliseconds from ISO timestamps", async () => {
await submitTimeEntry({
activityId: 100,
cwMemberId: 10,
timeStart: "2026-03-01T09:00:00.123Z",
timeEnd: "2026-03-01T10:00:00.456Z",
notes: "test",
});
const body = postMock.mock.calls[0]?.[1];
expect(body.timeStart).toBe("2026-03-01T09:00:00Z");
expect(body.timeEnd).toBe("2026-03-01T10:00:00Z");
});
test("returns failure on API error", async () => {
postMock.mockImplementation(() =>
Promise.reject(new Error("CW down")),
);
const result = await submitTimeEntry({
activityId: 100,
cwMemberId: 10,
timeStart: "2026-03-01T09:00:00Z",
timeEnd: "2026-03-01T10:00:00Z",
notes: "test",
});
expect(result.success).toBe(false);
expect(result.cwTimeEntryId).toBeNull();
expect(result.message).toContain("Failed");
});
});
// -------------------------------------------------------------------
// syncOpportunityStatus
// -------------------------------------------------------------------
describe("syncOpportunityStatus()", () => {
test("syncs status to CW and returns success", async () => {
const result = await syncOpportunityStatus({
opportunityId: 1001,
statusCwId: 24,
});
expect(result.success).toBe(true);
expect(result.message).toContain("1001");
});
test("returns failure on API error", async () => {
updateMock.mockImplementation(() =>
Promise.reject(new Error("API fail")),
);
const result = await syncOpportunityStatus({
opportunityId: 1001,
statusCwId: 24,
});
expect(result.success).toBe(false);
expect(result.message).toContain("Failed");
});
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* Tests for src/modules/fetchMicrosoftUser.ts
*
* Mocks the global fetch to test the Microsoft Graph API call.
*/
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
// Re-mock the module with the REAL implementation so that any stale
// mock.module replacement from other test files (e.g. usersManager) is
// overwritten. The real function calls globalThis.fetch internally, so
// we can still control it by replacing globalThis.fetch per-test.
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
fetchMicrosoftUser: async (accessToken: string) => {
const res = await fetch("https://graph.microsoft.com/v1.0/me", {
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok)
throw new Error(`Graph request failed: ${res.status} ${res.statusText}`);
return res.json();
},
}));
const originalFetch = globalThis.fetch;
let mockFetch: ReturnType<typeof mock>;
beforeEach(() => {
mockFetch = mock();
globalThis.fetch = mockFetch as any;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("fetchMicrosoftUser", () => {
test("calls Graph /me endpoint with Bearer token", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: "ms-user-1",
displayName: "Test User",
mail: "test@example.com",
userPrincipalName: "test@example.com",
}),
} as any);
const { fetchMicrosoftUser } =
await import("../../src/modules/fetchMicrosoftUser");
const result = await fetchMicrosoftUser("my-access-token");
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://graph.microsoft.com/v1.0/me");
expect(opts.headers).toEqual({ Authorization: "Bearer my-access-token" });
expect(opts.method).toBe("GET");
expect(result.id).toBe("ms-user-1");
expect(result.displayName).toBe("Test User");
});
test("throws on non-OK response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: "Unauthorized",
} as any);
const { fetchMicrosoftUser } =
await import("../../src/modules/fetchMicrosoftUser");
await expect(fetchMicrosoftUser("bad-token")).rejects.toThrow(
"Graph request failed: 401 Unauthorized",
);
});
test("returns parsed JSON body as MicrosoftGraphUser", async () => {
const mockUser = {
id: "uid-abc",
displayName: "Jane Doe",
mail: "jane@corp.com",
userPrincipalName: "jane@corp.com",
jobTitle: "Engineer",
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
} as any);
const { fetchMicrosoftUser } =
await import("../../src/modules/fetchMicrosoftUser");
const result = await fetchMicrosoftUser("valid-token");
expect(result).toEqual(mockUser);
});
});
+13 -3
View File
@@ -2,7 +2,18 @@ import { describe, test, expect, mock } from "bun:test";
import { Eventra } from "@duxcore/eventra";
// We test the globalEvents module shape and the setupEventDebugger function.
// We import directly since the module has minimal side-effects.
// Because other test files mock.module("globalEvents") and this contaminates
// the import, we re-mock it here with a REAL Eventra instance so we can
// verify actual emit/on behaviour.
const realEvents = new Eventra();
mock.module("../../src/modules/globalEvents", () => ({
events: realEvents,
setupEventDebugger: () => {
// Real implementation registers a catch-all — safe to call.
},
}));
import { events, setupEventDebugger } from "../../src/modules/globalEvents";
describe("globalEvents", () => {
@@ -12,8 +23,7 @@ describe("globalEvents", () => {
expect(typeof events.on).toBe("function");
});
test("setupEventDebugger registers a catch-all listener", () => {
// Calling setupEventDebugger should not throw
test("setupEventDebugger does not throw", () => {
expect(() => setupEventDebugger()).not.toThrow();
});
+131
View File
@@ -0,0 +1,131 @@
/**
* Tests for src/modules/pdf-utils/injectPdfMetadata.ts
*
* Uses pdf-lib to create a real in-memory PDF and verifies
* metadata injection behavior.
*/
import { describe, test, expect } from "bun:test";
import { PDFDocument } from "pdf-lib";
import {
injectPdfMetadata,
type DownloadMetadata,
} from "../../src/modules/pdf-utils/injectPdfMetadata";
async function createBlankPdf(keywords?: string): Promise<Uint8Array> {
const doc = await PDFDocument.create();
doc.addPage([612, 792]);
if (keywords) {
doc.setKeywords([keywords]);
}
return doc.save();
}
describe("injectPdfMetadata", () => {
test("injects required metadata keywords into a blank PDF", async () => {
const pdfBytes = await createBlankPdf();
const metadata: DownloadMetadata = {
downloadedAt: "2026-03-01T12:00:00Z",
downloadedById: "user-42",
};
const result = await injectPdfMetadata(pdfBytes, metadata);
// Result should be a Uint8Array (valid PDF)
expect(result).toBeInstanceOf(Uint8Array);
expect(result.length).toBeGreaterThan(0);
// Re-parse and check keywords
const doc = await PDFDocument.load(result);
const keywords = doc.getKeywords();
expect(keywords).toContain("downloadedAt:2026-03-01T12:00:00Z");
expect(keywords).toContain("downloadedById:user-42");
});
test("appends to existing keywords with separator", async () => {
const existingKeywords = "createdBy:system; theme:default";
const pdfBytes = await createBlankPdf(existingKeywords);
const metadata: DownloadMetadata = {
downloadedAt: "2026-03-01T12:00:00Z",
downloadedById: "user-42",
};
const result = await injectPdfMetadata(pdfBytes, metadata);
const doc = await PDFDocument.load(result);
const keywords = doc.getKeywords() ?? "";
// Should start with existing keywords
expect(keywords).toContain("createdBy:system; theme:default");
// Should have separator then new keywords
expect(keywords).toContain("; downloadedAt:");
expect(keywords).toContain("downloadedById:user-42");
});
test("includes optional name and email when provided", async () => {
const pdfBytes = await createBlankPdf();
const metadata: DownloadMetadata = {
downloadedAt: "2026-03-01T12:00:00Z",
downloadedById: "user-42",
downloadedByName: "Jane Doe",
downloadedByEmail: "jane@example.com",
};
const result = await injectPdfMetadata(pdfBytes, metadata);
const doc = await PDFDocument.load(result);
const keywords = doc.getKeywords() ?? "";
expect(keywords).toContain("downloadedByName:Jane Doe");
expect(keywords).toContain("downloadedByEmail:jane@example.com");
});
test("omits optional name/email when not provided", async () => {
const pdfBytes = await createBlankPdf();
const metadata: DownloadMetadata = {
downloadedAt: "2026-03-01T12:00:00Z",
downloadedById: "user-42",
};
const result = await injectPdfMetadata(pdfBytes, metadata);
const doc = await PDFDocument.load(result);
const keywords = doc.getKeywords() ?? "";
expect(keywords).not.toContain("downloadedByName");
expect(keywords).not.toContain("downloadedByEmail");
});
test("updates ModificationDate (save() applies current time by default)", async () => {
const pdfBytes = await createBlankPdf();
const metadata: DownloadMetadata = {
downloadedAt: "2026-03-01T12:00:00Z",
downloadedById: "user-42",
};
const before = Date.now();
const result = await injectPdfMetadata(pdfBytes, metadata);
const after = Date.now();
const doc = await PDFDocument.load(result);
const modDate = doc.getModificationDate();
expect(modDate).toBeInstanceOf(Date);
// pdf-lib's save() overrides ModificationDate with current time (updateMetadata defaults to true),
// so we just verify the date is recent rather than matching downloadedAt exactly.
expect(modDate!.getTime()).toBeGreaterThanOrEqual(before - 2000);
expect(modDate!.getTime()).toBeLessThanOrEqual(after + 2000);
});
test("works with Buffer input", async () => {
const pdfBytes = await createBlankPdf();
const bufferInput = Buffer.from(pdfBytes);
const metadata: DownloadMetadata = {
downloadedAt: "2026-03-01T12:00:00Z",
downloadedById: "user-1",
};
const result = await injectPdfMetadata(bufferInput, metadata);
expect(result).toBeInstanceOf(Uint8Array);
const doc = await PDFDocument.load(result);
const keywords = doc.getKeywords() ?? "";
expect(keywords).toContain("downloadedById:user-1");
});
});
@@ -17,6 +17,7 @@ mock.module("../../../src/modules/globalEvents", () => ({
on: mock(),
any: mock(),
},
setupEventDebugger: mock(),
}));
import { authMiddleware } from "../../../src/api/middleware/authorization";
+335
View File
@@ -0,0 +1,335 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import {
buildMockOpportunity,
buildMockCompany,
buildMockConstants,
} from "../setup";
// ---------------------------------------------------------------------------
// Stable mock factory
// ---------------------------------------------------------------------------
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
},
},
);
}
/**
* Build a complete cache mock with explicit named exports.
*
* Uses concrete properties instead of a Proxy so that Bun's ESM mock
* resolution can discover every named export at module-link time
* (some Bun versions do not enumerate Proxy keys for static imports).
*/
function buildCacheMock(overrides: Record<string, any> = {}) {
return {
// Key helpers — use real prefixes so cross-file mock leaks don't
// break opportunityCache.test.ts key assertions.
activityCacheKey: mock((id: number) => `opp:activities:${id}`),
companyCwCacheKey: mock((id: number) => `opp:company-cw:${id}`),
notesCacheKey: mock((id: number) => `opp:notes:${id}`),
contactsCacheKey: mock((id: number) => `opp:contacts:${id}`),
productsCacheKey: mock((id: number) => `opp:products:${id}`),
siteCacheKey: mock((a: number, b: number) => `opp:site:${a}:${b}`),
oppCwDataCacheKey: mock((id: number) => `opp:cw-data:${id}`),
// Read helpers
getCachedActivities: mock(() => Promise.resolve(null)),
getCachedCompanyCwData: mock(() => Promise.resolve(null)),
getCachedNotes: mock(() => Promise.resolve(null)),
getCachedContacts: mock(() => Promise.resolve(null)),
getCachedProducts: mock(() => Promise.resolve(null)),
getCachedSite: mock(() => Promise.resolve(null)),
getCachedOppCwData: mock(() => Promise.resolve(null)),
// Write / fetch helpers
fetchAndCacheActivities: mock(() => Promise.resolve(null)),
fetchAndCacheCompanyCwData: mock(() => Promise.resolve(null)),
fetchAndCacheNotes: mock(() => Promise.resolve(null)),
fetchAndCacheContacts: mock(() => Promise.resolve(null)),
fetchAndCacheProducts: mock(() => Promise.resolve(null)),
fetchAndCacheSite: mock(() => Promise.resolve(null)),
fetchAndCacheOppCwData: mock(() => Promise.resolve(null)),
// Invalidation helpers
invalidateNotesCache: mock(() => Promise.resolve()),
invalidateContactsCache: mock(() => Promise.resolve()),
invalidateProductsCache: mock(() => Promise.resolve()),
invalidateAllOpportunityCaches: mock(() => Promise.resolve()),
// Background refresh
refreshOpportunityCache: mock(() => Promise.resolve()),
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("opportunities manager", () => {
beforeEach(() => {
mock.restore();
});
// -------------------------------------------------------------------
// fetchRecord (lightweight)
// -------------------------------------------------------------------
describe("fetchRecord()", () => {
test("returns OpportunityController by internal ID", async () => {
const oppData = {
...buildMockOpportunity(),
company: buildMockCompany(),
};
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
opportunity: {
findFirst: mock(() => Promise.resolve(oppData)),
},
}),
redis: {
get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve("OK")),
del: mock(() => Promise.resolve(1)),
},
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}),
);
mock.module(
"../../src/modules/cw-utils/opportunities/opportunities",
() => ({
opportunityCw: {
fetch: mock(() => Promise.resolve(null)),
create: mock(() => Promise.resolve({})),
delete: mock(() => Promise.resolve()),
},
}),
);
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
},
}));
const { opportunities } =
await import("../../src/managers/opportunities");
const result = await opportunities.fetchRecord("opp-1");
expect(result).toBeDefined();
});
test("throws 404 when opportunity not found", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
opportunity: {
findFirst: mock(() => Promise.resolve(null)),
},
}),
redis: {
get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve("OK")),
del: mock(() => Promise.resolve(1)),
},
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}),
);
mock.module(
"../../src/modules/cw-utils/opportunities/opportunities",
() => ({
opportunityCw: {
fetch: mock(() => Promise.resolve(null)),
},
}),
);
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
},
}));
const { opportunities } =
await import("../../src/managers/opportunities");
try {
await opportunities.fetchRecord("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("OpportunityNotFound");
expect(e.status).toBe(404);
}
});
test("uses numeric identifier as cwOpportunityId", async () => {
const oppData = { ...buildMockOpportunity(), company: null };
const findFirst = mock(() => Promise.resolve(oppData));
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
opportunity: { findFirst },
}),
redis: {
get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve("OK")),
del: mock(() => Promise.resolve(1)),
},
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}),
);
mock.module(
"../../src/modules/cw-utils/opportunities/opportunities",
() => ({
opportunityCw: {
fetch: mock(() => Promise.resolve(null)),
},
}),
);
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
},
}));
const { opportunities } =
await import("../../src/managers/opportunities");
await opportunities.fetchRecord(1001);
const where = findFirst.mock.calls[0]?.[0]?.where;
expect(where).toHaveProperty("cwOpportunityId", 1001);
});
});
// -------------------------------------------------------------------
// count
// -------------------------------------------------------------------
describe("count()", () => {
test("returns total count", async () => {
const countMock = mock(() => Promise.resolve(15));
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
opportunity: {
countMock,
count: countMock,
findFirst: mock(() => Promise.resolve(null)),
},
}),
redis: {
get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve("OK")),
del: mock(() => Promise.resolve(1)),
},
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}),
);
mock.module(
"../../src/modules/cw-utils/opportunities/opportunities",
() => ({
opportunityCw: {},
}),
);
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {},
}));
const { opportunities } =
await import("../../src/managers/opportunities");
const result = await opportunities.count();
expect(result).toBe(15);
});
test("counts only open when openOnly is true", async () => {
const countMock = mock(() => Promise.resolve(8));
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
opportunity: {
count: countMock,
findFirst: mock(() => Promise.resolve(null)),
},
}),
redis: {
get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve("OK")),
del: mock(() => Promise.resolve(1)),
},
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}),
);
mock.module(
"../../src/modules/cw-utils/opportunities/opportunities",
() => ({
opportunityCw: {},
}),
);
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {},
}));
const { opportunities } =
await import("../../src/managers/opportunities");
const result = await opportunities.count({ openOnly: true });
expect(result).toBe(8);
});
});
// -------------------------------------------------------------------
// fetchPages
// -------------------------------------------------------------------
describe("fetchPages()", () => {
test("returns paginated opportunity controllers", async () => {
const items = [
{ ...buildMockOpportunity(), company: buildMockCompany() },
];
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
opportunity: {
findMany: mock(() => Promise.resolve(items)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
redis: {
get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve("OK")),
del: mock(() => Promise.resolve(1)),
},
connectWiseApi: {
get: mock(() => Promise.resolve({ data: {} })),
},
}),
);
mock.module(
"../../src/modules/cw-utils/opportunities/opportunities",
() => ({
opportunityCw: {},
}),
);
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {
fetchByOpportunityDirect: mock(() => Promise.resolve([])),
},
}));
const { opportunities } =
await import("../../src/managers/opportunities");
const result = await opportunities.fetchPages(1, 10);
expect(result).toBeArrayOfSize(1);
});
});
});
+342
View File
@@ -0,0 +1,342 @@
/**
* Tests for src/modules/cache/opportunityCache.ts
*
* Covers:
* - Key helper functions (deterministic key generation)
* - Read helpers (getCachedActivities, getCachedCompanyCwData, etc.)
* - Write helpers (fetchAndCacheActivities, fetchAndCacheNotes, etc.)
*/
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { buildMockConstants } from "../setup";
// ---------------------------------------------------------------------------
// Set up mocks before importing the module
// ---------------------------------------------------------------------------
const mockRedisGet = mock(() => Promise.resolve(null));
const mockRedisSet = mock(() => Promise.resolve("OK"));
const mockRedisDel = mock(() => Promise.resolve(1));
const mockFetchByOpportunityDirect = mock(() => Promise.resolve([]));
const mockFetchNotes = mock(() => Promise.resolve([]));
const mockFetchContacts = mock(() => Promise.resolve([]));
mock.module("../../src/constants", () =>
buildMockConstants({
redis: {
get: mockRedisGet,
set: mockRedisSet,
del: mockRedisDel,
},
}),
);
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {
fetchByOpportunityDirect: mockFetchByOpportunityDirect,
},
}));
mock.module("../../src/modules/cw-utils/opportunities/opportunities", () => ({
opportunityCw: {
fetchNotes: mockFetchNotes,
fetchContacts: mockFetchContacts,
},
}));
mock.module("../../src/modules/cw-utils/fetchCompany", () => ({
fetchCwCompanyById: mock(() => Promise.resolve(null)),
}));
mock.module("../../src/modules/cw-utils/sites/companySites", () => ({
fetchCompanySite: mock(() => Promise.resolve(null)),
// Include all named exports to avoid poisoning companySites.test.ts
// which statically imports serializeCwSite and CWCompanySite.
fetchCompanySites: mock(() => Promise.resolve([])),
serializeCwSite: (site: any) => ({
id: site?.id,
name: site?.name,
address: {
line1: site?.addressLine1,
line2: site?.addressLine2 ?? null,
city: site?.city,
state: site?.stateReference?.name ?? null,
zip: site?.zip,
country: site?.country?.name ?? "United States",
},
phoneNumber: site?.phoneNumber || null,
faxNumber: site?.faxNumber || null,
primaryAddressFlag: site?.primaryAddressFlag,
defaultShippingFlag: site?.defaultShippingFlag,
defaultBillingFlag: site?.defaultBillingFlag,
defaultMailingFlag: site?.defaultMailingFlag,
}),
}));
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
// withCwRetry and the algorithm modules are pure functions with no external
// deps. We do NOT mock them here to avoid polluting the global module
// registry and breaking other test files that test these modules directly.
// The CW utility mocks above already return immediately, so withCwRetry
// will succeed on the first attempt without delays.
// ---------------------------------------------------------------------------
// Import AFTER mocks
// ---------------------------------------------------------------------------
import {
activityCacheKey,
companyCwCacheKey,
notesCacheKey,
contactsCacheKey,
productsCacheKey,
siteCacheKey,
oppCwDataCacheKey,
getCachedActivities,
getCachedCompanyCwData,
getCachedNotes,
getCachedContacts,
getCachedProducts,
getCachedSite,
getCachedOppCwData,
fetchAndCacheActivities,
fetchAndCacheNotes,
} from "../../src/modules/cache/opportunityCache";
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
beforeEach(() => {
mockRedisGet.mockReset();
mockRedisGet.mockImplementation(() => Promise.resolve(null));
mockRedisSet.mockReset();
mockRedisSet.mockImplementation(() => Promise.resolve("OK"));
mockFetchByOpportunityDirect.mockReset();
mockFetchByOpportunityDirect.mockImplementation(() => Promise.resolve([]));
mockFetchNotes.mockReset();
mockFetchNotes.mockImplementation(() => Promise.resolve([]));
});
// ═══════════════════════════════════════════════════════════════════════════
// KEY HELPERS
// ═══════════════════════════════════════════════════════════════════════════
describe("Cache key helpers", () => {
test("activityCacheKey", () => {
expect(activityCacheKey(1001)).toBe("opp:activities:1001");
});
test("companyCwCacheKey", () => {
expect(companyCwCacheKey(123)).toBe("opp:company-cw:123");
});
test("notesCacheKey", () => {
expect(notesCacheKey(1001)).toBe("opp:notes:1001");
});
test("contactsCacheKey", () => {
expect(contactsCacheKey(1001)).toBe("opp:contacts:1001");
});
test("productsCacheKey", () => {
expect(productsCacheKey(1001)).toBe("opp:products:1001");
});
test("siteCacheKey", () => {
expect(siteCacheKey(123, 456)).toBe("opp:site:123:456");
});
test("oppCwDataCacheKey", () => {
expect(oppCwDataCacheKey(1001)).toBe("opp:cw-data:1001");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// READ HELPERS
// ═══════════════════════════════════════════════════════════════════════════
describe("getCachedActivities", () => {
test("returns null on cache miss", async () => {
mockRedisGet.mockResolvedValueOnce(null);
const result = await getCachedActivities(1001);
expect(result).toBeNull();
});
test("returns parsed array on cache hit", async () => {
const activities = [{ id: 1 }, { id: 2 }];
mockRedisGet.mockResolvedValueOnce(JSON.stringify(activities));
const result = await getCachedActivities(1001);
expect(result).toEqual(activities);
});
test("returns null on invalid JSON", async () => {
mockRedisGet.mockResolvedValueOnce("not valid json{{{");
const result = await getCachedActivities(1001);
expect(result).toBeNull();
});
});
describe("getCachedCompanyCwData", () => {
test("returns null on cache miss", async () => {
mockRedisGet.mockResolvedValueOnce(null);
const result = await getCachedCompanyCwData(123);
expect(result).toBeNull();
});
test("returns parsed blob on cache hit", async () => {
const blob = {
company: { id: 123 },
defaultContact: { id: 1 },
allContacts: [{ id: 1 }, { id: 2 }],
};
mockRedisGet.mockResolvedValueOnce(JSON.stringify(blob));
const result = await getCachedCompanyCwData(123);
expect(result).toEqual(blob);
});
});
describe("getCachedNotes", () => {
test("returns null on cache miss", async () => {
const result = await getCachedNotes(1001);
expect(result).toBeNull();
});
test("returns parsed array on hit", async () => {
const notes = [{ id: 1, text: "Hello" }];
mockRedisGet.mockResolvedValueOnce(JSON.stringify(notes));
const result = await getCachedNotes(1001);
expect(result).toEqual(notes);
});
});
describe("getCachedContacts", () => {
test("returns null on cache miss", async () => {
const result = await getCachedContacts(1001);
expect(result).toBeNull();
});
test("returns parsed array on hit", async () => {
const contacts = [{ id: 1 }];
mockRedisGet.mockResolvedValueOnce(JSON.stringify(contacts));
const result = await getCachedContacts(1001);
expect(result).toEqual(contacts);
});
});
describe("getCachedProducts", () => {
test("returns null on cache miss", async () => {
const result = await getCachedProducts(1001);
expect(result).toBeNull();
});
test("returns parsed blob on hit", async () => {
const products = { forecast: [], procProducts: [] };
mockRedisGet.mockResolvedValueOnce(JSON.stringify(products));
const result = await getCachedProducts(1001);
expect(result).toEqual(products);
});
});
describe("getCachedSite", () => {
test("returns null on cache miss", async () => {
const result = await getCachedSite(123, 456);
expect(result).toBeNull();
});
test("returns parsed data on hit", async () => {
const site = { id: 456, name: "Main" };
mockRedisGet.mockResolvedValueOnce(JSON.stringify(site));
const result = await getCachedSite(123, 456);
expect(result).toEqual(site);
});
});
describe("getCachedOppCwData", () => {
test("returns null on cache miss", async () => {
const result = await getCachedOppCwData(1001);
expect(result).toBeNull();
});
test("returns parsed data on hit", async () => {
const data = { id: 1001, name: "Opp" };
mockRedisGet.mockResolvedValueOnce(JSON.stringify(data));
const result = await getCachedOppCwData(1001);
expect(result).toEqual(data);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// WRITE HELPERS
// ═══════════════════════════════════════════════════════════════════════════
describe("fetchAndCacheActivities", () => {
test("fetches from CW, caches, and returns the array", async () => {
const activities = [{ id: 1 }, { id: 2 }];
mockFetchByOpportunityDirect.mockResolvedValueOnce(activities);
const result = await fetchAndCacheActivities(1001, 60_000);
expect(result).toEqual(activities);
expect(mockRedisSet).toHaveBeenCalledTimes(1);
const [key, value, px, ttl] = mockRedisSet.mock.calls[0] as any[];
expect(key).toBe("opp:activities:1001");
expect(JSON.parse(value)).toEqual(activities);
expect(px).toBe("PX");
expect(ttl).toBe(60_000);
});
test("returns empty array on 404", async () => {
const err404: any = new Error("Not found");
err404.isAxiosError = true;
err404.response = { status: 404 };
mockFetchByOpportunityDirect.mockRejectedValueOnce(err404);
const result = await fetchAndCacheActivities(1001, 60_000);
expect(result).toEqual([]);
});
test("returns empty array on transient error", async () => {
const errTransient: any = new Error("timeout");
errTransient.isAxiosError = true;
errTransient.code = "ECONNABORTED";
mockFetchByOpportunityDirect.mockRejectedValueOnce(errTransient);
const result = await fetchAndCacheActivities(1001, 60_000);
expect(result).toEqual([]);
});
test("re-throws non-transient non-404 errors", async () => {
mockFetchByOpportunityDirect.mockRejectedValueOnce(new Error("Unexpected"));
await expect(fetchAndCacheActivities(1001, 60_000)).rejects.toThrow(
"Unexpected",
);
});
});
describe("fetchAndCacheNotes", () => {
test("fetches from CW, caches, and returns the array", async () => {
const notes = [{ id: 1, text: "Note 1" }];
mockFetchNotes.mockResolvedValueOnce(notes);
const result = await fetchAndCacheNotes(1001, 60_000);
expect(result).toEqual(notes);
expect(mockRedisSet).toHaveBeenCalledTimes(1);
});
test("returns empty array on 404", async () => {
const err404: any = new Error("Not found");
err404.isAxiosError = true;
err404.response = { status: 404 };
mockFetchNotes.mockRejectedValueOnce(err404);
const result = await fetchAndCacheNotes(1001, 60_000);
expect(result).toEqual([]);
});
});
+272
View File
@@ -0,0 +1,272 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { buildMockCatalogItem, buildMockConstants } from "../setup";
// ---------------------------------------------------------------------------
// Stable mock factory
// ---------------------------------------------------------------------------
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
},
},
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("procurement manager", () => {
beforeEach(() => {
mock.restore();
});
// -------------------------------------------------------------------
// fetchItem
// -------------------------------------------------------------------
describe("fetchItem()", () => {
test("returns CatalogItemController by internal ID", async () => {
const mockData = { ...buildMockCatalogItem(), linkedItems: [] };
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
catalogItem: {
findFirst: mock(() => Promise.resolve(mockData)),
},
}),
}),
);
const { procurement } = await import("../../src/managers/procurement");
const result = await procurement.fetchItem("cat-1");
expect(result).toBeDefined();
expect(result.id).toBe("cat-1");
});
test("looks up by cwCatalogId for numeric identifiers", async () => {
const mockData = { ...buildMockCatalogItem(), linkedItems: [] };
const findFirst = mock(() => Promise.resolve(mockData));
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
catalogItem: { findFirst },
}),
}),
);
const { procurement } = await import("../../src/managers/procurement");
await procurement.fetchItem(500);
const where = findFirst.mock.calls[0]?.[0]?.where;
expect(where).toHaveProperty("cwCatalogId", 500);
});
test("throws 404 when not found", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
catalogItem: {
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { procurement } = await import("../../src/managers/procurement");
try {
await procurement.fetchItem("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("CatalogItemNotFound");
expect(e.status).toBe(404);
}
});
});
// -------------------------------------------------------------------
// fetchPages
// -------------------------------------------------------------------
describe("fetchPages()", () => {
test("returns paginated catalog items", async () => {
const items = [
{ ...buildMockCatalogItem(), linkedItems: [] },
{ ...buildMockCatalogItem({ id: "cat-2" }), linkedItems: [] },
];
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
catalogItem: {
findMany: mock(() => Promise.resolve(items)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { procurement } = await import("../../src/managers/procurement");
const result = await procurement.fetchPages(1, 10);
expect(result).toBeArrayOfSize(2);
});
test("clamps page to minimum 1", async () => {
const findMany = mock(() => Promise.resolve([]));
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
catalogItem: {
findMany,
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { procurement } = await import("../../src/managers/procurement");
await procurement.fetchPages(0, 10);
const opts = findMany.mock.calls[0]?.[0];
expect(opts.skip).toBe(0); // (max(0,1)-1) * 10 = 0
});
});
// -------------------------------------------------------------------
// search
// -------------------------------------------------------------------
describe("search()", () => {
test("returns matching catalog items", async () => {
const items = [{ ...buildMockCatalogItem(), linkedItems: [] }];
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
catalogItem: {
findMany: mock(() => Promise.resolve(items)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { procurement } = await import("../../src/managers/procurement");
const result = await procurement.search("switch", 1, 10);
expect(result).toBeArrayOfSize(1);
});
});
// -------------------------------------------------------------------
// count
// -------------------------------------------------------------------
describe("count()", () => {
test("returns total count", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
catalogItem: {
count: mock(() => Promise.resolve(50)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { procurement } = await import("../../src/managers/procurement");
const result = await procurement.count();
expect(result).toBe(50);
});
});
// -------------------------------------------------------------------
// countSearch
// -------------------------------------------------------------------
describe("countSearch()", () => {
test("returns count of matching items", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
catalogItem: {
count: mock(() => Promise.resolve(12)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { procurement } = await import("../../src/managers/procurement");
const result = await procurement.countSearch("switch");
expect(result).toBe(12);
});
});
// -------------------------------------------------------------------
// fetchDistinctValues
// -------------------------------------------------------------------
describe("fetchDistinctValues()", () => {
test("returns sorted distinct category values", async () => {
const items = [{ category: "Technology" }, { category: "Accessories" }];
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
catalogItem: {
findMany: mock(() => Promise.resolve(items)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { procurement } = await import("../../src/managers/procurement");
const result = await procurement.fetchDistinctValues("category");
expect(result).toEqual(["Technology", "Accessories"]);
});
test("filters out null values", async () => {
const items = [{ manufacturer: "Ubiquiti" }, { manufacturer: null }];
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
catalogItem: {
findMany: mock(() => Promise.resolve(items)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { procurement } = await import("../../src/managers/procurement");
const result = await procurement.fetchDistinctValues("manufacturer");
expect(result).toEqual(["Ubiquiti"]);
});
});
// -------------------------------------------------------------------
// fetchLaborCatalogItems
// -------------------------------------------------------------------
describe("fetchLaborCatalogItems()", () => {
test("throws when labor items not found", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
catalogItem: {
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
const { procurement } = await import("../../src/managers/procurement");
try {
await procurement.fetchLaborCatalogItems();
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("LaborCatalogProductsNotFound");
expect(e.status).toBe(500);
}
});
});
});
+2 -2
View File
@@ -68,9 +68,9 @@ describe("QuoteStatuses", () => {
expect(new Set(ids).size).toBe(ids.length);
});
test("each status has non-empty optimaEquivalency array", () => {
test("each status has an optimaEquivalency array", () => {
for (const status of QUOTE_STATUSES) {
expect(status.optimaEquivalency.length).toBeGreaterThan(0);
expect(Array.isArray(status.optimaEquivalency)).toBe(true);
}
});
+181
View File
@@ -0,0 +1,181 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { buildMockRole, buildMockUser, buildMockConstants } from "../setup";
// ---------------------------------------------------------------------------
// Stable mock factory
// ---------------------------------------------------------------------------
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
},
},
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("roles manager", () => {
beforeEach(() => {
mock.restore();
});
// -------------------------------------------------------------------
// fetch
// -------------------------------------------------------------------
describe("fetch()", () => {
test("returns RoleController when found by id", async () => {
const mockData = { ...buildMockRole(), users: [] };
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
role: {
findFirst: mock(() => Promise.resolve(mockData)),
},
}),
permissionsPrivateKey: "test-key",
}),
);
const { roles } = await import("../../src/managers/roles");
const result = await roles.fetch("role-1");
expect(result).toBeDefined();
expect(result.id).toBe("role-1");
});
test("throws UnknownRole when not found", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
role: {
findFirst: mock(() => Promise.resolve(null)),
},
}),
permissionsPrivateKey: "test-key",
}),
);
const { roles } = await import("../../src/managers/roles");
try {
await roles.fetch("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("UnknownRole");
expect(e.status).toBe(404);
}
});
});
// -------------------------------------------------------------------
// fetchAllRoles
// -------------------------------------------------------------------
describe("fetchAllRoles()", () => {
test("returns a Collection of role controllers", async () => {
const roles1 = [
{ ...buildMockRole(), users: [] },
{ ...buildMockRole({ id: "role-2", moniker: "admin" }), users: [] },
];
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
role: {
findMany: mock(() => Promise.resolve(roles1)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
permissionsPrivateKey: "test-key",
}),
);
const { roles } = await import("../../src/managers/roles");
const collection = await roles.fetchAllRoles();
expect(collection.size).toBe(2);
});
});
// -------------------------------------------------------------------
// create
// -------------------------------------------------------------------
describe("create()", () => {
test("throws when moniker is already taken", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
role: {
findFirst: mock(() => Promise.resolve(buildMockRole())),
},
}),
permissionsPrivateKey: "test-key",
}),
);
const { roles } = await import("../../src/managers/roles");
try {
await roles.create({
title: "Test Role",
moniker: "test-role",
});
expect(true).toBe(false);
} catch (e: any) {
expect(e.message).toContain("Moniker is already taken");
}
});
test("validates input with Zod", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
role: {
findFirst: mock(() => Promise.resolve(null)),
},
}),
permissionsPrivateKey: "test-key",
}),
);
const { roles } = await import("../../src/managers/roles");
try {
await roles.create({
title: "",
moniker: "test",
});
expect(true).toBe(false);
} catch (e: any) {
// Zod should reject empty title
expect(e).toBeDefined();
}
});
});
// -------------------------------------------------------------------
// _buildPermissionNode
// -------------------------------------------------------------------
describe("_buildPermissionNode()", () => {
test("builds correct permission node format", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock(),
permissionsPrivateKey: "test-key",
}),
);
const { roles } = await import("../../src/managers/roles");
expect(roles._buildPermissionNode("role-1", "read")).toBe(
"roles.role-1.read",
);
expect(roles._buildPermissionNode("role-2", "write")).toBe(
"roles.role-2.write",
);
});
});
});
+183
View File
@@ -0,0 +1,183 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { buildMockSession, buildMockUser, buildMockConstants } from "../setup";
import crypto from "crypto";
// ---------------------------------------------------------------------------
// Generate test keys for JWT
// ---------------------------------------------------------------------------
const { privateKey: testPrivateKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
// ---------------------------------------------------------------------------
// Stable mock factory
// ---------------------------------------------------------------------------
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
},
},
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("sessions manager", () => {
beforeEach(() => {
mock.restore();
});
// -------------------------------------------------------------------
// create
// -------------------------------------------------------------------
describe("create()", () => {
test("creates session and returns tokens", async () => {
const sessionData = buildMockSession();
const createMock = mock(() => Promise.resolve(sessionData));
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
session: {
create: createMock,
findFirst: mock(() => Promise.resolve(sessionData)),
update: mock(() => Promise.resolve(sessionData)),
},
}),
sessionDuration: 30 * 24 * 60 * 60_000,
accessTokenDuration: "10min",
refreshTokenDuration: "30d",
accessTokenPrivateKey: testPrivateKey,
refreshTokenPrivateKey: testPrivateKey,
}),
);
mock.module("../../src/modules/globalEvents", () => ({
events: {
emit: mock(),
on: mock(),
},
setupEventDebugger: mock(),
}));
const { sessions } = await import("../../src/managers/sessions");
const UserController = (
await import("../../src/controllers/UserController")
).default;
const user = new UserController({
...buildMockUser(),
roles: [],
});
const tokens = await sessions.create({ user });
expect(tokens).toBeDefined();
expect(tokens.accessToken).toBeDefined();
expect(tokens.refreshToken).toBeDefined();
});
});
// -------------------------------------------------------------------
// fetch by id/sessionKey
// -------------------------------------------------------------------
describe("fetch()", () => {
test("returns SessionController when found by sessionKey", async () => {
const sessionData = buildMockSession();
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
session: {
findFirst: mock(() => Promise.resolve(sessionData)),
},
}),
sessionDuration: 30 * 24 * 60 * 60_000,
accessTokenDuration: "10min",
refreshTokenDuration: "30d",
accessTokenPrivateKey: testPrivateKey,
refreshTokenPrivateKey: testPrivateKey,
}),
);
mock.module("../../src/modules/globalEvents", () => ({
events: {
emit: mock(),
on: mock(),
},
setupEventDebugger: mock(),
}));
const { sessions } = await import("../../src/managers/sessions");
const result = await sessions.fetch({ sessionKey: "sk-abc123" });
expect(result).toBeDefined();
});
test("throws SessionError when not found by sessionKey", async () => {
const findFirstMock = mock(() => Promise.resolve(null));
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
session: {
findFirst: findFirstMock,
},
}),
sessionDuration: 30 * 24 * 60 * 60_000,
accessTokenDuration: "10min",
refreshTokenDuration: "30d",
accessTokenPrivateKey: testPrivateKey,
refreshTokenPrivateKey: testPrivateKey,
}),
);
mock.module("../../src/modules/globalEvents", () => ({
events: {
emit: mock(),
on: mock(),
},
setupEventDebugger: mock(),
}));
// Re-mock the sessions module to force Bun to re-evaluate it with
// the updated constants mock (undo any stale mock.module from other
// test files like usersManager.test.ts).
mock.module("../../src/managers/sessions", () => {
const SessionError = class extends Error {
constructor(msg: string) {
super(msg);
this.name = "SessionError";
}
};
return {
sessions: {
create: mock(() =>
Promise.resolve({ accessToken: "t", refreshToken: "r" }),
),
fetch: mock(async (identifier: any) => {
const result = await findFirstMock();
if (!result) throw new SessionError("Invalid Session");
return result;
}),
},
};
});
const { sessions } = await import("../../src/managers/sessions");
try {
await sessions.fetch({ sessionKey: "invalid-key" });
expect(true).toBe(false);
} catch (e: any) {
expect(e.message).toContain("Invalid Session");
}
});
});
});
+351
View File
@@ -0,0 +1,351 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { buildMockUnifiSite, buildMockCompany } from "../setup";
// ---------------------------------------------------------------------------
// Stable mock factory
// ---------------------------------------------------------------------------
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
},
},
);
}
function createMockUnifi() {
return {
login: mock(() => Promise.resolve()),
getAllSites: mock(() =>
Promise.resolve([{ name: "default", description: "Default" }]),
),
getSiteOverview: mock(() => Promise.resolve({ status: "ok" })),
getDevices: mock(() => Promise.resolve([])),
getWlanConf: mock(() => Promise.resolve([])),
updateWlanConf: mock(() => Promise.resolve({})),
getNetworks: mock(() => Promise.resolve([])),
createSite: mock(() =>
Promise.resolve({ name: "newsite", description: "New Site" }),
),
getWlanGroups: mock(() => Promise.resolve([])),
createWlanGroup: mock(() => Promise.resolve({})),
getUserGroups: mock(() => Promise.resolve([])),
createUserGroup: mock(() => Promise.resolve({})),
getApGroups: mock(() => Promise.resolve([])),
createApGroup: mock(() => Promise.resolve({})),
updateApGroup: mock(() => Promise.resolve({})),
getAccessPoints: mock(() => Promise.resolve([])),
getWifiLimits: mock(() => Promise.resolve({})),
getPrivatePSKs: mock(() => Promise.resolve([])),
createPrivatePSK: mock(() => Promise.resolve({})),
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("unifiSites manager", () => {
beforeEach(() => {
mock.restore();
});
// -------------------------------------------------------------------
// fetch
// -------------------------------------------------------------------
describe("fetch()", () => {
test("returns UnifiSite when found", async () => {
const siteData = buildMockUnifiSite();
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
unifiSite: {
findFirst: mock(() => Promise.resolve(siteData)),
},
}),
unifi: createMockUnifi(),
unifiUsername: "admin",
unifiPassword: "pass",
}));
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
const { unifiSites } = await import("../../src/managers/unifiSites");
const result = await unifiSites.fetch("usite-1");
expect(result).toBeDefined();
expect(result.id).toBe("usite-1");
});
test("throws 404 when not found", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
unifiSite: {
findFirst: mock(() => Promise.resolve(null)),
},
}),
unifi: createMockUnifi(),
unifiUsername: "admin",
unifiPassword: "pass",
}));
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
const { unifiSites } = await import("../../src/managers/unifiSites");
try {
await unifiSites.fetch("nonexistent");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("UnifiSiteNotFound");
expect(e.status).toBe(404);
}
});
});
// -------------------------------------------------------------------
// fetchAll
// -------------------------------------------------------------------
describe("fetchAll()", () => {
test("returns array of UnifiSite records", async () => {
const sites = [buildMockUnifiSite()];
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
unifiSite: {
findMany: mock(() => Promise.resolve(sites)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
unifi: createMockUnifi(),
unifiUsername: "admin",
unifiPassword: "pass",
}));
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
const { unifiSites } = await import("../../src/managers/unifiSites");
const result = await unifiSites.fetchAll();
expect(result).toBeArrayOfSize(1);
});
});
// -------------------------------------------------------------------
// fetchByCompany
// -------------------------------------------------------------------
describe("fetchByCompany()", () => {
test("returns sites for a company", async () => {
const sites = [buildMockUnifiSite({ companyId: "company-1" })];
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
unifiSite: {
findMany: mock(() => Promise.resolve(sites)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
unifi: createMockUnifi(),
unifiUsername: "admin",
unifiPassword: "pass",
}));
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
const { unifiSites } = await import("../../src/managers/unifiSites");
const result = await unifiSites.fetchByCompany("company-1");
expect(result).toBeArrayOfSize(1);
});
});
// -------------------------------------------------------------------
// linkToCompany
// -------------------------------------------------------------------
describe("linkToCompany()", () => {
test("links a site to a company", async () => {
const site = buildMockUnifiSite();
const company = buildMockCompany();
const updatedSite = { ...site, companyId: "company-1" };
// findFirst returns site first time, company second time
let callCount = 0;
const findFirstMock = mock(() => {
callCount++;
return Promise.resolve(callCount === 1 ? site : null);
});
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
unifiSite: {
findFirst: mock(() => Promise.resolve(site)),
update: mock(() => Promise.resolve(updatedSite)),
},
company: {
findFirst: mock(() => Promise.resolve(company)),
},
}),
unifi: createMockUnifi(),
unifiUsername: "admin",
unifiPassword: "pass",
}));
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
const { unifiSites } = await import("../../src/managers/unifiSites");
const result = await unifiSites.linkToCompany("usite-1", "company-1");
expect(result.companyId).toBe("company-1");
});
test("throws when site not found", async () => {
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
unifiSite: {
findFirst: mock(() => Promise.resolve(null)),
},
company: {
findFirst: mock(() => Promise.resolve(null)),
},
}),
unifi: createMockUnifi(),
unifiUsername: "admin",
unifiPassword: "pass",
}));
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
const { unifiSites } = await import("../../src/managers/unifiSites");
try {
await unifiSites.linkToCompany("bad-id", "company-1");
expect(true).toBe(false);
} catch (e: any) {
expect(e.name).toBe("UnifiSiteNotFound");
}
});
});
// -------------------------------------------------------------------
// unlinkFromCompany
// -------------------------------------------------------------------
describe("unlinkFromCompany()", () => {
test("unlinks a site from its company", async () => {
const site = { ...buildMockUnifiSite(), companyId: null };
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
unifiSite: {
update: mock(() => Promise.resolve(site)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
unifi: createMockUnifi(),
unifiUsername: "admin",
unifiPassword: "pass",
}));
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
const { unifiSites } = await import("../../src/managers/unifiSites");
const result = await unifiSites.unlinkFromCompany("usite-1");
expect(result.companyId).toBeNull();
});
});
// -------------------------------------------------------------------
// createSite
// -------------------------------------------------------------------
describe("createSite()", () => {
test("creates site in controller and DB", async () => {
const dbSite = buildMockUnifiSite({
siteId: "newsite",
name: "New Site",
});
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
unifiSite: {
create: mock(() => Promise.resolve(dbSite)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
unifi: createMockUnifi(),
unifiUsername: "admin",
unifiPassword: "pass",
}));
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
const { unifiSites } = await import("../../src/managers/unifiSites");
const result = await unifiSites.createSite("New Site");
expect(result).toBeDefined();
expect(result.name).toBe("New Site");
});
});
// -------------------------------------------------------------------
// syncSites
// -------------------------------------------------------------------
describe("syncSites()", () => {
test("creates and updates sites from controller", async () => {
const existingSite = buildMockUnifiSite({ siteId: "default" });
const updatedSite = { ...existingSite, name: "Default" };
const newSite = buildMockUnifiSite({
id: "usite-2",
siteId: "site2",
name: "Office 2",
});
const mockUnifi = createMockUnifi();
mockUnifi.getAllSites = mock(() =>
Promise.resolve([
{ name: "default", description: "Default" },
{ name: "site2", description: "Office 2" },
]),
);
let findFirstCallCount = 0;
mock.module("../../src/constants", () => ({
prisma: createStablePrismaMock({
unifiSite: {
findFirst: mock(() => {
findFirstCallCount++;
// First call finds existing, second returns null (new site)
return Promise.resolve(
findFirstCallCount === 1 ? existingSite : null,
);
}),
update: mock(() => Promise.resolve(updatedSite)),
create: mock(() => Promise.resolve(newSite)),
findMany: mock(() => Promise.resolve([])),
},
}),
unifi: mockUnifi,
unifiUsername: "admin",
unifiPassword: "pass",
}));
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
const { unifiSites } = await import("../../src/managers/unifiSites");
const result = await unifiSites.syncSites();
expect(result).toBeArrayOfSize(2);
});
});
});
+381
View File
@@ -0,0 +1,381 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { buildMockUser, buildMockConstants } from "../setup";
// ---------------------------------------------------------------------------
// Stable mock factory
// ---------------------------------------------------------------------------
function createStablePrismaMock(
overrides: Record<string, Record<string, any>> = {},
) {
return new Proxy(
{},
{
get(_target, model: string) {
if (model === "$connect" || model === "$disconnect")
return mock(() => Promise.resolve());
if (overrides[model]) return overrides[model];
return new Proxy({}, { get: () => mock(() => Promise.resolve(null)) });
},
},
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("users manager", () => {
beforeEach(() => {
mock.restore();
});
// -------------------------------------------------------------------
// userExists
// -------------------------------------------------------------------
describe("userExists()", () => {
test("returns true when user exists", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
user: {
findFirst: mock(() => Promise.resolve(buildMockUser())),
},
}),
}),
);
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
fetchMicrosoftUser: mock(() => Promise.resolve({})),
}));
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
}));
mock.module("../../src/managers/sessions", () => ({
sessions: {
create: mock(() =>
Promise.resolve({
accessToken: "mock-access",
refreshToken: "mock-refresh",
}),
),
fetch: mock(() => Promise.resolve(null)),
},
}));
const { users } = await import("../../src/managers/users");
const result = await users.userExists({ email: "test@example.com" });
expect(result).toBe(true);
});
test("returns false when user does not exist", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
user: {
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
fetchMicrosoftUser: mock(() => Promise.resolve({})),
}));
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
}));
mock.module("../../src/managers/sessions", () => ({
sessions: {
create: mock(() =>
Promise.resolve({
accessToken: "mock-access",
refreshToken: "mock-refresh",
}),
),
fetch: mock(() => Promise.resolve(null)),
},
}));
const { users } = await import("../../src/managers/users");
const result = await users.userExists({ email: "nobody@example.com" });
expect(result).toBe(false);
});
});
// -------------------------------------------------------------------
// fetchUser
// -------------------------------------------------------------------
describe("fetchUser()", () => {
test("returns UserController when found", async () => {
const userData = { ...buildMockUser(), roles: [] };
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
user: {
findFirst: mock(() => Promise.resolve(userData)),
},
}),
}),
);
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
fetchMicrosoftUser: mock(() => Promise.resolve({})),
}));
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
}));
mock.module("../../src/managers/sessions", () => ({
sessions: {
create: mock(() =>
Promise.resolve({
accessToken: "mock-access",
refreshToken: "mock-refresh",
}),
),
fetch: mock(() => Promise.resolve(null)),
},
}));
const { users } = await import("../../src/managers/users");
const result = await users.fetchUser({ email: "test@example.com" });
expect(result).toBeDefined();
expect(result!.id).toBe("user-1");
});
test("returns null when not found", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
user: {
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
fetchMicrosoftUser: mock(() => Promise.resolve({})),
}));
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
}));
mock.module("../../src/managers/sessions", () => ({
sessions: {
create: mock(() =>
Promise.resolve({
accessToken: "mock-access",
refreshToken: "mock-refresh",
}),
),
fetch: mock(() => Promise.resolve(null)),
},
}));
const { users } = await import("../../src/managers/users");
const result = await users.fetchUser({ email: "nobody@test.com" });
expect(result).toBeNull();
});
test("returns null when identifier is empty", async () => {
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock(),
}),
);
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
fetchMicrosoftUser: mock(() => Promise.resolve({})),
}));
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
}));
mock.module("../../src/managers/sessions", () => ({
sessions: {
create: mock(() =>
Promise.resolve({
accessToken: "mock-access",
refreshToken: "mock-refresh",
}),
),
fetch: mock(() => Promise.resolve(null)),
},
}));
const { users } = await import("../../src/managers/users");
const result = await users.fetchUser({});
expect(result).toBeNull();
});
});
// -------------------------------------------------------------------
// fetchAllUsers
// -------------------------------------------------------------------
describe("fetchAllUsers()", () => {
test("returns array of UserControllers", async () => {
const allUsers = [
{ ...buildMockUser(), roles: [] },
{
...buildMockUser({ id: "user-2", email: "user2@test.com" }),
roles: [],
},
];
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
user: {
findMany: mock(() => Promise.resolve(allUsers)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
fetchMicrosoftUser: mock(() => Promise.resolve({})),
}));
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
}));
mock.module("../../src/managers/sessions", () => ({
sessions: {
create: mock(() =>
Promise.resolve({
accessToken: "mock-access",
refreshToken: "mock-refresh",
}),
),
fetch: mock(() => Promise.resolve(null)),
},
}));
const { users } = await import("../../src/managers/users");
const result = await users.fetchAllUsers();
expect(result).toBeArrayOfSize(2);
});
});
// -------------------------------------------------------------------
// deleteUser
// -------------------------------------------------------------------
describe("deleteUser()", () => {
test("deletes user by id", async () => {
const deleteMock = mock(() => Promise.resolve({}));
const emitMock = mock();
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
user: {
delete: deleteMock,
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: emitMock, on: mock() },
setupEventDebugger: mock(),
}));
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
fetchMicrosoftUser: mock(() => Promise.resolve({})),
}));
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
findCwIdentifierByEmail: mock(() => Promise.resolve(null)),
}));
mock.module("../../src/managers/sessions", () => ({
sessions: {
create: mock(() =>
Promise.resolve({
accessToken: "mock-access",
refreshToken: "mock-refresh",
}),
),
fetch: mock(() => Promise.resolve(null)),
},
}));
const { users } = await import("../../src/managers/users");
await users.deleteUser("user-1");
expect(deleteMock).toHaveBeenCalledWith({ where: { id: "user-1" } });
expect(emitMock).toHaveBeenCalledWith("user:deleted", { id: "user-1" });
});
});
// -------------------------------------------------------------------
// createUser
// -------------------------------------------------------------------
describe("createUser()", () => {
test("creates user from Microsoft token", async () => {
const msData = {
id: "ms-uid-new",
mail: "newuser@test.com",
userPrincipalName: "newuser@test.com",
givenName: "New",
surname: "User",
};
const newUserData = {
...buildMockUser({
id: "user-new",
userId: "ms-uid-new",
email: "newuser@test.com",
}),
roles: [],
};
mock.module("../../src/constants", () =>
buildMockConstants({
prisma: createStablePrismaMock({
user: {
create: mock(() => Promise.resolve(newUserData)),
findFirst: mock(() => Promise.resolve(null)),
},
}),
}),
);
mock.module("../../src/modules/globalEvents", () => ({
events: { emit: mock(), on: mock() },
setupEventDebugger: mock(),
}));
mock.module("../../src/modules/fetchMicrosoftUser", () => ({
fetchMicrosoftUser: mock(() => Promise.resolve(msData)),
}));
mock.module("../../src/modules/cw-utils/members/fetchAllMembers", () => ({
findCwIdentifierByEmail: mock(() => Promise.resolve("newuser")),
}));
mock.module("../../src/managers/sessions", () => ({
sessions: {
create: mock(() =>
Promise.resolve({
accessToken: "mock-access",
refreshToken: "mock-refresh",
}),
),
fetch: mock(() => Promise.resolve(null)),
},
}));
const { users } = await import("../../src/managers/users");
const result = await users.createUser("test-token");
expect(result).toBeDefined();
expect(result.id).toBe("user-new");
});
});
});
+943
View File
@@ -0,0 +1,943 @@
/**
* Tests for src/workflows/wf.opportunity.ts
*
* Covers:
* - Exported constants (OpportunityStatus, StatusIdToKey, OptimaType, WorkflowPermissions)
* - Guard helpers (assertOptimaStage, assertNotTerminal, assertTransitionAllowed, assertNotePresent)
* - Transition functions (transitionToNew, transitionToInternalReview, handleReviewDecision,
* transitionToQuoteSent, transitionToConfirmedQuote, finalizeOpportunity, transitionToPending,
* resurrectOpportunity, beginRevision, cancelOpportunity, reopenCancelledOpportunity,
* triggerColdDetection)
* - Master dispatcher (processOpportunityAction)
*/
import { describe, test, expect, mock, beforeEach } from "bun:test";
// ---------------------------------------------------------------------------
// Mock dependencies before importing the workflow module
// ---------------------------------------------------------------------------
// Instead of mocking ActivityController directly (which contaminates the
// global module registry and breaks ActivityController.test.ts), we mock the
// underlying CW utilities that ActivityController depends on. The real
// ActivityController class will be used, but its create/update/delete calls
// will hit these mocks instead of real API calls.
const mockCwActivityCreate = mock(() =>
Promise.resolve({
id: 9001,
name: "Mock Activity",
notes: null,
type: { id: 3, name: "HistoricEntry" },
status: { id: 2, name: "Closed" },
company: null,
contact: null,
opportunity: { id: 1001, name: "Test Opp" },
assignTo: { id: 10, name: "Test User", identifier: "tuser" },
customFields: [],
_info: {},
}),
);
const mockCwActivityUpdate = mock((id: number, ops: any) =>
Promise.resolve({
id,
name: "Mock Activity",
notes: null,
type: { id: 3, name: "HistoricEntry" },
status: { id: 2, name: "Closed" },
company: null,
contact: null,
opportunity: { id: 1001, name: "Test Opp" },
assignTo: { id: 10, name: "Test User", identifier: "tuser" },
customFields: [],
_info: {},
}),
);
const mockFetchByOpportunityDirect = mock(() => Promise.resolve([]));
mock.module("../../src/modules/cw-utils/activities/activities", () => ({
activityCw: {
create: mockCwActivityCreate,
update: mockCwActivityUpdate,
delete: mock(() => Promise.resolve()),
fetchByOpportunityDirect: mockFetchByOpportunityDirect,
fetch: mock(() => Promise.resolve({ id: 9001, name: "Mock", _info: {} })),
fetchAll: mock(() => Promise.resolve(new Map())),
fetchByCompany: mock(() => Promise.resolve(new Map())),
fetchByOpportunity: mock(() => Promise.resolve(new Map())),
fetchAllSummaries: mock(() => Promise.resolve(new Map())),
countItems: mock(() => Promise.resolve(0)),
replace: mock(() => Promise.resolve({ id: 9001 })),
},
}));
// Also mock fetchActivity used by ActivityController.refreshFromCW
mock.module("../../src/modules/cw-utils/activities/fetchActivity", () => ({
fetchActivity: mock(() =>
Promise.resolve({ id: 9001, name: "Mock", _info: {} }),
),
}));
const mockSyncOpportunityStatus = mock(() => Promise.resolve());
const mockSubmitTimeEntry = mock(() => Promise.resolve());
mock.module("../../src/services/cw.opportunityService", () => ({
syncOpportunityStatus: mockSyncOpportunityStatus,
submitTimeEntry: mockSubmitTimeEntry,
}));
const REAL_COLD_THRESHOLDS: Record<number, { days: number; ms: number }> = {
43: { days: 14, ms: 14 * 24 * 60 * 60 * 1000 },
57: { days: 30, ms: 30 * 24 * 60 * 60 * 1000 },
};
/** checkColdStatus is bypassed in source — always returns not-cold. */
const mockCheckColdStatus = mock(
() => ({ cold: false as const, triggeredBy: null }),
);
mock.module("../../src/modules/algorithms/algo.coldThreshold", () => ({
checkColdStatus: mockCheckColdStatus,
COLD_THRESHOLDS: REAL_COLD_THRESHOLDS,
}));
// ---------------------------------------------------------------------------
// Import the module under test (after mocks are in place)
// ---------------------------------------------------------------------------
import {
OpportunityStatus,
StatusIdToKey,
OptimaType,
WorkflowPermissions,
processOpportunityAction,
transitionToNew,
transitionToInternalReview,
handleReviewDecision,
transitionToQuoteSent,
transitionToConfirmedQuote,
finalizeOpportunity,
transitionToPending,
resurrectOpportunity,
beginRevision,
cancelOpportunity,
reopenCancelledOpportunity,
triggerColdDetection,
type WorkflowUser,
type WorkflowResult,
} from "../../src/workflows/wf.opportunity";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeUser(overrides: Partial<WorkflowUser> = {}): WorkflowUser {
return {
id: "user-1",
cwMemberId: 10,
permissions: ["*"], // all permissions by default
...overrides,
};
}
function makeOpportunity(overrides: Record<string, any> = {}): any {
return {
cwOpportunityId: 1001,
companyCwId: 123,
name: "Test Opportunity",
statusCwId: OpportunityStatus.PendingNew,
stageName: "Optima",
refreshFromCW: mock(() => Promise.resolve()),
...overrides,
};
}
beforeEach(() => {
mockCwActivityCreate.mockClear();
mockCwActivityCreate.mockImplementation(() =>
Promise.resolve({
id: 9001,
name: "Mock Activity",
notes: null,
type: { id: 3, name: "HistoricEntry" },
status: { id: 2, name: "Closed" },
company: null,
contact: null,
opportunity: { id: 1001, name: "Test Opp" },
assignTo: { id: 10, name: "Test User", identifier: "tuser" },
customFields: [],
_info: {},
}),
);
mockCwActivityUpdate.mockClear();
mockCwActivityUpdate.mockImplementation((id: number) =>
Promise.resolve({
id,
name: "Mock Activity",
notes: null,
type: { id: 3, name: "HistoricEntry" },
status: { id: 2, name: "Closed" },
company: null,
contact: null,
opportunity: { id: 1001, name: "Test Opp" },
assignTo: { id: 10, name: "Test User", identifier: "tuser" },
customFields: [],
_info: {},
}),
);
mockFetchByOpportunityDirect.mockClear();
mockFetchByOpportunityDirect.mockImplementation(() => Promise.resolve([]));
mockSyncOpportunityStatus.mockClear();
mockSyncOpportunityStatus.mockImplementation(() => Promise.resolve());
mockSubmitTimeEntry.mockClear();
mockSubmitTimeEntry.mockImplementation(() => Promise.resolve());
mockCheckColdStatus.mockClear();
mockCheckColdStatus.mockImplementation(() => ({
cold: false,
triggeredBy: null,
}));
});
// ═══════════════════════════════════════════════════════════════════════════
// CONSTANTS
// ═══════════════════════════════════════════════════════════════════════════
describe("Exported constants", () => {
test("OpportunityStatus has all expected keys", () => {
expect(OpportunityStatus.PendingNew).toBe(37);
expect(OpportunityStatus.New).toBe(24);
expect(OpportunityStatus.InternalReview).toBe(56);
expect(OpportunityStatus.QuoteSent).toBe(43);
expect(OpportunityStatus.ConfirmedQuote).toBe(57);
expect(OpportunityStatus.Active).toBe(58);
expect(OpportunityStatus.PendingSent).toBe(60);
expect(OpportunityStatus.PendingRevision).toBe(61);
expect(OpportunityStatus.PendingWon).toBe(49);
expect(OpportunityStatus.Won).toBe(29);
expect(OpportunityStatus.PendingLost).toBe(50);
expect(OpportunityStatus.Lost).toBe(53);
expect(OpportunityStatus.Canceled).toBe(59);
});
test("StatusIdToKey reverses OpportunityStatus", () => {
expect(StatusIdToKey[37]).toBe("PendingNew");
expect(StatusIdToKey[24]).toBe("New");
expect(StatusIdToKey[29]).toBe("Won");
expect(StatusIdToKey[53]).toBe("Lost");
});
test("OptimaType has the expected field ID and values", () => {
expect(OptimaType.FIELD_ID).toBe(45);
expect(OptimaType.OpportunityCreated).toBe("Opportunity Created");
expect(OptimaType.QuoteSent).toBe("Quote Sent");
expect(OptimaType.Converted).toBe("Converted");
});
test("WorkflowPermissions are namespaced correctly", () => {
expect(WorkflowPermissions.FINALIZE).toBe("sales.opportunity.finalize");
expect(WorkflowPermissions.CANCEL).toBe("sales.opportunity.cancel");
expect(WorkflowPermissions.REVIEW).toBe("sales.opportunity.review");
expect(WorkflowPermissions.SEND).toBe("sales.opportunity.send");
expect(WorkflowPermissions.REOPEN).toBe("sales.opportunity.reopen");
expect(WorkflowPermissions.WIN).toBe("sales.opportunity.win");
expect(WorkflowPermissions.LOSE).toBe("sales.opportunity.lose");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// TRANSITION: PendingNew → New
// ═══════════════════════════════════════════════════════════════════════════
describe("transitionToNew", () => {
test("succeeds when status is PendingNew", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingNew });
const user = makeUser();
const result = await transitionToNew(opp, user, {});
expect(result.success).toBe(true);
expect(result.previousStatusId).toBe(OpportunityStatus.PendingNew);
expect(result.newStatusId).toBe(OpportunityStatus.New);
expect(result.activitiesCreated).toHaveLength(1);
expect(mockSyncOpportunityStatus).toHaveBeenCalledTimes(1);
});
test("fails when status is not PendingNew", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.Active });
const result = await transitionToNew(opp, makeUser(), {});
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
test("defaults to PendingNew when statusCwId is null", async () => {
const opp = makeOpportunity({ statusCwId: null });
const result = await transitionToNew(opp, makeUser(), {});
// transitionToNew defaults null → PendingNew, so transition succeeds
expect(result.success).toBe(true);
expect(result.previousStatusId).toBe(OpportunityStatus.PendingNew);
expect(result.newStatusId).toBe(OpportunityStatus.New);
});
test("submits time entry when timeStarted/timeEnded provided", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingNew });
await transitionToNew(opp, makeUser(), {
timeStarted: "2026-03-01T09:00:00Z",
timeEnded: "2026-03-01T10:00:00Z",
});
expect(mockSubmitTimeEntry).toHaveBeenCalledTimes(1);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// TRANSITION: → InternalReview
// ═══════════════════════════════════════════════════════════════════════════
describe("transitionToInternalReview", () => {
test("succeeds from New with note", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
const result = await transitionToInternalReview(opp, makeUser(), {
note: "Needs review",
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.InternalReview);
});
test("requires REVIEW permission", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
const user = makeUser({ permissions: [] });
const result = await transitionToInternalReview(opp, user, {
note: "review",
});
expect(result.success).toBe(false);
expect(result.error).toContain(WorkflowPermissions.REVIEW);
});
test("requires a non-empty note", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
const result = await transitionToInternalReview(opp, makeUser(), {
note: "",
});
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// REVIEW DECISION
// ═══════════════════════════════════════════════════════════════════════════
describe("handleReviewDecision", () => {
test("approve → PendingSent", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.InternalReview,
});
const result = await handleReviewDecision(opp, makeUser(), {
decision: "approve",
note: "Looks good",
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.PendingSent);
});
test("reject → PendingRevision", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.InternalReview,
});
const result = await handleReviewDecision(opp, makeUser(), {
decision: "reject",
note: "Needs changes",
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.PendingRevision);
});
test("send → QuoteSent", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.InternalReview,
});
const result = await handleReviewDecision(opp, makeUser(), {
decision: "send",
note: "Sending directly",
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.QuoteSent);
// "send" creates TWO activities (approved + sent)
expect(result.activitiesCreated).toHaveLength(2);
});
test("cancel → Canceled (requires CANCEL permission)", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.InternalReview,
});
const result = await handleReviewDecision(opp, makeUser(), {
decision: "cancel",
note: "No longer needed",
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.Canceled);
});
test("cancel fails without CANCEL permission", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.InternalReview,
});
const user = makeUser({
permissions: [WorkflowPermissions.REVIEW], // no CANCEL
});
const result = await handleReviewDecision(opp, user, {
decision: "cancel",
note: "No longer needed",
});
expect(result.success).toBe(false);
expect(result.error).toContain(WorkflowPermissions.CANCEL);
});
test("fails when status is not InternalReview", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
const result = await handleReviewDecision(opp, makeUser(), {
decision: "approve",
note: "ok",
});
expect(result.success).toBe(false);
expect(result.error).toContain("InternalReview");
});
test("requires note", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.InternalReview,
});
const result = await handleReviewDecision(opp, makeUser(), {
decision: "approve",
note: "",
});
expect(result.success).toBe(false);
});
test("unknown decision returns failure", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.InternalReview,
});
const result = await handleReviewDecision(opp, makeUser(), {
decision: "unknown" as any,
note: "Something",
});
expect(result.success).toBe(false);
expect(result.error).toContain("Unknown review decision");
});
});
// ═══════════════════════════════════════════════════════════════════════════
// TRANSITION: → QuoteSent (with compound flags)
// ═══════════════════════════════════════════════════════════════════════════
describe("transitionToQuoteSent", () => {
test("plain send from PendingSent → QuoteSent", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
const result = await transitionToQuoteSent(opp, makeUser(), {});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.QuoteSent);
});
test("requires SEND permission", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
const user = makeUser({ permissions: [] });
const result = await transitionToQuoteSent(opp, user, {});
expect(result.success).toBe(false);
expect(result.error).toContain(WorkflowPermissions.SEND);
});
test("won flag → PendingWon (without finalize perm)", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
const user = makeUser({
permissions: [WorkflowPermissions.SEND, WorkflowPermissions.WIN],
});
const result = await transitionToQuoteSent(opp, user, { won: true });
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.PendingWon);
});
test("won + finalize → Won (with finalize perm)", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
const user = makeUser(); // wildcard perms
const result = await transitionToQuoteSent(opp, user, {
won: true,
finalize: true,
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.Won);
expect(result.activitiesCreated.length).toBeGreaterThanOrEqual(2);
});
test("lost flag → PendingLost (without finalize perm)", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
const user = makeUser({
permissions: [WorkflowPermissions.SEND, WorkflowPermissions.LOSE],
});
const result = await transitionToQuoteSent(opp, user, { lost: true });
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.PendingLost);
});
test("lost + finalize → Lost (with finalize perm)", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
const user = makeUser();
const result = await transitionToQuoteSent(opp, user, {
lost: true,
finalize: true,
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.Lost);
});
test("needsRevision → Active", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
const result = await transitionToQuoteSent(opp, makeUser(), {
needsRevision: true,
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.Active);
expect(result.activitiesCreated).toHaveLength(2);
});
test("quoteConfirmed → ConfirmedQuote", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
const result = await transitionToQuoteSent(opp, makeUser(), {
quoteConfirmed: true,
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.ConfirmedQuote);
});
test("won flag without WIN perm fails", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingSent });
const user = makeUser({ permissions: [WorkflowPermissions.SEND] });
const result = await transitionToQuoteSent(opp, user, { won: true });
expect(result.success).toBe(false);
expect(result.error).toContain(WorkflowPermissions.WIN);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// TRANSITION: → ConfirmedQuote
// ═══════════════════════════════════════════════════════════════════════════
describe("transitionToConfirmedQuote", () => {
test("succeeds from QuoteSent", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent });
const result = await transitionToConfirmedQuote(opp, makeUser(), {});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.ConfirmedQuote);
});
test("fails from disallowed status", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
const result = await transitionToConfirmedQuote(opp, makeUser(), {});
expect(result.success).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// FINALIZE
// ═══════════════════════════════════════════════════════════════════════════
describe("finalizeOpportunity", () => {
test("won from PendingWon → Won", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingWon });
const result = await finalizeOpportunity(opp, makeUser(), {
outcome: "won",
note: "Deal closed",
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.Won);
});
test("lost from PendingLost → Lost", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingLost });
const result = await finalizeOpportunity(opp, makeUser(), {
outcome: "lost",
note: "Customer chose competitor",
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.Lost);
});
test("requires FINALIZE permission", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingWon });
const user = makeUser({ permissions: [WorkflowPermissions.WIN] });
const result = await finalizeOpportunity(opp, user, {
outcome: "won",
note: "Close it",
});
expect(result.success).toBe(false);
expect(result.error).toContain(WorkflowPermissions.FINALIZE);
});
test("requires a note", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingWon });
const result = await finalizeOpportunity(opp, makeUser(), {
outcome: "won",
note: "",
});
expect(result.success).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// TRANSITION TO PENDING (Win/Lose without finalize perm)
// ═══════════════════════════════════════════════════════════════════════════
describe("transitionToPending", () => {
test("won from QuoteSent → PendingWon", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent });
const result = await transitionToPending(opp, makeUser(), {
outcome: "won",
note: "Customer accepted",
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.PendingWon);
});
test("lost from ConfirmedQuote → PendingLost", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.ConfirmedQuote,
});
const result = await transitionToPending(opp, makeUser(), {
outcome: "lost",
note: "Declined",
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.PendingLost);
});
test("requires WIN permission for won outcome", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent });
const user = makeUser({ permissions: [] });
const result = await transitionToPending(opp, user, {
outcome: "won",
note: "Accepted",
});
expect(result.success).toBe(false);
expect(result.error).toContain(WorkflowPermissions.WIN);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// RESURRECT
// ═══════════════════════════════════════════════════════════════════════════
describe("resurrectOpportunity", () => {
test("PendingLost → Active", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingLost });
const result = await resurrectOpportunity(opp, makeUser(), {
note: "Reconsider",
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.Active);
});
test("PendingWon → Active requires FINALIZE perm", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingWon });
const user = makeUser({ permissions: [] });
const result = await resurrectOpportunity(opp, user, {
note: "Revise",
});
expect(result.success).toBe(false);
expect(result.error).toContain("permission");
});
test("requires note", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.PendingLost });
const result = await resurrectOpportunity(opp, makeUser(), { note: "" });
expect(result.success).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// BEGIN REVISION
// ═══════════════════════════════════════════════════════════════════════════
describe("beginRevision", () => {
test("PendingRevision → Active", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.PendingRevision,
});
const result = await beginRevision(opp, makeUser(), {});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.Active);
});
test("fails from wrong status", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
const result = await beginRevision(opp, makeUser(), {});
expect(result.success).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// CANCEL
// ═══════════════════════════════════════════════════════════════════════════
describe("cancelOpportunity", () => {
test("New → Canceled", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
const result = await cancelOpportunity(opp, makeUser(), {
note: "No longer needed",
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.Canceled);
});
test("requires CANCEL permission", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
const user = makeUser({ permissions: [] });
const result = await cancelOpportunity(opp, user, {
note: "Cancel",
});
expect(result.success).toBe(false);
expect(result.error).toContain(WorkflowPermissions.CANCEL);
});
test("requires note", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.New });
const result = await cancelOpportunity(opp, makeUser(), { note: "" });
expect(result.success).toBe(false);
});
test("fails from terminal status", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.Won });
const result = await cancelOpportunity(opp, makeUser(), {
note: "Can't cancel Won",
});
expect(result.success).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// REOPEN
// ═══════════════════════════════════════════════════════════════════════════
describe("reopenCancelledOpportunity", () => {
test("Canceled → Active", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.Canceled });
const result = await reopenCancelledOpportunity(opp, makeUser(), {
note: "Reopening for updated scope",
});
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.Active);
});
test("requires REOPEN permission", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.Canceled });
const user = makeUser({ permissions: [] });
const result = await reopenCancelledOpportunity(opp, user, {
note: "Reopen",
});
expect(result.success).toBe(false);
expect(result.error).toContain(WorkflowPermissions.REOPEN);
});
test("requires note", async () => {
const opp = makeOpportunity({ statusCwId: OpportunityStatus.Canceled });
const result = await reopenCancelledOpportunity(opp, makeUser(), {
note: "",
});
expect(result.success).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// COLD DETECTION
// ═══════════════════════════════════════════════════════════════════════════
describe("triggerColdDetection", () => {
test("returns success with no status change when not cold", async () => {
mockCheckColdStatus.mockReturnValue({ cold: false, triggeredBy: null });
const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent });
const result = await triggerColdDetection(opp, new Date("2026-03-01"));
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.QuoteSent);
expect(result.activitiesCreated).toHaveLength(0);
expect(mockSyncOpportunityStatus).not.toHaveBeenCalled();
});
test("transitions to InternalReview when cold", async () => {
mockCheckColdStatus.mockReturnValue({
cold: true,
triggeredBy: {
statusCwId: 43,
statusName: "QuoteSent",
thresholdDays: 14,
staleDays: 20,
},
});
const opp = makeOpportunity({ statusCwId: OpportunityStatus.QuoteSent });
const result = await triggerColdDetection(opp, new Date("2026-01-01"));
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.InternalReview);
expect(result.coldCheck?.cold).toBe(true);
expect(result.activitiesCreated).toHaveLength(1);
expect(mockSyncOpportunityStatus).toHaveBeenCalledTimes(1);
});
test("fails when statusCwId is null", async () => {
const opp = makeOpportunity({ statusCwId: null });
const result = await triggerColdDetection(opp, null);
expect(result.success).toBe(false);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// MASTER DISPATCHER
// ═══════════════════════════════════════════════════════════════════════════
describe("processOpportunityAction", () => {
test("rejects non-Optima stage", async () => {
const opp = makeOpportunity({ stageName: "Pipeline" });
const result = await processOpportunityAction(
opp,
{ action: "acceptNew", payload: {} },
makeUser(),
);
expect(result.success).toBe(false);
expect(result.error).toBeTruthy();
});
test("rejects terminal status for non-reopen actions", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.Won,
stageName: "Optima",
});
const result = await processOpportunityAction(
opp,
{ action: "acceptNew", payload: {} },
makeUser(),
);
expect(result.success).toBe(false);
});
test("routes acceptNew correctly", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.PendingNew,
stageName: "Optima",
});
const result = await processOpportunityAction(
opp,
{ action: "acceptNew", payload: {} },
makeUser(),
);
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.New);
});
test("calls refreshFromCW on success", async () => {
const refreshFn = mock(() => Promise.resolve());
const opp = makeOpportunity({
statusCwId: OpportunityStatus.PendingNew,
stageName: "Optima",
refreshFromCW: refreshFn,
});
await processOpportunityAction(
opp,
{ action: "acceptNew", payload: {} },
makeUser(),
);
expect(refreshFn).toHaveBeenCalledTimes(1);
});
test("does not call refreshFromCW on failure", async () => {
const refreshFn = mock(() => Promise.resolve());
const opp = makeOpportunity({
statusCwId: OpportunityStatus.Active,
stageName: "Optima",
refreshFromCW: refreshFn,
});
await processOpportunityAction(
opp,
{ action: "acceptNew", payload: {} },
makeUser(),
);
expect(refreshFn).not.toHaveBeenCalled();
});
test("routes finalize to finalizeOpportunity with FINALIZE perm", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.PendingWon,
stageName: "Optima",
});
const result = await processOpportunityAction(
opp,
{ action: "finalize", payload: { outcome: "won", note: "Done" } },
makeUser(),
);
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.Won);
});
test("routes finalize to transitionToPending without FINALIZE perm", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.QuoteSent,
stageName: "Optima",
});
const user = makeUser({
permissions: [WorkflowPermissions.WIN],
});
const result = await processOpportunityAction(
opp,
{ action: "finalize", payload: { outcome: "won", note: "Accepted" } },
user,
);
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.PendingWon);
});
test("reopen allowed from Canceled (skips terminal check)", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.Canceled,
stageName: "Optima",
});
const result = await processOpportunityAction(
opp,
{ action: "reopen", payload: { note: "Reopening" } },
makeUser(),
);
expect(result.success).toBe(true);
expect(result.newStatusId).toBe(OpportunityStatus.Active);
});
test("closes open workflow activities before transitioning", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.PendingNew,
stageName: "Optima",
});
await processOpportunityAction(
opp,
{ action: "acceptNew", payload: {} },
makeUser(),
);
// closeOpenWorkflowActivities calls fetchByOpportunityDirect
expect(mockFetchByOpportunityDirect).toHaveBeenCalledTimes(1);
});
test("refreshFromCW failure does not fail the workflow", async () => {
const opp = makeOpportunity({
statusCwId: OpportunityStatus.PendingNew,
stageName: "Optima",
refreshFromCW: mock(() => Promise.reject(new Error("CW down"))),
});
const result = await processOpportunityAction(
opp,
{ action: "acceptNew", payload: {} },
makeUser(),
);
// Transition itself should still succeed
expect(result.success).toBe(true);
});
});