feat: add opportunity workflows, delete routes, company sites, algorithms, and expanded test coverage

This commit is contained in:
2026-03-09 02:56:08 -05:00
parent c0a4d4f919
commit f53b390e18
50 changed files with 8837 additions and 63 deletions
+397
View File
@@ -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
}
}
```
<details>
<summary><strong>All action types and their payloads</strong></summary>
| Action | Required Payload Fields | Optional Payload Fields | Description |
| ---------------- | --------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------- |
| `acceptNew` | — | `note`, `timeSpent` | PendingNew → New |
| `requestReview` | `note` | `timeSpent` | New/Active → InternalReview |
| `reviewDecision` | `note`, `decision` | `timeSpent` | InternalReview → PendingSent/PendingRevision/QuoteSent/Canceled |
| `sendQuote` | — | `note`, `timeSpent`, `quoteConfirmed`, `won`, `lost`, `needsRevision` | PendingSent/New → QuoteSent (+ compound transitions) |
| `confirmQuote` | — | `note`, `timeSpent` | QuoteSent → ConfirmedQuote |
| `finalize` | `note`, `outcome` (`"won"` or `"lost"`) | `timeSpent` | Pending → Won/Lost (or direct if permitted) |
| `resurrect` | `note` | `timeSpent` | PendingLost → Active |
| `beginRevision` | — | `note`, `timeSpent` | PendingRevision → Active |
| `resendQuote` | — | `note`, `timeSpent`, `quoteConfirmed`, `won`, `lost`, `needsRevision` | Active → QuoteSent (+ compound transitions) |
| `cancel` | `note` | `timeSpent` | New/Active → Canceled (requires cancel perm) |
| `reopen` | `note` | `timeSpent` | Canceled → Active |
`decision` values for `reviewDecision`: `"approve"`, `"reject"`, `"send"`, `"cancel"`
</details>
**Response (200) — success:**
```json
{
"message": "Workflow action completed successfully.",
"status": 200,
"data": {
"previousStatusId": 60,
"previousStatus": "PendingSent",
"newStatusId": 43,
"newStatus": "QuoteSent",
"activitiesCreated": [ { "...activity JSON..." } ],
"coldCheck": null
}
}
```
**Response (422) — transition rejected:**
```json
{
"message": "Workflow action failed.",
"status": 422,
"name": "WorkflowTransitionFailed",
"data": {
"previousStatusId": 29,
"previousStatus": "Won",
"newStatusId": null,
"newStatus": null
}
}
```
---
#### `GET /v1/sales/opportunities/:identifier/workflow/history`
Fetch the workflow activity history for an opportunity. Returns only CW activities that have a valid `Optima_Type` custom field set, sorted newest first.
**Auth:** Bearer token required
**Permission:** `sales.opportunity.fetch`
**Source:** [src/api/sales/opportunities/[id]/workflow/history.ts](src/api/sales/opportunities/[id]/workflow/history.ts)
**Query parameters:**
| Param | Type | Description |
| ------ | ------ | ------------------------------------------------- |
| `type` | string | Filter by Optima_Type value (e.g. `"Quote Sent"`) |
**Response (200):**
```json
{
"message": "Workflow history fetched successfully.",
"status": 200,
"data": {
"opportunityId": "clx...",
"cwOpportunityId": 12345,
"totalActivities": 3,
"activities": [
{
"activity": { "...activity JSON..." },
"optimaType": "Quote Sent",
"quoteId": "QUO-12345",
"closed": true,
"closedAt": "2026-03-09T05:40:00.000Z"
}
]
}
}
```
| Field | Type | Description |
| ------------ | -------------- | -------------------------------------------------------------------------------------------- |
| `activity` | object | Full CW activity JSON from `ActivityController.toJson()` |
| `optimaType` | string | The `Optima_Type` custom field value (e.g. `"Quote Sent"`, `"Opportunity Setup"`) |
| `quoteId` | string \| null | The `QuoteID` custom field value (CW field id 48), or `null` if not set |
| `closed` | boolean | `true` when the CW activity status is Closed (status id 2) |
| `closedAt` | string \| null | ISO-8601 timestamp from the `Close Date` custom field (CW field id 49), or `null` if not set |
---
## UniFi Routes
All UniFi routes require the `unifi.access` permission in addition to their route-specific permission. This acts as a gate for the entire UniFi API.