feat: add opportunity workflows, delete routes, company sites, algorithms, and expanded test coverage
This commit is contained in:
+397
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user