diff --git a/API_ROUTES.md b/API_ROUTES.md
index 197c6f2..f978208 100644
--- a/API_ROUTES.md
+++ b/API_ROUTES.md
@@ -901,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`
@@ -3619,6 +3665,41 @@ Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company,
---
+### 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`
@@ -3889,6 +3970,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`
@@ -4769,6 +4887,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
+ }
+}
+```
+
+
+All action types and their payloads
+
+| 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"`
+
+
+
+**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.
diff --git a/PERMISSIONS.md b/PERMISSIONS.md
index 041da18..39b43b8 100644
--- a/PERMISSIONS.md
+++ b/PERMISSIONS.md
@@ -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
@@ -141,27 +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::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.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.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.` 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` |
+| 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.` 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` |
Field-level permissions for sales.opportunity.product.add
diff --git a/src/api/companies/[id]/sites.ts b/src/api/companies/[id]/sites.ts
new file mode 100644
index 0000000..6a8e085
--- /dev/null
+++ b/src/api/companies/[id]/sites.ts
@@ -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"],
+ }),
+);
diff --git a/src/api/companies/index.ts b/src/api/companies/index.ts
index e2e1461..843a383 100644
--- a/src/api/companies/index.ts
+++ b/src/api/companies/index.ts
@@ -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 };
diff --git a/src/api/sales/index.ts b/src/api/sales/index.ts
index 3e58663..5d781b5 100644
--- a/src/api/sales/index.ts
+++ b/src/api/sales/index.ts
@@ -5,6 +5,7 @@ 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";
@@ -13,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";
@@ -24,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,
@@ -32,6 +37,7 @@ export {
addSpecialOrderProduct,
count,
createOpportunity,
+ deleteOpportunity,
fetch,
fetchAll,
fetchOpportunityTypes,
@@ -39,6 +45,7 @@ export {
resequenceProducts,
updateProduct,
cancelProduct,
+ deleteProduct,
notes,
fetchNote,
createNote,
@@ -52,4 +59,7 @@ export {
fetchDownloads,
refresh,
updateOpportunity,
+ workflowDispatch,
+ workflowStatus,
+ workflowHistory,
};
diff --git a/src/api/sales/opportunities/[id]/delete.ts b/src/api/sales/opportunities/[id]/delete.ts
new file mode 100644
index 0000000..4223b00
--- /dev/null
+++ b/src/api/sales/opportunities/[id]/delete.ts
@@ -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"] }),
+);
diff --git a/src/api/sales/opportunities/[id]/products/add.ts b/src/api/sales/opportunities/[id]/products/add.ts
index 6915ec2..6749609 100644
--- a/src/api/sales/opportunities/[id]/products/add.ts
+++ b/src/api/sales/opportunities/[id]/products/add.ts
@@ -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(
diff --git a/src/api/sales/opportunities/[id]/products/delete.ts b/src/api/sales/opportunities/[id]/products/delete.ts
new file mode 100644
index 0000000..b82c108
--- /dev/null
+++ b/src/api/sales/opportunities/[id]/products/delete.ts
@@ -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"] }),
+);
diff --git a/src/api/sales/opportunities/[id]/products/update.ts b/src/api/sales/opportunities/[id]/products/update.ts
index 214947f..1cd5e69 100644
--- a/src/api/sales/opportunities/[id]/products/update.ts
+++ b/src/api/sales/opportunities/[id]/products/update.ts
@@ -98,12 +98,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),
diff --git a/src/api/sales/opportunities/[id]/quotes/commit.ts b/src/api/sales/opportunities/[id]/quotes/commit.ts
index da903b5..14bc607 100644
--- a/src/api/sales/opportunities/[id]/quotes/commit.ts
+++ b/src/api/sales/opportunities/[id]/quotes/commit.ts
@@ -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 }),
diff --git a/src/api/sales/opportunities/[id]/workflow/dispatch.ts b/src/api/sales/opportunities/[id]/workflow/dispatch.ts
new file mode 100644
index 0000000..0747cc5
--- /dev/null
+++ b/src/api/sales/opportunities/[id]/workflow/dispatch.ts
@@ -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"] }),
+);
diff --git a/src/api/sales/opportunities/[id]/workflow/history.ts b/src/api/sales/opportunities/[id]/workflow/history.ts
new file mode 100644
index 0000000..fee4ca7
--- /dev/null
+++ b/src/api/sales/opportunities/[id]/workflow/history.ts
@@ -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([
+ 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;
+ 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"] }),
+);
diff --git a/src/api/sales/opportunities/[id]/workflow/status.ts b/src/api/sales/opportunities/[id]/workflow/status.ts
new file mode 100644
index 0000000..827803e
--- /dev/null
+++ b/src/api/sales/opportunities/[id]/workflow/status.ts
@@ -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;
+}
+
+const ACTION_MAP: Record = {
+ [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"] }),
+);
diff --git a/src/api/sales/opportunities/create.ts b/src/api/sales/opportunities/create.ts
index c58af19..583a3a3 100644
--- a/src/api/sales/opportunities/create.ts
+++ b/src/api/sales/opportunities/create.ts
@@ -5,6 +5,11 @@ 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),
@@ -41,6 +46,34 @@ export default createRoute(
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(),
diff --git a/src/controllers/OpportunityController.ts b/src/controllers/OpportunityController.ts
index 921de2b..1616048 100644
--- a/src/controllers/OpportunityController.ts
+++ b/src/controllers/OpportunityController.ts
@@ -1351,6 +1351,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 {
+ 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
*
diff --git a/src/managers/opportunities.ts b/src/managers/opportunities.ts
index ea8dae7..7d38ca3 100644
--- a/src/managers/opportunities.ts
+++ b/src/managers/opportunities.ts
@@ -15,6 +15,7 @@ import {
fetchAndCacheActivities,
fetchAndCacheCompanyCwData,
fetchAndCacheOppCwData,
+ invalidateAllOpportunityCaches,
} from "../modules/cache/opportunityCache";
// ---------------------------------------------------------------------------
@@ -524,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}
+ */
+ async deleteItem(identifier: string | number): Promise {
+ 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);
+ },
};
diff --git a/src/managers/users.ts b/src/managers/users.ts
index f8de759..5ddb91e 100644
--- a/src/managers/users.ts
+++ b/src/managers/users.ts
@@ -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,
diff --git a/src/modules/algorithms/algo.coldThreshold.ts b/src/modules/algorithms/algo.coldThreshold.ts
new file mode 100644
index 0000000..a26fe25
--- /dev/null
+++ b/src/modules/algorithms/algo.coldThreshold.ts
@@ -0,0 +1,123 @@
+/**
+ * @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 = {
+ /** 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 = {
+ 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 {
+ const NOT_COLD: ColdCheckResult = { cold: false, triggeredBy: null };
+
+ if (!input.statusCwId) return NOT_COLD;
+
+ const threshold = COLD_THRESHOLDS[input.statusCwId];
+ if (!threshold) return NOT_COLD;
+
+ if (!input.lastActivityDate) return NOT_COLD;
+
+ const now = input.now ?? new Date();
+ const elapsed = now.getTime() - input.lastActivityDate.getTime();
+
+ if (elapsed < threshold.ms) return NOT_COLD;
+
+ return {
+ cold: true,
+ triggeredBy: {
+ statusCwId: input.statusCwId,
+ statusName: STATUS_NAMES[input.statusCwId] ?? "Unknown",
+ thresholdDays: threshold.days,
+ staleDays: Math.floor(elapsed / (24 * 60 * 60 * 1000)),
+ },
+ };
+}
diff --git a/src/modules/algorithms/algo.followUpScheduler.ts b/src/modules/algorithms/algo.followUpScheduler.ts
new file mode 100644
index 0000000..1ce0fdd
--- /dev/null
+++ b/src/modules/algorithms/algo.followUpScheduler.ts
@@ -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 (Mon–Fri) 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;
+}
diff --git a/src/modules/cache/opportunityCache.ts b/src/modules/cache/opportunityCache.ts
index 1d76a65..c690b83 100644
--- a/src/modules/cache/opportunityCache.ts
+++ b/src/modules/cache/opportunityCache.ts
@@ -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 {
+ 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;
diff --git a/src/modules/cw-utils/opportunities/opportunities.ts b/src/modules/cw-utils/opportunities/opportunities.ts
index c6e8b2c..4ffd259 100644
--- a/src/modules/cw-utils/opportunities/opportunities.ts
+++ b/src/modules/cw-utils/opportunities/opportunities.ts
@@ -295,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 => {
+ 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
*
@@ -463,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 => {
+ await connectWiseApi.delete(`/sales/opportunities/${opportunityId}`);
+ },
};
diff --git a/src/modules/cw-utils/opportunities/opportunity.types.ts b/src/modules/cw-utils/opportunities/opportunity.types.ts
index d6b2091..69414b8 100644
--- a/src/modules/cw-utils/opportunities/opportunity.types.ts
+++ b/src/modules/cw-utils/opportunities/opportunity.types.ts
@@ -213,7 +213,6 @@ export interface CWForecastItemCreate {
catalogItem?: { id: number };
forecastDescription?: string;
productDescription?: string;
- customerDescription?: string;
quantity?: number;
status?: { id: number };
productClass?: string;
diff --git a/src/services/cw.opportunityService.ts b/src/services/cw.opportunityService.ts
new file mode 100644
index 0000000..27b2c2d
--- /dev/null
+++ b/src/services/cw.opportunityService.ts
@@ -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 {
+ 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 {
+ 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"}`,
+ };
+ }
+}
diff --git a/src/types/PermissionNodes.ts b/src/types/PermissionNodes.ts
index 59623a9..adb6b3c 100644
--- a/src/types/PermissionNodes.ts
+++ b/src/types/PermissionNodes.ts
@@ -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"],
+ },
],
},
@@ -435,6 +441,13 @@ export const PERMISSION_NODES = {
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",
@@ -464,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:
@@ -557,6 +576,75 @@ export const PERMISSION_NODES = {
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"],
+ },
],
},
diff --git a/src/types/QuoteStatuses.ts b/src/types/QuoteStatuses.ts
index f436dce..8a7e81b 100644
--- a/src/types/QuoteStatuses.ts
+++ b/src/types/QuoteStatuses.ts
@@ -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: [],
+ },
];
diff --git a/src/workflows/wf.opportunity.ts b/src/workflows/wf.opportunity.ts
new file mode 100644
index 0000000..03c89e8
--- /dev/null
+++ b/src/workflows/wf.opportunity.ts
@@ -0,0 +1,1814 @@
+/**
+ * @module wf.opportunity
+ *
+ * Opportunity Workflow
+ * ====================
+ *
+ * Central workflow engine for the opportunity lifecycle. All state
+ * transitions, follow-up scheduling, cold detection, and CW activity
+ * creation are driven through this file.
+ *
+ * ## Ground rules
+ *
+ * 1. Every state transition creates a CW activity as the audit trail.
+ * 2. Activities carry an `Optima_Type` custom field to tag their type.
+ * 3. The opportunity's CW status is the source of truth for current state.
+ * 4. The activity history IS the metadata — timestamps, notes, and
+ * state changes are all derived from activities.
+ * 5. When `timeSpent` is provided on an activity close, a CW time entry
+ * is automatically submitted.
+ * 6. The opportunity's stage MUST be "Optima" before any workflow
+ * action is allowed.
+ *
+ * ## Statuses (CW IDs)
+ *
+ * | Enum Key | CW ID | CW Name |
+ * |-------------------|-------|------------------------|
+ * | PendingNew | 37 | 00. Pending New |
+ * | New | 24 | 01. New |
+ * | InternalReview | 56 | 02. Internal Review |
+ * | QuoteSent | 43 | 03. Quote Sent |
+ * | ConfirmedQuote | 57 | 04. Confirmed Quote |
+ * | Active | 58 | 05. Active |
+ * | PendingSent | 60 | Pending Sent |
+ * | PendingRevision | 61 | Pending Revision |
+ * | PendingWon | 49 | 91. Pending Won |
+ * | Won | 29 | 95. Won |
+ * | PendingLost | 50 | 98. Pending Lost |
+ * | Lost | 53 | 99. Lost |
+ * | Canceled | 59 | Canceled |
+ */
+
+import { OpportunityController } from "../controllers/OpportunityController";
+import { ActivityController } from "../controllers/ActivityController";
+import { activityCw } from "../modules/cw-utils/activities/activities";
+import {
+ checkColdStatus,
+ type ColdCheckResult,
+} from "../modules/algorithms/algo.coldThreshold";
+import {
+ submitTimeEntry,
+ syncOpportunityStatus,
+} from "../services/cw.opportunityService";
+
+// ═══════════════════════════════════════════════════════════════════════════
+// CONSTANTS & ENUMS
+// ═══════════════════════════════════════════════════════════════════════════
+
+/**
+ * Canonical status enum mapping workflow names → CW status IDs.
+ */
+export const OpportunityStatus = {
+ PendingNew: 37,
+ New: 24,
+ InternalReview: 56,
+ QuoteSent: 43,
+ ConfirmedQuote: 57,
+ Active: 58,
+ PendingSent: 60,
+ PendingRevision: 61,
+ PendingWon: 49,
+ Won: 29,
+ PendingLost: 50,
+ Lost: 53,
+ Canceled: 59,
+} as const;
+
+export type OpportunityStatusKey = keyof typeof OpportunityStatus;
+export type OpportunityStatusId =
+ (typeof OpportunityStatus)[OpportunityStatusKey];
+
+/** Reverse lookup: CW ID → workflow key. */
+export const StatusIdToKey: Record =
+ Object.fromEntries(
+ Object.entries(OpportunityStatus).map(([k, v]) => [
+ v,
+ k as OpportunityStatusKey,
+ ]),
+ ) as Record;
+
+/** Terminal (immutable) statuses. */
+const TERMINAL_STATUSES = new Set([
+ OpportunityStatus.Won,
+ OpportunityStatus.Lost,
+]);
+
+/**
+ * CW Activity custom field for Optima_Type.
+ *
+ * Field ID 45, with list options:
+ * 58 = Quote Setup
+ * 58 = Opportunity Setup
+ * 59 = Quote Sent
+ * 60 = Quote Confirmed
+ * 61 = Revision
+ * 62 = Finalized
+ * 63 = Converted
+ * 64 = Opportunity Created
+ * 65 = Opportunity Review
+ * 66 = Quote Generated
+ * 67 = Quote Sent & Confirmed
+ */
+export const OptimaType = {
+ FIELD_ID: 45,
+ OpportunityCreated: "Opportunity Created",
+ OpportunitySetup: "Opportunity Setup",
+ OpportunityReview: "Opportunity Review",
+ QuoteSent: "Quote Sent",
+ QuoteConfirmed: "Quote Confirmed",
+ QuoteSentConfirmed: "Quote Sent & Confirmed",
+ QuoteGenerated: "Quote Generated",
+ Revision: "Revision",
+ Finalized: "Finalized",
+ Converted: "Converted",
+} as const;
+
+/** CW custom field ID for the QuoteID field on activities. */
+const QUOTE_ID_FIELD_ID = 48;
+
+/** CW custom field ID for the Close Date field on activities. */
+const CLOSE_DATE_FIELD_ID = 49;
+
+/**
+ * Optima_Type values whose activities should remain Open until the
+ * next workflow transition closes them automatically.
+ */
+const STAYS_OPEN_TYPES = new Set([
+ OptimaType.OpportunitySetup,
+ OptimaType.OpportunityReview,
+ OptimaType.Revision,
+]);
+
+export type OptimaTypeValue =
+ | typeof OptimaType.OpportunityCreated
+ | typeof OptimaType.OpportunitySetup
+ | typeof OptimaType.OpportunityReview
+ | typeof OptimaType.QuoteSent
+ | typeof OptimaType.QuoteConfirmed
+ | typeof OptimaType.QuoteSentConfirmed
+ | typeof OptimaType.QuoteGenerated
+ | typeof OptimaType.Revision
+ | typeof OptimaType.Finalized
+ | typeof OptimaType.Converted;
+
+/** Permission nodes required by gated transitions. */
+export const WorkflowPermissions = {
+ FINALIZE: "sales.opportunity.finalize",
+ CANCEL: "sales.opportunity.cancel",
+ REVIEW: "sales.opportunity.review",
+ SEND: "sales.opportunity.send",
+ REOPEN: "sales.opportunity.reopen",
+ WIN: "sales.opportunity.win",
+ LOSE: "sales.opportunity.lose",
+} as const;
+
+/** Required Optima stage name. */
+const REQUIRED_STAGE = "Optima";
+
+// ═══════════════════════════════════════════════════════════════════════════
+// TRANSITION MAP
+// ═══════════════════════════════════════════════════════════════════════════
+
+/**
+ * Defines which target statuses are reachable from each source status.
+ *
+ * This map covers DIRECT transitions only. Some transitions are
+ * compound (e.g. QuoteSent with `won: true` goes through Won/PendingWon),
+ * which are handled inside the individual transition functions.
+ */
+const ALLOWED_TRANSITIONS: Record> = {
+ [OpportunityStatus.PendingNew]: new Set([OpportunityStatus.New]),
+
+ [OpportunityStatus.New]: new Set([
+ OpportunityStatus.InternalReview,
+ OpportunityStatus.QuoteSent,
+ OpportunityStatus.Canceled,
+ ]),
+
+ [OpportunityStatus.InternalReview]: new Set([
+ OpportunityStatus.PendingSent,
+ OpportunityStatus.PendingRevision,
+ OpportunityStatus.QuoteSent, // reviewer manually sends
+ OpportunityStatus.Canceled,
+ ]),
+
+ [OpportunityStatus.PendingSent]: new Set([OpportunityStatus.QuoteSent]),
+
+ [OpportunityStatus.PendingRevision]: new Set([OpportunityStatus.Active]),
+
+ [OpportunityStatus.QuoteSent]: new Set([
+ OpportunityStatus.ConfirmedQuote,
+ OpportunityStatus.Won,
+ OpportunityStatus.PendingWon,
+ OpportunityStatus.PendingLost,
+ OpportunityStatus.Active,
+ OpportunityStatus.InternalReview, // cold automation only
+ ]),
+
+ [OpportunityStatus.ConfirmedQuote]: new Set([
+ OpportunityStatus.Won,
+ OpportunityStatus.PendingWon,
+ OpportunityStatus.PendingLost,
+ OpportunityStatus.Active,
+ OpportunityStatus.InternalReview, // cold automation only
+ ]),
+
+ [OpportunityStatus.Active]: new Set([
+ OpportunityStatus.QuoteSent,
+ OpportunityStatus.InternalReview,
+ OpportunityStatus.Canceled,
+ ]),
+
+ [OpportunityStatus.PendingWon]: new Set([
+ OpportunityStatus.Won,
+ OpportunityStatus.Active, // admin revision
+ ]),
+
+ [OpportunityStatus.PendingLost]: new Set([
+ OpportunityStatus.Lost,
+ OpportunityStatus.Active, // resurrection
+ ]),
+
+ [OpportunityStatus.Won]: new Set(), // terminal
+ [OpportunityStatus.Lost]: new Set(), // terminal
+
+ [OpportunityStatus.Canceled]: new Set([
+ OpportunityStatus.Active, // re-open
+ ]),
+};
+
+// ═══════════════════════════════════════════════════════════════════════════
+// TYPES
+// ═══════════════════════════════════════════════════════════════════════════
+
+/** User context passed into every workflow action. */
+export interface WorkflowUser {
+ /** Internal user ID. */
+ id: string;
+ /** CW member ID for activity assignment. */
+ cwMemberId: number;
+ /** Resolved permission node strings the user holds. */
+ permissions: string[];
+}
+
+/** Base payload fields common to all actions. */
+interface BaseActionPayload {
+ /** Required note for state-change activities. */
+ note?: string;
+ /** ISO-8601 datetime when work started. Both timeStarted and timeEnded must be provided to submit a time entry. */
+ timeStarted?: string;
+ /** ISO-8601 datetime when work ended. Both timeStarted and timeEnded must be provided to submit a time entry. */
+ timeEnded?: string;
+}
+
+/** Transition to New. */
+export interface AcceptNewPayload extends BaseActionPayload {}
+
+/** Transition to InternalReview. */
+export interface RequestReviewPayload extends BaseActionPayload {
+ note: string; // required
+}
+
+/** Review decision payload (approve / reject / send / cancel). */
+export interface ReviewDecisionPayload extends BaseActionPayload {
+ note: string; // required
+ decision: "approve" | "reject" | "send" | "cancel";
+}
+
+/** Transition from PendingSent → QuoteSent. */
+export interface SendQuotePayload extends BaseActionPayload {
+ /**
+ * If true, marks sent AND confirmed simultaneously.
+ * Skips receipt confirmation follow-up. Transitions to ConfirmedQuote.
+ */
+ quoteConfirmed?: boolean;
+
+ /**
+ * Quote was presented and won immediately.
+ * Without finalize: transitions to PendingWon.
+ * With finalize (requires FINALIZE perm): transitions directly to Won.
+ * Satisfies QuoteSent and ConfirmedQuote simultaneously.
+ */
+ won?: boolean;
+
+ /**
+ * Quote rejected immediately upon presentation.
+ * Without finalize: transitions to PendingLost.
+ * With finalize (requires FINALIZE perm): transitions directly to Lost.
+ */
+ lost?: boolean;
+
+ /**
+ * Explicitly finalize the outcome (skip pending state).
+ * Requires `sales.opportunity.finalize` permission.
+ * Used with `won` or `lost` to go directly to Won/Lost
+ * instead of PendingWon/PendingLost.
+ */
+ finalize?: boolean;
+
+ /**
+ * Quote needs revision.
+ * Creates a revision activity and transitions to Active.
+ */
+ needsRevision?: boolean;
+}
+
+/** Confirm receipt of a quote. */
+export interface ConfirmQuotePayload extends BaseActionPayload {}
+
+/** Mark opportunity as won or lost. */
+export interface FinalizePayload extends BaseActionPayload {
+ note: string; // required
+ outcome: "won" | "lost";
+}
+
+/** Resurrect from PendingWon / PendingLost → Active. */
+export interface ResurrectPayload extends BaseActionPayload {
+ note: string; // required
+}
+
+/** Begin revision from PendingRevision → Active. */
+export interface BeginRevisionPayload extends BaseActionPayload {}
+
+/** Re-send from Active → QuoteSent. */
+export interface ResendQuotePayload extends SendQuotePayload {}
+
+/** Cancel payload. */
+export interface CancelPayload extends BaseActionPayload {
+ note: string; // required
+}
+
+/** Re-open from Canceled → Active. */
+export interface ReopenPayload extends BaseActionPayload {
+ note: string; // required
+}
+
+// -----------------------------------------------------------------------
+// Action discriminator
+// -----------------------------------------------------------------------
+
+export type WorkflowAction =
+ | { action: "acceptNew"; payload: AcceptNewPayload }
+ | { action: "requestReview"; payload: RequestReviewPayload }
+ | { action: "reviewDecision"; payload: ReviewDecisionPayload }
+ | { action: "sendQuote"; payload: SendQuotePayload }
+ | { action: "confirmQuote"; payload: ConfirmQuotePayload }
+ | { action: "finalize"; payload: FinalizePayload }
+ | { action: "resurrect"; payload: ResurrectPayload }
+ | { action: "beginRevision"; payload: BeginRevisionPayload }
+ | { action: "resendQuote"; payload: ResendQuotePayload }
+ | { action: "cancel"; payload: CancelPayload }
+ | { action: "reopen"; payload: ReopenPayload };
+
+// -----------------------------------------------------------------------
+// Result
+// -----------------------------------------------------------------------
+
+export interface WorkflowResult {
+ success: boolean;
+ /** Previous CW status ID, null when the status did not change. */
+ previousStatusId: number | null;
+ /** New CW status ID after the transition, null on failure. */
+ newStatusId: number | null;
+ /** Human-readable status keys. */
+ previousStatus: OpportunityStatusKey | null;
+ newStatus: OpportunityStatusKey | null;
+ /** Activities created during the transition. */
+ activitiesCreated: ActivityController[];
+ /** Cold-check result, when applicable. */
+ coldCheck: ColdCheckResult | null;
+ /** Error message on failure. */
+ error: string | null;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// HELPERS
+// ═══════════════════════════════════════════════════════════════════════════
+
+function hasPermission(user: WorkflowUser, node: string): boolean {
+ return user.permissions.includes("*") || user.permissions.includes(node);
+}
+
+function fail(
+ message: string,
+ currentStatusId?: number | null,
+): WorkflowResult {
+ return {
+ success: false,
+ previousStatusId: currentStatusId ?? null,
+ newStatusId: null,
+ previousStatus:
+ currentStatusId != null ? (StatusIdToKey[currentStatusId] ?? null) : null,
+ newStatus: null,
+ activitiesCreated: [],
+ coldCheck: null,
+ error: message,
+ };
+}
+
+function ok(
+ previousStatusId: number,
+ newStatusId: number,
+ activities: ActivityController[],
+ coldCheck: ColdCheckResult | null = null,
+): WorkflowResult {
+ return {
+ success: true,
+ previousStatusId,
+ newStatusId,
+ previousStatus: StatusIdToKey[previousStatusId] ?? null,
+ newStatus: StatusIdToKey[newStatusId] ?? null,
+ activitiesCreated: activities,
+ coldCheck,
+ error: null,
+ };
+}
+
+/**
+ * Build the `customFields` array for a CW activity with Optima_Type set,
+ * and optionally a QuoteID.
+ */
+function buildCustomFields(
+ optimaType: OptimaTypeValue,
+ opts?: { quoteId?: string; closeDate?: string },
+) {
+ const fields: any[] = [
+ {
+ id: OptimaType.FIELD_ID,
+ caption: "Optima_Type",
+ type: "Text",
+ entryMethod: "List",
+ numberOfDecimals: 0,
+ value: optimaType,
+ },
+ ];
+
+ if (opts?.quoteId) {
+ fields.push({
+ id: QUOTE_ID_FIELD_ID,
+ caption: "QuoteID",
+ type: "Text",
+ entryMethod: "EntryField",
+ numberOfDecimals: 0,
+ value: opts.quoteId,
+ });
+ }
+
+ if (opts?.closeDate) {
+ fields.push({
+ id: CLOSE_DATE_FIELD_ID,
+ caption: "Close Date",
+ type: "Text",
+ entryMethod: "Date",
+ numberOfDecimals: 0,
+ value: opts.closeDate,
+ });
+ }
+
+ return fields;
+}
+
+/**
+ * Create a CW activity for a workflow transition.
+ */
+export async function createWorkflowActivity(opts: {
+ name: string;
+ opportunityCwId: number;
+ companyCwId: number | null;
+ assignToCwMemberId: number;
+ notes: string;
+ optimaType: OptimaTypeValue;
+ quoteId?: string;
+ dateStart?: string;
+ dateEnd?: string;
+}): Promise {
+ const shouldStayOpen = STAYS_OPEN_TYPES.has(opts.optimaType);
+
+ const activity = await ActivityController.create({
+ name: opts.name,
+ type: { id: 3 }, // HistoricEntry
+ opportunity: { id: opts.opportunityCwId },
+ ...(opts.companyCwId ? { company: { id: opts.companyCwId } } : {}),
+ assignTo: { id: opts.assignToCwMemberId },
+ ...(shouldStayOpen ? {} : { status: { id: 2 } }), // Closed unless stays-open
+ notes: opts.notes,
+ });
+
+ // Set custom fields (Optima_Type + optional QuoteID + closeDate for closed activities).
+ // For non-stays-open types, also re-assert Closed status.
+ const now = shouldStayOpen ? undefined : new Date().toISOString();
+ const patchOps: any[] = [
+ {
+ op: "replace",
+ path: "customFields",
+ value: buildCustomFields(opts.optimaType, {
+ quoteId: opts.quoteId,
+ closeDate: now,
+ }),
+ },
+ ];
+
+ if (!shouldStayOpen) {
+ patchOps.push({
+ op: "replace",
+ path: "status",
+ value: { id: 2 }, // Closed
+ });
+ }
+
+ const patched = await activity.update(patchOps);
+ return patched;
+}
+
+/**
+ * Handle optional time entry: submit to CW if timeStart and timeEnd are provided.
+ */
+async function handleTimeEntry(
+ activityCwId: number | undefined,
+ cwMemberId: number,
+ payload: BaseActionPayload,
+ notes: string,
+): Promise {
+ console.log(
+ `[Workflow:TimeEntry] Called — activityCwId=${activityCwId}, cwMemberId=${cwMemberId}, timeStarted=${payload.timeStarted ?? "MISSING"}, timeEnded=${payload.timeEnded ?? "MISSING"}`,
+ );
+
+ if (activityCwId == null) {
+ console.warn(
+ `[Workflow:TimeEntry] SKIPPED — activityCwId is ${activityCwId} (activity was not created or has no CW ID)`,
+ );
+ return;
+ }
+ if (!payload.timeStarted || !payload.timeEnded) {
+ console.warn(
+ `[Workflow:TimeEntry] SKIPPED — timeStarted or timeEnded not provided (timeStarted=${payload.timeStarted ?? "undefined"}, timeEnded=${payload.timeEnded ?? "undefined"})`,
+ );
+ return;
+ }
+
+ console.log(
+ `[Workflow:TimeEntry] Submitting — activity=${activityCwId}, member=${cwMemberId}, start=${payload.timeStarted}, end=${payload.timeEnded}, notes="${notes}"`,
+ );
+ try {
+ const result = await submitTimeEntry({
+ activityId: activityCwId,
+ cwMemberId,
+ timeStart: payload.timeStarted,
+ timeEnd: payload.timeEnded,
+ notes,
+ });
+ if (result.success) {
+ console.log(
+ `[Workflow:TimeEntry] SUCCESS — cwTimeEntryId=${result.cwTimeEntryId}, message="${result.message}"`,
+ );
+ } else {
+ console.error(`[Workflow:TimeEntry] FAILED — ${result.message}`);
+ }
+ } catch (err: any) {
+ console.error(
+ `[Workflow:TimeEntry] EXCEPTION —`,
+ err?.response?.data ?? err?.message ?? err,
+ );
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// GUARDS
+// ═══════════════════════════════════════════════════════════════════════════
+
+/**
+ * Top-level guard: opportunity stage must be "Optima".
+ */
+function assertOptimaStage(opportunity: OpportunityController): string | null {
+ if (opportunity.stageName !== REQUIRED_STAGE) {
+ return `Workflow actions require the opportunity stage to be "${REQUIRED_STAGE}". Current stage: "${opportunity.stageName ?? "(none)"}"`;
+ }
+ return null;
+}
+
+/**
+ * Guard: opportunity must not be in a terminal status.
+ */
+function assertNotTerminal(statusCwId: number | null): string | null {
+ if (statusCwId != null && TERMINAL_STATUSES.has(statusCwId)) {
+ const key = StatusIdToKey[statusCwId] ?? "Unknown";
+ return `Opportunity is in terminal status "${key}" and cannot be modified.`;
+ }
+ return null;
+}
+
+/**
+ * Guard: transition must be allowed by the transition map.
+ */
+function assertTransitionAllowed(
+ fromStatusId: number,
+ toStatusId: number,
+): string | null {
+ const allowed = ALLOWED_TRANSITIONS[fromStatusId];
+ if (!allowed || !allowed.has(toStatusId)) {
+ const from = StatusIdToKey[fromStatusId] ?? String(fromStatusId);
+ const to = StatusIdToKey[toStatusId] ?? String(toStatusId);
+ return `Transition from "${from}" to "${to}" is not allowed.`;
+ }
+ return null;
+}
+
+/**
+ * Guard: note must be non-empty.
+ */
+function assertNotePresent(note: string | undefined): string | null {
+ if (!note || note.trim().length === 0) {
+ return "A non-empty note is required for this action.";
+ }
+ return null;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// TRANSITION FUNCTIONS
+// ═══════════════════════════════════════════════════════════════════════════
+
+/**
+ * PendingNew → New
+ *
+ * Accept/setup an opportunity.
+ */
+export async function transitionToNew(
+ opportunity: OpportunityController,
+ user: WorkflowUser,
+ payload: AcceptNewPayload,
+): Promise {
+ const currentStatus = opportunity.statusCwId ?? OpportunityStatus.PendingNew;
+ const targetStatus = OpportunityStatus.New;
+
+ const err = assertTransitionAllowed(currentStatus, targetStatus);
+ if (err) return fail(err, currentStatus);
+
+ const activities: ActivityController[] = [];
+
+ // Create setup activity
+ const activity = await createWorkflowActivity({
+ name: `[Workflow] Opportunity accepted — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note ?? "Opportunity accepted and moved to New.",
+ optimaType: OptimaType.OpportunitySetup,
+ });
+ activities.push(activity);
+
+ // Sync status to CW
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note ?? "Opportunity accepted.",
+ );
+
+ return ok(currentStatus, targetStatus, activities);
+}
+
+/**
+ * Any pre-confirmed status → InternalReview
+ *
+ * Manually request internal review. Requires a note.
+ */
+export async function transitionToInternalReview(
+ opportunity: OpportunityController,
+ user: WorkflowUser,
+ payload: RequestReviewPayload,
+): Promise {
+ const currentStatus = opportunity.statusCwId;
+ if (currentStatus == null) return fail("Opportunity has no current status.");
+
+ const targetStatus = OpportunityStatus.InternalReview;
+
+ const noteErr = assertNotePresent(payload.note);
+ if (noteErr) return fail(noteErr, currentStatus);
+
+ if (!hasPermission(user, WorkflowPermissions.REVIEW)) {
+ return fail(
+ `User lacks the "${WorkflowPermissions.REVIEW}" permission required to submit an opportunity for internal review.`,
+ currentStatus,
+ );
+ }
+
+ const transErr = assertTransitionAllowed(currentStatus, targetStatus);
+ if (transErr) return fail(transErr, currentStatus);
+
+ const activities: ActivityController[] = [];
+
+ const activity = await createWorkflowActivity({
+ name: `[Workflow] Sent to Internal Review — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note,
+ optimaType: OptimaType.OpportunityReview,
+ });
+ activities.push(activity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note,
+ );
+
+ return ok(currentStatus, targetStatus, activities);
+}
+
+/**
+ * InternalReview → PendingSent | PendingRevision | QuoteSent | Canceled
+ *
+ * Reviewer makes a decision on an opportunity in InternalReview.
+ */
+export async function handleReviewDecision(
+ opportunity: OpportunityController,
+ user: WorkflowUser,
+ payload: ReviewDecisionPayload,
+): Promise {
+ const currentStatus = opportunity.statusCwId;
+ if (currentStatus == null) return fail("Opportunity has no current status.");
+
+ if (currentStatus !== OpportunityStatus.InternalReview) {
+ return fail(
+ `Review decisions can only be made when the opportunity is in InternalReview. Current status: "${StatusIdToKey[currentStatus] ?? "Unknown"}"`,
+ currentStatus,
+ );
+ }
+
+ const noteErr = assertNotePresent(payload.note);
+ if (noteErr) return fail(noteErr, currentStatus);
+
+ const activities: ActivityController[] = [];
+
+ switch (payload.decision) {
+ // ── Approve → PendingSent ──────────────────────────────────────────
+ case "approve": {
+ const targetStatus = OpportunityStatus.PendingSent;
+ const transErr = assertTransitionAllowed(currentStatus, targetStatus);
+ if (transErr) return fail(transErr, currentStatus);
+
+ const activity = await createWorkflowActivity({
+ name: `[Workflow] Review approved — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note,
+ optimaType: OptimaType.OpportunityReview,
+ });
+ activities.push(activity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note,
+ );
+ return ok(currentStatus, targetStatus, activities);
+ }
+
+ // ── Reject → PendingRevision ──────────────────────────────────────
+ case "reject": {
+ const targetStatus = OpportunityStatus.PendingRevision;
+ const transErr = assertTransitionAllowed(currentStatus, targetStatus);
+ if (transErr) return fail(transErr, currentStatus);
+
+ const activity = await createWorkflowActivity({
+ name: `[Workflow] Review rejected — revision required — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note,
+ optimaType: OptimaType.Revision,
+ });
+ activities.push(activity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note,
+ );
+ return ok(currentStatus, targetStatus, activities);
+ }
+
+ // ── Send directly (reviewer manually sends) ──────────────────────
+ case "send": {
+ const targetStatus = OpportunityStatus.QuoteSent;
+ const transErr = assertTransitionAllowed(currentStatus, targetStatus);
+ if (transErr) return fail(transErr, currentStatus);
+
+ // Create approved activity
+ const approvedActivity = await createWorkflowActivity({
+ name: `[Workflow] Review approved — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: `Review approved: ${payload.note}`,
+ optimaType: OptimaType.OpportunityReview,
+ });
+ activities.push(approvedActivity);
+
+ // Create quote-sent activity
+ const sentActivity = await createWorkflowActivity({
+ name: `[Workflow] Quote sent (by reviewer) — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: `Quote sent by reviewer: ${payload.note}`,
+ optimaType: OptimaType.QuoteSent,
+ });
+ activities.push(sentActivity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note,
+ );
+ return ok(currentStatus, targetStatus, activities);
+ }
+
+ // ── Cancel from review ────────────────────────────────────────────
+ case "cancel": {
+ const targetStatus = OpportunityStatus.Canceled;
+ const transErr = assertTransitionAllowed(currentStatus, targetStatus);
+ if (transErr) return fail(transErr, currentStatus);
+
+ if (!hasPermission(user, WorkflowPermissions.CANCEL)) {
+ return fail(
+ `User lacks the "${WorkflowPermissions.CANCEL}" permission required to cancel an opportunity.`,
+ currentStatus,
+ );
+ }
+
+ const activity = await createWorkflowActivity({
+ name: `[Workflow] Canceled from review — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note,
+ optimaType: OptimaType.Finalized,
+ });
+ activities.push(activity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note,
+ );
+ return ok(currentStatus, targetStatus, activities);
+ }
+
+ default:
+ return fail(
+ `Unknown review decision: "${(payload as any).decision}"`,
+ currentStatus,
+ );
+ }
+}
+
+/**
+ * PendingSent → QuoteSent (and its compound transitions)
+ *
+ * Also handles New → QuoteSent (direct send, skipping review) and
+ * Active → QuoteSent (re-send after revision).
+ *
+ * Payload flags:
+ * quoteConfirmed — sent + confirmed simultaneously → ConfirmedQuote
+ * won — presented and won → Won/PendingWon
+ * lost — rejected immediately → PendingLost
+ * needsRevision — needs revision → Active
+ */
+export async function transitionToQuoteSent(
+ opportunity: OpportunityController,
+ user: WorkflowUser,
+ payload: SendQuotePayload,
+): Promise {
+ const currentStatus = opportunity.statusCwId;
+ if (currentStatus == null) return fail("Opportunity has no current status.");
+
+ if (!hasPermission(user, WorkflowPermissions.SEND)) {
+ return fail(
+ `User lacks the "${WorkflowPermissions.SEND}" permission required to send a quote.`,
+ currentStatus,
+ );
+ }
+
+ // Validate source status allows transition to QuoteSent
+ const transErr = assertTransitionAllowed(
+ currentStatus,
+ OpportunityStatus.QuoteSent,
+ );
+ if (transErr) return fail(transErr, currentStatus);
+
+ const activities: ActivityController[] = [];
+ const now = new Date().toISOString();
+
+ // ── won flag: presented and won immediately ─────────────────────────
+ if (payload.won) {
+ if (!hasPermission(user, WorkflowPermissions.WIN)) {
+ return fail(
+ `User lacks the "${WorkflowPermissions.WIN}" permission required to mark an opportunity as won.`,
+ currentStatus,
+ );
+ }
+
+ const wantsFinalize = !!payload.finalize;
+ const canFinalize =
+ wantsFinalize && hasPermission(user, WorkflowPermissions.FINALIZE);
+
+ if (wantsFinalize && !canFinalize) {
+ return fail(
+ `User lacks the "${WorkflowPermissions.FINALIZE}" permission required to finalize an opportunity.`,
+ currentStatus,
+ );
+ }
+
+ const targetStatus = canFinalize
+ ? OpportunityStatus.Won
+ : OpportunityStatus.PendingWon;
+
+ // Combined Sent & Confirmed activity
+ const sentConfirmedActivity = await createWorkflowActivity({
+ name: `[Workflow] Quote sent & confirmed — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes:
+ `Quote sent and confirmed simultaneously (immediate win). ${payload.note ?? ""}`.trim(),
+ optimaType: OptimaType.QuoteSentConfirmed,
+ });
+ activities.push(sentConfirmedActivity);
+
+ // Won/PendingWon activity
+ const outcomeType = canFinalize
+ ? OptimaType.Converted
+ : OptimaType.Finalized;
+ const wonActivity = await createWorkflowActivity({
+ name: `[Workflow] ${canFinalize ? "Won" : "Pending Won"} — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes:
+ payload.note ?? `Opportunity ${canFinalize ? "won" : "pending won"}.`,
+ optimaType: outcomeType,
+ });
+ activities.push(wonActivity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note ?? "Immediate win.",
+ );
+ return ok(currentStatus, targetStatus, activities);
+ }
+
+ // ── lost flag: rejected immediately ─────────────────────────────────
+ if (payload.lost) {
+ if (!hasPermission(user, WorkflowPermissions.LOSE)) {
+ return fail(
+ `User lacks the "${WorkflowPermissions.LOSE}" permission required to mark an opportunity as lost.`,
+ currentStatus,
+ );
+ }
+
+ const wantsFinalize = !!payload.finalize;
+ const canFinalize =
+ wantsFinalize && hasPermission(user, WorkflowPermissions.FINALIZE);
+
+ if (wantsFinalize && !canFinalize) {
+ return fail(
+ `User lacks the "${WorkflowPermissions.FINALIZE}" permission required to finalize an opportunity.`,
+ currentStatus,
+ );
+ }
+
+ const targetStatus = canFinalize
+ ? OpportunityStatus.Lost
+ : OpportunityStatus.PendingLost;
+
+ const wasAlsoConfirmed = !!payload.quoteConfirmed;
+
+ const sentActivity = await createWorkflowActivity({
+ name: wasAlsoConfirmed
+ ? `[Workflow] Quote sent & confirmed — ${opportunity.name}`
+ : `[Workflow] Quote sent — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: wasAlsoConfirmed
+ ? `Quote sent and confirmed, but rejected. ${payload.note ?? ""}`.trim()
+ : `Quote sent. ${payload.note ?? ""}`.trim(),
+ optimaType: wasAlsoConfirmed
+ ? OptimaType.QuoteSentConfirmed
+ : OptimaType.QuoteSent,
+ });
+ activities.push(sentActivity);
+
+ const lostActivity = await createWorkflowActivity({
+ name: canFinalize
+ ? `[Workflow] Lost (immediate rejection) — ${opportunity.name}`
+ : `[Workflow] Pending Lost (immediate rejection) — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note ?? "Quote rejected immediately upon presentation.",
+ optimaType: canFinalize ? OptimaType.Converted : OptimaType.Finalized,
+ });
+ activities.push(lostActivity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note ?? "Immediate rejection.",
+ );
+ return ok(currentStatus, targetStatus, activities);
+ }
+
+ // ── needsRevision flag → Active ────────────────────────────────────
+ if (payload.needsRevision) {
+ const targetStatus = OpportunityStatus.Active;
+
+ const wasAlsoConfirmed = !!payload.quoteConfirmed;
+
+ const sentActivity = await createWorkflowActivity({
+ name: wasAlsoConfirmed
+ ? `[Workflow] Quote sent & confirmed — ${opportunity.name}`
+ : `[Workflow] Quote sent — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: wasAlsoConfirmed
+ ? `Quote sent and confirmed, but revision needed. ${payload.note ?? ""}`.trim()
+ : `Quote sent. ${payload.note ?? ""}`.trim(),
+ optimaType: wasAlsoConfirmed
+ ? OptimaType.QuoteSentConfirmed
+ : OptimaType.QuoteSent,
+ });
+ activities.push(sentActivity);
+
+ const revisionActivity = await createWorkflowActivity({
+ name: `[Workflow] Revision needed — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note ?? "Revision requested after quote presentation.",
+ optimaType: OptimaType.Revision,
+ });
+ activities.push(revisionActivity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note ?? "Needs revision.",
+ );
+ return ok(currentStatus, targetStatus, activities);
+ }
+
+ // ── quoteConfirmed flag → ConfirmedQuote ─────────────────────────────
+ if (payload.quoteConfirmed) {
+ const targetStatus = OpportunityStatus.ConfirmedQuote;
+
+ const sentConfirmedActivity = await createWorkflowActivity({
+ name: `[Workflow] Quote sent & confirmed — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes:
+ `Quote sent and confirmed simultaneously. ${payload.note ?? ""}`.trim(),
+ optimaType: OptimaType.QuoteSentConfirmed,
+ });
+ activities.push(sentConfirmedActivity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note ?? "Quote sent and confirmed.",
+ );
+ return ok(currentStatus, targetStatus, activities);
+ }
+
+ // ── Default: plain QuoteSent (no flags) ──────────────────────────────
+ const targetStatus = OpportunityStatus.QuoteSent;
+
+ const sentActivity = await createWorkflowActivity({
+ name: `[Workflow] Quote sent — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: `Quote sent. ${payload.note ?? ""}`.trim(),
+ optimaType: OptimaType.QuoteSent,
+ });
+ activities.push(sentActivity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note ?? "Quote sent.",
+ );
+ return ok(currentStatus, targetStatus, activities);
+}
+
+/**
+ * QuoteSent → ConfirmedQuote
+ *
+ * Customer has acknowledged receipt of the quote.
+ */
+export async function transitionToConfirmedQuote(
+ opportunity: OpportunityController,
+ user: WorkflowUser,
+ payload: ConfirmQuotePayload,
+): Promise {
+ const currentStatus = opportunity.statusCwId;
+ if (currentStatus == null) return fail("Opportunity has no current status.");
+
+ const targetStatus = OpportunityStatus.ConfirmedQuote;
+
+ const transErr = assertTransitionAllowed(currentStatus, targetStatus);
+ if (transErr) return fail(transErr, currentStatus);
+
+ const activities: ActivityController[] = [];
+
+ const activity = await createWorkflowActivity({
+ name: `[Workflow] Quote confirmed — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note ?? "Customer confirmed receipt of quote.",
+ optimaType: OptimaType.QuoteConfirmed,
+ });
+ activities.push(activity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note ?? "Quote confirmed.",
+ );
+ return ok(currentStatus, targetStatus, activities);
+}
+
+/**
+ * PendingWon → Won OR PendingLost → Lost
+ *
+ * Finalize from a pending state. Requires `sales.opportunity.finalize`.
+ *
+ * Also used for direct Won/Lost from QuoteSent/ConfirmedQuote when
+ * the user holds the finalize permission (called internally).
+ */
+export async function finalizeOpportunity(
+ opportunity: OpportunityController,
+ user: WorkflowUser,
+ payload: FinalizePayload,
+): Promise {
+ const currentStatus = opportunity.statusCwId;
+ if (currentStatus == null) return fail("Opportunity has no current status.");
+
+ const noteErr = assertNotePresent(payload.note);
+ if (noteErr) return fail(noteErr, currentStatus);
+
+ const requiredPerm =
+ payload.outcome === "won"
+ ? WorkflowPermissions.WIN
+ : WorkflowPermissions.LOSE;
+ if (!hasPermission(user, requiredPerm)) {
+ return fail(
+ `User lacks the "${requiredPerm}" permission required to mark an opportunity as ${payload.outcome}.`,
+ currentStatus,
+ );
+ }
+
+ if (!hasPermission(user, WorkflowPermissions.FINALIZE)) {
+ return fail(
+ `User lacks the "${WorkflowPermissions.FINALIZE}" permission required to finalize an opportunity.`,
+ currentStatus,
+ );
+ }
+
+ const targetStatus =
+ payload.outcome === "won" ? OpportunityStatus.Won : OpportunityStatus.Lost;
+
+ const transErr = assertTransitionAllowed(currentStatus, targetStatus);
+ if (transErr) return fail(transErr, currentStatus);
+
+ const activities: ActivityController[] = [];
+
+ const optimaType =
+ payload.outcome === "won" ? OptimaType.Converted : OptimaType.Finalized;
+
+ const activity = await createWorkflowActivity({
+ name: `[Workflow] ${payload.outcome === "won" ? "Won" : "Lost"} — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note,
+ optimaType,
+ });
+ activities.push(activity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note,
+ );
+ return ok(currentStatus, targetStatus, activities);
+}
+
+/**
+ * ConfirmedQuote/QuoteSent → PendingWon | PendingLost
+ *
+ * When the user DOES NOT have finalize permission, win/lose actions
+ * route to the pending variant.
+ */
+export async function transitionToPending(
+ opportunity: OpportunityController,
+ user: WorkflowUser,
+ payload: FinalizePayload,
+): Promise {
+ const currentStatus = opportunity.statusCwId;
+ if (currentStatus == null) return fail("Opportunity has no current status.");
+
+ const noteErr = assertNotePresent(payload.note);
+ if (noteErr) return fail(noteErr, currentStatus);
+
+ const requiredPerm =
+ payload.outcome === "won"
+ ? WorkflowPermissions.WIN
+ : WorkflowPermissions.LOSE;
+ if (!hasPermission(user, requiredPerm)) {
+ return fail(
+ `User lacks the "${requiredPerm}" permission required to mark an opportunity as ${payload.outcome}.`,
+ currentStatus,
+ );
+ }
+
+ const targetStatus =
+ payload.outcome === "won"
+ ? OpportunityStatus.PendingWon
+ : OpportunityStatus.PendingLost;
+
+ const transErr = assertTransitionAllowed(currentStatus, targetStatus);
+ if (transErr) return fail(transErr, currentStatus);
+
+ const activities: ActivityController[] = [];
+
+ const activity = await createWorkflowActivity({
+ name: `[Workflow] ${payload.outcome === "won" ? "Pending Won" : "Pending Lost"} — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note,
+ optimaType: OptimaType.Finalized,
+ });
+ activities.push(activity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note,
+ );
+ return ok(currentStatus, targetStatus, activities);
+}
+
+/**
+ * PendingWon / PendingLost → Active (resurrection / revision)
+ *
+ * Allows an admin (with finalize permission) to send a pending
+ * opportunity back to Active for revision.
+ */
+export async function resurrectOpportunity(
+ opportunity: OpportunityController,
+ user: WorkflowUser,
+ payload: ResurrectPayload,
+): Promise {
+ const currentStatus = opportunity.statusCwId;
+ if (currentStatus == null) return fail("Opportunity has no current status.");
+
+ // Only finalize-permissioned users can resurrect from PendingWon
+ if (
+ currentStatus === OpportunityStatus.PendingWon &&
+ !hasPermission(user, WorkflowPermissions.FINALIZE)
+ ) {
+ return fail(
+ "You do not have permission to send a Pending Won opportunity back for revision.",
+ currentStatus,
+ );
+ }
+
+ const noteErr = assertNotePresent(payload.note);
+ if (noteErr) return fail(noteErr, currentStatus);
+
+ const targetStatus = OpportunityStatus.Active;
+
+ const transErr = assertTransitionAllowed(currentStatus, targetStatus);
+ if (transErr) return fail(transErr, currentStatus);
+
+ const activities: ActivityController[] = [];
+
+ const fromLabel =
+ currentStatus === OpportunityStatus.PendingWon
+ ? "Pending Won"
+ : "Pending Lost";
+ const activity = await createWorkflowActivity({
+ name: `[Workflow] Resurrected from ${fromLabel} — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note,
+ optimaType: OptimaType.Revision,
+ });
+ activities.push(activity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note,
+ );
+ return ok(currentStatus, targetStatus, activities);
+}
+
+/**
+ * PendingRevision → Active
+ *
+ * Rep begins revising the opportunity after review rejection.
+ */
+export async function beginRevision(
+ opportunity: OpportunityController,
+ user: WorkflowUser,
+ payload: BeginRevisionPayload,
+): Promise {
+ const currentStatus = opportunity.statusCwId;
+ if (currentStatus == null) return fail("Opportunity has no current status.");
+
+ const targetStatus = OpportunityStatus.Active;
+
+ const transErr = assertTransitionAllowed(currentStatus, targetStatus);
+ if (transErr) return fail(transErr, currentStatus);
+
+ const activities: ActivityController[] = [];
+
+ const activity = await createWorkflowActivity({
+ name: `[Workflow] Revision started — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note ?? "Revision started after review rejection.",
+ optimaType: OptimaType.Revision,
+ });
+ activities.push(activity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note ?? "Revision started.",
+ );
+ return ok(currentStatus, targetStatus, activities);
+}
+
+/**
+ * Any cancelable status → Canceled
+ *
+ * Requires `sales.opportunity.cancel` permission.
+ */
+export async function cancelOpportunity(
+ opportunity: OpportunityController,
+ user: WorkflowUser,
+ payload: CancelPayload,
+): Promise {
+ const currentStatus = opportunity.statusCwId;
+ if (currentStatus == null) return fail("Opportunity has no current status.");
+
+ const noteErr = assertNotePresent(payload.note);
+ if (noteErr) return fail(noteErr, currentStatus);
+
+ if (!hasPermission(user, WorkflowPermissions.CANCEL)) {
+ return fail(
+ `User lacks the "${WorkflowPermissions.CANCEL}" permission required to cancel an opportunity.`,
+ currentStatus,
+ );
+ }
+
+ const targetStatus = OpportunityStatus.Canceled;
+
+ const transErr = assertTransitionAllowed(currentStatus, targetStatus);
+ if (transErr) return fail(transErr, currentStatus);
+
+ const activities: ActivityController[] = [];
+
+ const activity = await createWorkflowActivity({
+ name: `[Workflow] Canceled — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note,
+ optimaType: OptimaType.Finalized,
+ });
+ activities.push(activity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note,
+ );
+ return ok(currentStatus, targetStatus, activities);
+}
+
+/**
+ * Canceled → Active (re-open)
+ *
+ * Dedicated re-open action. Creates an audit trail activity with a
+ * required note explaining why the opportunity is being re-opened.
+ */
+export async function reopenCancelledOpportunity(
+ opportunity: OpportunityController,
+ user: WorkflowUser,
+ payload: ReopenPayload,
+): Promise {
+ const currentStatus = opportunity.statusCwId;
+ if (currentStatus == null) return fail("Opportunity has no current status.");
+
+ const noteErr = assertNotePresent(payload.note);
+ if (noteErr) return fail(noteErr, currentStatus);
+
+ if (!hasPermission(user, WorkflowPermissions.REOPEN)) {
+ return fail(
+ `User lacks the "${WorkflowPermissions.REOPEN}" permission required to re-open a cancelled opportunity.`,
+ currentStatus,
+ );
+ }
+
+ const targetStatus = OpportunityStatus.Active;
+
+ const transErr = assertTransitionAllowed(currentStatus, targetStatus);
+ if (transErr) return fail(transErr, currentStatus);
+
+ const activities: ActivityController[] = [];
+
+ const activity = await createWorkflowActivity({
+ name: `[Workflow] Re-opened from Canceled — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: user.cwMemberId,
+ notes: payload.note,
+ optimaType: OptimaType.Revision,
+ });
+ activities.push(activity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ await handleTimeEntry(
+ activities[0]?.cwActivityId,
+ user.cwMemberId,
+ payload,
+ payload.note,
+ );
+ return ok(currentStatus, targetStatus, activities);
+}
+
+/**
+ * Cold detection automation trigger.
+ *
+ * → InternalReview (system-generated)
+ *
+ * Called by automation/scheduler, not by a user action.
+ * Evaluates the cold threshold and, if cold, transitions the
+ * opportunity to InternalReview with a system-generated activity.
+ */
+export async function triggerColdDetection(
+ opportunity: OpportunityController,
+ lastActivityDate: Date | null,
+): Promise {
+ const currentStatus = opportunity.statusCwId;
+ if (currentStatus == null) return fail("Opportunity has no current status.");
+
+ const coldResult = checkColdStatus({
+ statusCwId: currentStatus,
+ lastActivityDate,
+ });
+
+ if (!coldResult.cold) {
+ return {
+ success: true,
+ previousStatusId: currentStatus,
+ newStatusId: currentStatus,
+ previousStatus: StatusIdToKey[currentStatus] ?? null,
+ newStatus: StatusIdToKey[currentStatus] ?? null,
+ activitiesCreated: [],
+ coldCheck: coldResult,
+ error: null,
+ };
+ }
+
+ const targetStatus = OpportunityStatus.InternalReview;
+
+ const transErr = assertTransitionAllowed(currentStatus, targetStatus);
+ if (transErr) {
+ return {
+ ...fail(transErr, currentStatus),
+ coldCheck: coldResult,
+ };
+ }
+
+ const activities: ActivityController[] = [];
+
+ // System-generated activity — no user assignment, use a system marker.
+ // We use assignTo with a placeholder; the caller should provide a
+ // system member ID when integrating.
+ const trigger = coldResult.triggeredBy!;
+ const activity = await createWorkflowActivity({
+ name: `[Workflow][Auto] Cold detected — ${opportunity.name}`,
+ opportunityCwId: opportunity.cwOpportunityId,
+ companyCwId: opportunity.companyCwId,
+ assignToCwMemberId: 0, // system — will be resolved by caller
+ notes:
+ `Opportunity went cold. Status "${trigger.statusName}" exceeded ` +
+ `${trigger.thresholdDays}-day threshold (stale for ${trigger.staleDays} days). ` +
+ `Automatically moved to Internal Review.`,
+ optimaType: OptimaType.OpportunityReview,
+ });
+ activities.push(activity);
+
+ await syncOpportunityStatus({
+ opportunityId: opportunity.cwOpportunityId,
+ statusCwId: targetStatus,
+ });
+
+ return {
+ ...ok(currentStatus, targetStatus, activities),
+ coldCheck: coldResult,
+ };
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// CLOSE OPEN WORKFLOW ACTIVITIES
+// ═══════════════════════════════════════════════════════════════════════════
+
+/**
+ * Close any open workflow activities for this opportunity.
+ *
+ * Called at the start of every workflow transition so that activities
+ * marked as "stays open" (Opportunity Setup, Opportunity Review, Revision)
+ * are closed when the opportunity moves to the next stage.
+ */
+async function closeOpenWorkflowActivities(
+ opportunityCwId: number,
+): Promise {
+ try {
+ const activities =
+ await activityCw.fetchByOpportunityDirect(opportunityCwId);
+
+ for (const raw of activities) {
+ // Only close Open activities (status id !== 2)
+ if (raw.status?.id === 2) continue;
+
+ // Only close activities that have a workflow Optima_Type custom field
+ const optimaField = raw.customFields?.find(
+ (f: any) => f.id === OptimaType.FIELD_ID,
+ );
+ if (!optimaField?.value) continue;
+
+ // Only close activities whose type is in the stays-open set
+ if (!STAYS_OPEN_TYPES.has(optimaField.value as OptimaTypeValue)) continue;
+
+ const closeDate = new Date().toISOString();
+ const existingFields = (raw.customFields ?? []).map((f: any) =>
+ f.id === CLOSE_DATE_FIELD_ID ? { ...f, value: closeDate } : f,
+ );
+ // Add Close Date if it wasn't already present
+ if (!existingFields.some((f: any) => f.id === CLOSE_DATE_FIELD_ID)) {
+ existingFields.push({
+ id: CLOSE_DATE_FIELD_ID,
+ caption: "Close Date",
+ type: "Text",
+ entryMethod: "Date",
+ numberOfDecimals: 0,
+ value: closeDate,
+ });
+ }
+
+ const controller = new ActivityController(raw);
+ await controller.update([
+ { op: "replace", path: "status", value: { id: 2 } },
+ { op: "replace", path: "customFields", value: existingFields },
+ ]);
+ }
+ } catch (err) {
+ console.error(
+ `[Workflow] Failed to close open activities for opportunity ${opportunityCwId}:`,
+ err,
+ );
+ // Non-fatal — don't block the transition
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// MASTER DISPATCHER
+// ═══════════════════════════════════════════════════════════════════════════
+
+/**
+ * Process an opportunity workflow action.
+ *
+ * This is the single entry point for all workflow transitions. It:
+ * 1. Validates the opportunity stage is "Optima".
+ * 2. Validates the opportunity is not in a terminal status.
+ * 3. Routes the action to the correct transition function.
+ * 4. On success, refreshes the opportunity from CW to keep
+ * the local database in sync.
+ *
+ * @param opportunity - The OpportunityController instance to act on.
+ * @param action - The discriminated action union.
+ * @param user - The authenticated user performing the action.
+ */
+export async function processOpportunityAction(
+ opportunity: OpportunityController,
+ { action, payload }: WorkflowAction,
+ user: WorkflowUser,
+): Promise {
+ // ── Stage gate ──────────────────────────────────────────────────────
+ const stageErr = assertOptimaStage(opportunity);
+ if (stageErr) return fail(stageErr, opportunity.statusCwId);
+
+ // ── Terminal gate (except reopen which starts from Canceled) ────────
+ if (action !== "reopen") {
+ const termErr = assertNotTerminal(opportunity.statusCwId);
+ if (termErr) return fail(termErr, opportunity.statusCwId);
+ }
+
+ // ── Close any open workflow activities from previous stage ──────────
+ await closeOpenWorkflowActivities(opportunity.cwOpportunityId);
+
+ // ── Route to transition function ────────────────────────────────────
+ let result: WorkflowResult;
+
+ switch (action) {
+ case "acceptNew":
+ result = await transitionToNew(opportunity, user, payload);
+ break;
+
+ case "requestReview":
+ result = await transitionToInternalReview(opportunity, user, payload);
+ break;
+
+ case "reviewDecision":
+ result = await handleReviewDecision(opportunity, user, payload);
+ break;
+
+ case "sendQuote":
+ result = await transitionToQuoteSent(opportunity, user, payload);
+ break;
+
+ case "confirmQuote":
+ result = await transitionToConfirmedQuote(opportunity, user, payload);
+ break;
+
+ case "finalize": {
+ // If user has finalize permission, go directly to Won/Lost.
+ // Otherwise, go to PendingWon/PendingLost.
+ result = hasPermission(user, WorkflowPermissions.FINALIZE)
+ ? await finalizeOpportunity(opportunity, user, payload)
+ : await transitionToPending(opportunity, user, payload);
+ break;
+ }
+
+ case "resurrect":
+ result = await resurrectOpportunity(opportunity, user, payload);
+ break;
+
+ case "beginRevision":
+ result = await beginRevision(opportunity, user, payload);
+ break;
+
+ case "resendQuote":
+ result = await transitionToQuoteSent(opportunity, user, payload);
+ break;
+
+ case "cancel":
+ result = await cancelOpportunity(opportunity, user, payload);
+ break;
+
+ case "reopen":
+ result = await reopenCancelledOpportunity(opportunity, user, payload);
+ break;
+
+ default: {
+ const _exhaustive: never = action;
+ return fail(`Unknown workflow action: "${_exhaustive}"`);
+ }
+ }
+
+ // ── Post-transition: refresh opportunity from CW → local DB ────────
+ if (result.success) {
+ try {
+ await opportunity.refreshFromCW();
+ } catch (refreshErr) {
+ console.error(
+ "[Workflow] Failed to refresh opportunity from CW after transition:",
+ refreshErr,
+ );
+ // Don't fail the workflow — the transition itself succeeded.
+ }
+ }
+
+ return result;
+}
diff --git a/tests/setup.ts b/tests/setup.ts
index 92f14b5..8ae242b 100644
--- a/tests/setup.ts
+++ b/tests/setup.ts
@@ -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 = {},
+): Record {
+ 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 = {},
+): Record {
+ return {
+ events: {
+ emit: mock(),
+ on: mock(),
+ off: mock(),
+ once: mock(),
+ removeAllListeners: mock(),
+ },
+ setupEventDebugger: mock(),
+ ...overrides,
+ };
+}
+
export function createMockPrisma() {
const createModelProxy = () =>
new Proxy(
diff --git a/tests/unit/activitiesManager.test.ts b/tests/unit/activitiesManager.test.ts
new file mode 100644
index 0000000..80432f5
--- /dev/null
+++ b/tests/unit/activitiesManager.test.ts
@@ -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> = {},
+) {
+ 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);
+ });
+ });
+});
diff --git a/tests/unit/algoColdThreshold.test.ts b/tests/unit/algoColdThreshold.test.ts
new file mode 100644
index 0000000..81c6130
--- /dev/null
+++ b/tests/unit/algoColdThreshold.test.ts
@@ -0,0 +1,129 @@
+/**
+ * Tests for src/modules/algorithms/algo.coldThreshold.ts
+ *
+ * Pure function — no mocking needed.
+ */
+
+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", () => {
+ test("returns not cold when statusCwId is null", () => {
+ const result = checkColdStatus({
+ statusCwId: null,
+ lastActivityDate: new Date(),
+ });
+ expect(result.cold).toBe(false);
+ expect(result.triggeredBy).toBeNull();
+ });
+
+ test("returns not cold for non-eligible status", () => {
+ const result = checkColdStatus({
+ statusCwId: 24, // New — not in threshold table
+ lastActivityDate: new Date("2020-01-01"),
+ now: new Date("2026-06-01"),
+ });
+ expect(result.cold).toBe(false);
+ });
+
+ test("returns not cold when lastActivityDate is null", () => {
+ const result = checkColdStatus({
+ statusCwId: 43, // QuoteSent
+ lastActivityDate: null,
+ });
+ expect(result.cold).toBe(false);
+ });
+
+ test("returns not cold when within threshold (QuoteSent, 13 days)", () => {
+ const now = new Date("2026-03-14T00:00:00Z");
+ const lastActivity = new Date("2026-03-01T00:00:00Z"); // 13 days ago
+ const result = checkColdStatus({
+ statusCwId: 43,
+ lastActivityDate: lastActivity,
+ now,
+ });
+ expect(result.cold).toBe(false);
+ });
+
+ test("returns cold when QuoteSent exceeds 14 days", () => {
+ const now = new Date("2026-03-16T00:00:00Z");
+ const lastActivity = new Date("2026-03-01T00:00:00Z"); // 15 days ago
+ const result = checkColdStatus({
+ statusCwId: 43,
+ lastActivityDate: lastActivity,
+ now,
+ });
+ expect(result.cold).toBe(true);
+ expect(result.triggeredBy).not.toBeNull();
+ expect(result.triggeredBy!.statusCwId).toBe(43);
+ expect(result.triggeredBy!.statusName).toBe("QuoteSent");
+ expect(result.triggeredBy!.thresholdDays).toBe(14);
+ expect(result.triggeredBy!.staleDays).toBe(15);
+ });
+
+ test("returns cold when ConfirmedQuote exceeds 30 days", () => {
+ const now = new Date("2026-04-01T00:00:00Z");
+ const lastActivity = new Date("2026-02-28T00:00:00Z"); // 32 days
+ const result = checkColdStatus({
+ statusCwId: 57,
+ lastActivityDate: lastActivity,
+ now,
+ });
+ expect(result.cold).toBe(true);
+ expect(result.triggeredBy!.statusName).toBe("ConfirmedQuote");
+ expect(result.triggeredBy!.thresholdDays).toBe(30);
+ expect(result.triggeredBy!.staleDays).toBeGreaterThanOrEqual(30);
+ });
+
+ test("returns not cold when ConfirmedQuote within 30 days", () => {
+ const now = new Date("2026-03-20T00:00:00Z");
+ const lastActivity = new Date("2026-03-01T00:00:00Z"); // 19 days
+ const result = checkColdStatus({
+ statusCwId: 57,
+ lastActivityDate: lastActivity,
+ now,
+ });
+ expect(result.cold).toBe(false);
+ });
+
+ test("exactly at threshold is cold (>= threshold)", () => {
+ const now = new Date("2026-03-15T00:00:00Z");
+ const lastActivity = new Date("2026-03-01T00:00:00Z"); // exactly 14 days
+ const result = checkColdStatus({
+ statusCwId: 43,
+ lastActivityDate: lastActivity,
+ now,
+ });
+ expect(result.cold).toBe(true);
+ expect(result.triggeredBy!.staleDays).toBe(14);
+ });
+
+ test("now override works as expected", () => {
+ const fixed = new Date("2026-06-01T00:00:00Z");
+ const lastActivity = new Date("2026-05-01T00:00:00Z"); // 31 days
+ const result = checkColdStatus({
+ statusCwId: 57,
+ lastActivityDate: lastActivity,
+ now: fixed,
+ });
+ expect(result.cold).toBe(true);
+ expect(result.triggeredBy!.staleDays).toBe(31);
+ });
+});
diff --git a/tests/unit/algoFollowUpScheduler.test.ts b/tests/unit/algoFollowUpScheduler.test.ts
new file mode 100644
index 0000000..b779378
--- /dev/null
+++ b/tests/unit/algoFollowUpScheduler.test.ts
@@ -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(),
+ );
+ });
+});
diff --git a/tests/unit/companiesManager.test.ts b/tests/unit/companiesManager.test.ts
new file mode 100644
index 0000000..61a15af
--- /dev/null
+++ b/tests/unit/companiesManager.test.ts
@@ -0,0 +1,178 @@
+import { describe, test, expect, mock, beforeEach } from "bun:test";
+import { buildMockCompany } from "../setup";
+
+// ---------------------------------------------------------------------------
+// Stable mock factory
+// ---------------------------------------------------------------------------
+
+function createStablePrismaMock(
+ overrides: Record> = {},
+) {
+ 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");
+ });
+ });
+});
diff --git a/tests/unit/computeProductsCacheTTL.test.ts b/tests/unit/computeProductsCacheTTL.test.ts
index 214e39d..f225518 100644
--- a/tests/unit/computeProductsCacheTTL.test.ts
+++ b/tests/unit/computeProductsCacheTTL.test.ts
@@ -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 ----------------------------------
diff --git a/tests/unit/controllers/CwMemberController.test.ts b/tests/unit/controllers/CwMemberController.test.ts
new file mode 100644
index 0000000..02ccddf
--- /dev/null
+++ b/tests/unit/controllers/CwMemberController.test.ts
@@ -0,0 +1,181 @@
+import { describe, test, expect } from "bun:test";
+import { CwMemberController } from "../../../src/controllers/CwMemberController";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function buildMockCwMember(overrides: Record = {}) {
+ 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");
+ });
+ });
+});
diff --git a/tests/unit/credentialTypesManager.test.ts b/tests/unit/credentialTypesManager.test.ts
new file mode 100644
index 0000000..09066b9
--- /dev/null
+++ b/tests/unit/credentialTypesManager.test.ts
@@ -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> = {},
+) {
+ 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" } });
+ });
+ });
+});
diff --git a/tests/unit/credentialsManager.test.ts b/tests/unit/credentialsManager.test.ts
new file mode 100644
index 0000000..47a9fcb
--- /dev/null
+++ b/tests/unit/credentialsManager.test.ts
@@ -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> = {},
+) {
+ 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);
+ }
+ });
+ });
+});
diff --git a/tests/unit/cwMembersManager.test.ts b/tests/unit/cwMembersManager.test.ts
new file mode 100644
index 0000000..1dc3724
--- /dev/null
+++ b/tests/unit/cwMembersManager.test.ts
@@ -0,0 +1,188 @@
+import { describe, test, expect, mock, beforeEach } from "bun:test";
+
+// ---------------------------------------------------------------------------
+// Stable mock factory
+// ---------------------------------------------------------------------------
+
+function createStablePrismaMock(
+ overrides: Record> = {},
+) {
+ 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 = {}) {
+ 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");
+ });
+ });
+});
diff --git a/tests/unit/cwOpportunityService.test.ts b/tests/unit/cwOpportunityService.test.ts
new file mode 100644
index 0000000..c90a5fc
--- /dev/null
+++ b/tests/unit/cwOpportunityService.test.ts
@@ -0,0 +1,182 @@
+import { describe, test, expect, mock, beforeEach } from "bun:test";
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("cw.opportunityService", () => {
+ beforeEach(() => {
+ mock.restore();
+ });
+
+ // -------------------------------------------------------------------
+ // submitTimeEntry
+ // -------------------------------------------------------------------
+ describe("submitTimeEntry()", () => {
+ test("submits time entry and returns success", async () => {
+ const postMock = mock(() => Promise.resolve({ data: { id: 9001 } }));
+ mock.module("../../src/constants", () => ({
+ connectWiseApi: { post: postMock },
+ prisma: new Proxy(
+ {},
+ {
+ get: () => mock(() => Promise.resolve(null)),
+ },
+ ),
+ }));
+ mock.module(
+ "../../src/modules/cw-utils/opportunities/opportunities",
+ () => ({
+ opportunityCw: {
+ update: mock(() => Promise.resolve({})),
+ },
+ }),
+ );
+
+ const { submitTimeEntry } =
+ await import("../../src/services/cw.opportunityService");
+ 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 () => {
+ const postMock = mock(() => Promise.resolve({ data: { id: 9001 } }));
+ mock.module("../../src/constants", () => ({
+ connectWiseApi: { post: postMock },
+ prisma: new Proxy(
+ {},
+ {
+ get: () => mock(() => Promise.resolve(null)),
+ },
+ ),
+ }));
+ mock.module(
+ "../../src/modules/cw-utils/opportunities/opportunities",
+ () => ({
+ opportunityCw: { update: mock(() => Promise.resolve({})) },
+ }),
+ );
+
+ const { submitTimeEntry } =
+ await import("../../src/services/cw.opportunityService");
+ 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 () => {
+ mock.module("../../src/constants", () => ({
+ connectWiseApi: {
+ post: mock(() => Promise.reject(new Error("CW down"))),
+ },
+ prisma: new Proxy(
+ {},
+ {
+ get: () => mock(() => Promise.resolve(null)),
+ },
+ ),
+ }));
+ mock.module(
+ "../../src/modules/cw-utils/opportunities/opportunities",
+ () => ({
+ opportunityCw: { update: mock(() => Promise.resolve({})) },
+ }),
+ );
+
+ const { submitTimeEntry } =
+ await import("../../src/services/cw.opportunityService");
+ 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 updateMock = mock(() => Promise.resolve({}));
+ mock.module("../../src/constants", () => ({
+ connectWiseApi: { post: mock(() => Promise.resolve({ data: {} })) },
+ prisma: new Proxy(
+ {},
+ {
+ get: () => mock(() => Promise.resolve(null)),
+ },
+ ),
+ }));
+ mock.module(
+ "../../src/modules/cw-utils/opportunities/opportunities",
+ () => ({
+ opportunityCw: { update: updateMock },
+ }),
+ );
+
+ const { syncOpportunityStatus } =
+ await import("../../src/services/cw.opportunityService");
+ 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 () => {
+ mock.module("../../src/constants", () => ({
+ connectWiseApi: { post: mock(() => Promise.resolve({ data: {} })) },
+ prisma: new Proxy(
+ {},
+ {
+ get: () => mock(() => Promise.resolve(null)),
+ },
+ ),
+ }));
+ mock.module(
+ "../../src/modules/cw-utils/opportunities/opportunities",
+ () => ({
+ opportunityCw: {
+ update: mock(() => Promise.reject(new Error("API fail"))),
+ },
+ }),
+ );
+
+ const { syncOpportunityStatus } =
+ await import("../../src/services/cw.opportunityService");
+ const result = await syncOpportunityStatus({
+ opportunityId: 1001,
+ statusCwId: 24,
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.message).toContain("Failed");
+ });
+ });
+});
diff --git a/tests/unit/fetchMicrosoftUser.test.ts b/tests/unit/fetchMicrosoftUser.test.ts
new file mode 100644
index 0000000..f737bcd
--- /dev/null
+++ b/tests/unit/fetchMicrosoftUser.test.ts
@@ -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;
+
+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);
+ });
+});
diff --git a/tests/unit/globalEvents.test.ts b/tests/unit/globalEvents.test.ts
index 166e6fc..aa30087 100644
--- a/tests/unit/globalEvents.test.ts
+++ b/tests/unit/globalEvents.test.ts
@@ -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();
});
diff --git a/tests/unit/injectPdfMetadata.test.ts b/tests/unit/injectPdfMetadata.test.ts
new file mode 100644
index 0000000..bc4316c
--- /dev/null
+++ b/tests/unit/injectPdfMetadata.test.ts
@@ -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 {
+ 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");
+ });
+});
diff --git a/tests/unit/middleware/authorization.test.ts b/tests/unit/middleware/authorization.test.ts
index 2eb77d9..b6e89fa 100644
--- a/tests/unit/middleware/authorization.test.ts
+++ b/tests/unit/middleware/authorization.test.ts
@@ -17,6 +17,7 @@ mock.module("../../../src/modules/globalEvents", () => ({
on: mock(),
any: mock(),
},
+ setupEventDebugger: mock(),
}));
import { authMiddleware } from "../../../src/api/middleware/authorization";
diff --git a/tests/unit/opportunitiesManager.test.ts b/tests/unit/opportunitiesManager.test.ts
new file mode 100644
index 0000000..8c46088
--- /dev/null
+++ b/tests/unit/opportunitiesManager.test.ts
@@ -0,0 +1,321 @@
+import { describe, test, expect, mock, beforeEach } from "bun:test";
+import {
+ buildMockOpportunity,
+ buildMockCompany,
+ buildMockConstants,
+} from "../setup";
+
+// ---------------------------------------------------------------------------
+// Stable mock factory
+// ---------------------------------------------------------------------------
+
+function createStablePrismaMock(
+ overrides: Record> = {},
+) {
+ 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 — any unspecified export returns a mock fn. */
+function buildCacheMock(overrides: Record = {}) {
+ return new Proxy(overrides, {
+ get(target, prop: string) {
+ if (prop in target) return target[prop];
+ // Key helpers return strings; everything else returns a mock fn
+ if (prop.endsWith("CacheKey") || prop.endsWith("DataCacheKey"))
+ return mock((...args: any[]) => `mock:${prop}:${args.join(":")}`);
+ return mock(() => Promise.resolve(null));
+ },
+ });
+}
+
+// ---------------------------------------------------------------------------
+// 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/cache/opportunityCache", () =>
+ buildCacheMock(),
+ );
+ 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/cache/opportunityCache", () =>
+ buildCacheMock(),
+ );
+ 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/cache/opportunityCache", () =>
+ buildCacheMock(),
+ );
+ 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/cache/opportunityCache", () =>
+ buildCacheMock(),
+ );
+ 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/cache/opportunityCache", () =>
+ buildCacheMock(),
+ );
+ 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/cache/opportunityCache", () =>
+ buildCacheMock(),
+ );
+ 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);
+ });
+ });
+});
diff --git a/tests/unit/opportunityCache.test.ts b/tests/unit/opportunityCache.test.ts
new file mode 100644
index 0000000..51878ab
--- /dev/null
+++ b/tests/unit/opportunityCache.test.ts
@@ -0,0 +1,321 @@
+/**
+ * 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)),
+}));
+
+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([]);
+ });
+});
diff --git a/tests/unit/procurementManager.test.ts b/tests/unit/procurementManager.test.ts
new file mode 100644
index 0000000..c10a63f
--- /dev/null
+++ b/tests/unit/procurementManager.test.ts
@@ -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> = {},
+) {
+ 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);
+ }
+ });
+ });
+});
diff --git a/tests/unit/quoteStatuses.test.ts b/tests/unit/quoteStatuses.test.ts
index 1915750..ef30f2d 100644
--- a/tests/unit/quoteStatuses.test.ts
+++ b/tests/unit/quoteStatuses.test.ts
@@ -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);
}
});
diff --git a/tests/unit/rolesManager.test.ts b/tests/unit/rolesManager.test.ts
new file mode 100644
index 0000000..97582ab
--- /dev/null
+++ b/tests/unit/rolesManager.test.ts
@@ -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> = {},
+) {
+ 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",
+ );
+ });
+ });
+});
diff --git a/tests/unit/sessionsManager.test.ts b/tests/unit/sessionsManager.test.ts
new file mode 100644
index 0000000..6ca1f5b
--- /dev/null
+++ b/tests/unit/sessionsManager.test.ts
@@ -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> = {},
+) {
+ 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");
+ }
+ });
+ });
+});
diff --git a/tests/unit/unifiSitesManager.test.ts b/tests/unit/unifiSitesManager.test.ts
new file mode 100644
index 0000000..4299ad1
--- /dev/null
+++ b/tests/unit/unifiSitesManager.test.ts
@@ -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> = {},
+) {
+ 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);
+ });
+ });
+});
diff --git a/tests/unit/usersManager.test.ts b/tests/unit/usersManager.test.ts
new file mode 100644
index 0000000..0197ddf
--- /dev/null
+++ b/tests/unit/usersManager.test.ts
@@ -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> = {},
+) {
+ 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");
+ });
+ });
+});
diff --git a/tests/unit/wfOpportunity.test.ts b/tests/unit/wfOpportunity.test.ts
new file mode 100644
index 0000000..6b63609
--- /dev/null
+++ b/tests/unit/wfOpportunity.test.ts
@@ -0,0 +1,934 @@
+/**
+ * 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 mockCheckColdStatus = mock(() => ({ cold: false, triggeredBy: null }));
+
+mock.module("../../src/modules/algorithms/algo.coldThreshold", () => ({
+ checkColdStatus: mockCheckColdStatus,
+}));
+
+// ---------------------------------------------------------------------------
+// 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 {
+ return {
+ id: "user-1",
+ cwMemberId: 10,
+ permissions: ["*"], // all permissions by default
+ ...overrides,
+ };
+}
+
+function makeOpportunity(overrides: Record = {}): 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);
+ });
+});