feat: expand sales opportunity workflow and metrics APIs

This commit is contained in:
2026-03-15 23:38:56 -05:00
parent 33b34d08a7
commit e764932c39
55 changed files with 3425 additions and 157 deletions
+239 -37
View File
@@ -127,7 +127,7 @@ All fetch and fetchAll endpoints gate response object keys via `processObjectVal
| User | `obj.user` | `GET /user/@me`, `GET /user/users/:identifier`, `GET /user/users`, `GET /role/:identifier/users` |
| Role | `obj.role` | `GET /role/:identifier`, `GET /role/`, `GET /user/users/:identifier/roles` |
| Catalog Item | `obj.catalogItem` | `GET /procurement/items`, `GET /procurement/items/:identifier`, `GET /procurement/items/:identifier/linked` |
| Opportunity | `obj.opportunity` | `GET /sales/opportunities`, `GET /sales/opportunities/:identifier` |
| Opportunity | `obj.opportunity` | `GET /sales/opportunities`, `GET /sales/opportunities/opportunity/:identifier` |
| UniFi Site | `obj.unifiSite` | `GET /unifi/sites`, `GET /unifi/site/:id`, `GET /company/companies/:identifier/unifi/sites` |
| WiFi Network | `unifi.site.wifi.read` | `GET /unifi/site/:id/wifi` |
@@ -2956,7 +2956,11 @@ Fetch the list of all opportunity quote statuses (types). Returns a static list
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.fetch.many`
**Required Permissions:**
- Base access: `sales.opportunity.fetch.many`
- `scope=all`: `sales.opportunity.metrics.all`
- `identifier=<cwIdentifier>` (when different from caller's own identifier): `sales.opportunity.metrics.identifier.override`
**Response:**
@@ -3064,6 +3068,7 @@ Fetch a paginated list of opportunities. Supports search. Each opportunity inclu
"site": { "id": 50, "name": "Main Office" },
"customerPO": null,
"totalSalesTax": 0,
"expectedSalesTax": 0.06,
"probability": 50,
"location": { "id": 1, "name": "Murray" },
"department": { "id": 5, "name": "Sales" },
@@ -3161,9 +3166,198 @@ Get the total number of opportunities.
---
### Get My Opportunities
**GET** `/sales/opportunities/@me`
Returns all opportunities where the authenticated user is assigned as the primary or secondary sales rep. The user's ConnectWise member identifier is resolved from their account.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.fetch.many`
**Query Parameters:**
- `includeClosed` _(optional, default `false`)_ — When `true`, includes closed opportunities in the result.
**Response:**
```json
{
"status": 200,
"message": "Opportunities fetched successfully!",
"data": [ /* array of opportunity objects */ ],
"successful": true
}
```
**Notes:**
- Returns an empty array if the authenticated user has no ConnectWise identifier linked to their account.
---
### Get Opportunities by User ID
**GET** `/sales/opportunities/user/:id`
Returns all opportunities where the specified user is assigned as the primary or secondary sales rep.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.fetch.many`
**Path Parameters:**
- `id` — Internal user ID (cuid)
**Query Parameters:**
- `includeClosed` _(optional, default `false`)_ — When `true`, includes closed opportunities in the result.
**Response:**
```json
{
"status": 200,
"message": "Opportunities fetched successfully!",
"data": [ /* array of opportunity objects */ ],
"successful": true
}
```
**Error Responses:**
- `404` — User not found
- Returns an empty array if the user has no ConnectWise identifier linked.
---
### Get Sales Opportunity Metrics (Cached)
**GET** `/sales/opportunities/metrics`
Fetch precomputed sales opportunity metrics from Redis. Metrics are refreshed in the background every 5 minutes for active ConnectWise members and are designed for fast dashboard/reporting reads.
**Authentication Required:** Yes
**Required Permissions:** `sales.opportunity.fetch.many`
**Query Parameters:**
- `scope` _(optional, default `me`)_ — `me` returns metrics for the current user's `cwIdentifier`; `all` returns cached metrics for all active CW members.
- `identifier` _(optional)_ — Explicit CW member identifier override (e.g. `jroberts`). Ignored when `scope=all`.
**Strict parameter gating:**
- `scope=all` is rejected with `403` unless caller has `sales.opportunity.metrics.all`.
- `identifier=<cwIdentifier>` is rejected with `403` when the identifier differs from caller's own `cwIdentifier` and caller lacks `sales.opportunity.metrics.identifier.override`.
**Response (`scope=me`):**
```json
{
"status": 200,
"message": "Sales opportunity metrics fetched successfully!",
"data": {
"identifier": "jroberts",
"metrics": {
"memberIdentifier": "jroberts",
"memberName": "John Roberts",
"generatedAt": "2026-03-15T20:10:00.000Z",
"pipelineRevenue": 250000,
"closedWonRevenueMtd": 142500,
"closedWonRevenueYtd": 512000,
"winCount": { "mtd": 3, "ytd": 11 },
"lossCount": { "mtd": 1, "ytd": 4 },
"avgDaysToClose": 27.35,
"openOpportunityCount": 9,
"weightedPipelineRevenue": 173500,
"taxablePipelineRevenue": 198000,
"nonTaxablePipelineRevenue": 52000,
"avgOpenDealSize": 27777.78,
"avgWonDealSize": { "mtd": 47500, "ytd": 46545.45 },
"winRate": { "mtd": 0.75, "ytd": 0.7333 },
"lossRate": { "mtd": 0.25, "ytd": 0.2667 },
"assignedOpportunityCount": 14,
"cacheHitCount": 12,
"cacheMissCount": 2,
"cacheHitRate": 0.8571,
"opportunityBreakdown": {
"pipeline": [
{
"id": "clx1abc123",
"cwId": 98765,
"name": "Acme Corp - Network Upgrade",
"revenue": 45000,
"taxableRevenue": 45000,
"nonTaxableRevenue": 0,
"probability": 70,
"weightedRevenue": 31500,
"closedDate": null
}
],
"closedWonMtd": [
{
"id": "clx1def456",
"cwId": 98720,
"name": "Globex - Security Stack",
"revenue": 142500,
"taxableRevenue": 120000,
"nonTaxableRevenue": 22500,
"probability": 100,
"weightedRevenue": 142500,
"closedDate": "2026-03-08T00:00:00.000Z"
}
],
"closedWonYtd": [ /* same shape, all won opps this year */ ],
"closedLostMtd": [ /* same shape, lost opps this month */ ],
"closedLostYtd": [ /* same shape, lost opps this year */ ]
}
}
},
"successful": true
}
```
Each entry in an `opportunityBreakdown` list contains:
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Internal DB id (cuid) |
| `cwId` | `number` | ConnectWise opportunity ID |
| `name` | `string` | Opportunity name |
| `revenue` | `number` | Computed total revenue |
| `taxableRevenue` | `number` | Taxable portion of revenue |
| `nonTaxableRevenue` | `number` | Non-taxable portion of revenue |
| `probability` | `number` | Probability as a 0100 percent value |
| `weightedRevenue` | `number` | `revenue × (probability / 100)` |
| `closedDate` | `string \| null` | ISO 8601 close date, or `null` if open |
**Response (`scope=all`):**
```json
{
"status": 200,
"message": "Sales opportunity metrics fetched successfully!",
"data": {
"generatedAt": "2026-03-15T20:10:00.000Z",
"activeMemberCount": 26,
"memberIdentifiers": ["jroberts", "asmith"],
"members": {
"jroberts": { "memberIdentifier": "jroberts" },
"asmith": { "memberIdentifier": "asmith" }
}
},
"successful": true
}
```
---
### Get Opportunity
**GET** `/sales/opportunities/:identifier`
**GET** `/sales/opportunities/opportunity/:identifier`
Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The response includes hydrated company data (with address and contacts) and full site details (with address) when available. CW data (activities, company, site) is served from the Redis cache when available; on cache miss, data is fetched live from CW and cached with an adaptive TTL.
@@ -3183,6 +3377,8 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The
- `includeRegenData` _(optional)_ — When `"true"`, includes `quoteRegenData` on each quote object (applies to `?include=quotes`). Default: `false`.
- `includeRegenParams` _(optional)_ — When `"true"`, includes `quoteRegenParams` on each quote object (applies to `?include=quotes`). Default: `false`.
`expectedSalesTax` is computed from address using site address first and company address as fallback. Logic first checks state, then city-level jurisdiction when available in the sales tax data file, and otherwise falls back to the state's `avg_combined_rate`. Tennessee (`TN`) remains hard-coded to `0.0975` regardless of data-file values.
**Response:**
```json
@@ -3255,6 +3451,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The
},
"customerPO": null,
"totalSalesTax": 0,
"expectedSalesTax": 0.06,
"probability": 50,
"location": { "id": 1, "name": "Murray" },
"department": { "id": 5, "name": "Sales" },
@@ -3310,7 +3507,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The
### Refresh Opportunity
**POST** `/sales/opportunities/:identifier/refresh`
**POST** `/sales/opportunities/opportunity/:identifier/refresh`
Refresh an opportunity's local data by fetching the latest from ConnectWise. The response includes hydrated company data and site details.
@@ -3377,6 +3574,7 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise. The
"site": { "id": 50, "name": "Main Office" },
"customerPO": null,
"totalSalesTax": 0,
"expectedSalesTax": 0.06,
"probability": 50,
"location": { "id": 1, "name": "Murray" },
"department": { "id": 5, "name": "Sales" },
@@ -3518,6 +3716,7 @@ Create a new opportunity in ConnectWise. The created opportunity is synced to th
"site": { "id": 50, "name": "Main Office" },
"customerPO": "PO-12345",
"totalSalesTax": 0,
"expectedSalesTax": 0,
"probability": 0,
"location": { "id": 1, "name": "Murray" },
"department": null,
@@ -3552,7 +3751,7 @@ Create a new opportunity in ConnectWise. The created opportunity is synced to th
### Update Opportunity
**PATCH** `/sales/opportunities/:identifier`
**PATCH** `/sales/opportunities/opportunity/:identifier`
Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company, contact, site, description). Only the provided fields are patched; omitted fields remain unchanged. The updated opportunity is synced back to the local database and returned in the response.
@@ -3643,6 +3842,7 @@ Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company,
"site": { "id": 50, "name": "Main Office" },
"customerPO": "PO-12345",
"totalSalesTax": 0,
"expectedSalesTax": 0,
"probability": 50,
"location": { "id": 1, "name": "Murray" },
"department": { "id": 5, "name": "Sales" },
@@ -3667,7 +3867,7 @@ Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company,
### Delete Opportunity
**DELETE** `/sales/opportunities/:identifier`
**DELETE** `/sales/opportunities/opportunity/:identifier`
Delete an opportunity from ConnectWise and the local database. All related Redis caches (activities, notes, contacts, products, CW data) are invalidated.
@@ -3702,7 +3902,7 @@ Delete an opportunity from ConnectWise and the local database. All related Redis
### Get Opportunity Products
**GET** `/sales/opportunities/:identifier/products`
**GET** `/sales/opportunities/opportunity/:identifier/products`
Fetch products for an opportunity, scoped to items that have a matching ConnectWise procurement product (`forecastDetailId` link). Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Hot opportunities (updated within 3 days) have a 15-second TTL; others use a 30-minute lazy TTL. Products are returned sorted by the opportunity's local `productSequence` array when set; otherwise, items are sorted by their ConnectWise `sequenceNumber`.
@@ -3788,7 +3988,7 @@ Internal inventory data is sourced from the local CatalogItem database. If the p
### Resequence Opportunity Products
**PATCH** `/sales/opportunities/:identifier/products/sequence`
**PATCH** `/sales/opportunities/opportunity/:identifier/products/sequence`
Update the display order of products (forecast items) on an opportunity. The sequence is stored **locally** in the database (`productSequence` field on the Opportunity model) — no modifications are made to ConnectWise. This means forecast item IDs remain stable and procurement product linkages are unaffected.
@@ -3850,7 +4050,7 @@ When a `productSequence` is set, `GET .../products` returns items in that order.
### Edit Opportunity Product
**PATCH** `/sales/opportunities/:identifier/products/:productId/edit`
**PATCH** `/sales/opportunities/opportunity/:identifier/products/:productId/edit`
Edit a product line item on an opportunity. This route supports forecast-backed fields and procurement-backed fields (including custom fields for narrative/notes).
@@ -3915,7 +4115,7 @@ At least one field is required.
### Cancel / Uncancel Opportunity Product
**PATCH** `/sales/opportunities/:identifier/products/:productId/cancel`
**PATCH** `/sales/opportunities/opportunity/:identifier/products/:productId/cancel`
Set cancellation state for a product line item using procurement cancellation fields.
@@ -3974,7 +4174,7 @@ Set cancellation state for a product line item using procurement cancellation fi
### Delete Product from Opportunity
**DELETE** `/sales/opportunities/:identifier/products/:productId`
**DELETE** `/sales/opportunities/opportunity/: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.
@@ -4011,7 +4211,7 @@ Remove a forecast item (product) from an opportunity in ConnectWise. The item is
### Add Product to Opportunity
**POST** `/sales/opportunities/:identifier/products`
**POST** `/sales/opportunities/opportunity/:identifier/products`
Add a new product (forecast item) to an opportunity in ConnectWise. The request body is validated with Zod, then each submitted field is gated against `sales.opportunity.product.field.<field>` permissions — only fields the user has permission for are forwarded to ConnectWise.
@@ -4121,7 +4321,7 @@ All fields are optional. Only fields the user has the corresponding `sales.oppor
### Add SPECIAL ORDER Product
**POST** `/sales/opportunities/:identifier/products/special-order`
**POST** `/sales/opportunities/opportunity/:identifier/products/special-order`
Add one or more products as **SPECIAL ORDER** procurement line items. This route creates ConnectWise procurement products (not forecast items) and enforces stable defaults for quick-entry workflows.
@@ -4201,7 +4401,7 @@ Accepts either a single object or an array of objects.
### Get Labor Product Options
**GET** `/sales/opportunities/:identifier/products/labor/options`
**GET** `/sales/opportunities/opportunity/:identifier/products/labor/options`
Fetch the resolved **Field** and **Tech** labor catalog products plus default labor pricing metadata so the UI can hydrate the labor-entry form without hardcoding catalog IDs.
@@ -4252,7 +4452,7 @@ Fetch the resolved **Field** and **Tech** labor catalog products plus default la
### Add Labor Product
**POST** `/sales/opportunities/:identifier/products/labor`
**POST** `/sales/opportunities/opportunity/:identifier/products/labor`
Add a labor line item to an opportunity using one of the two canonical labor catalog products (**Field** or **Tech**). The route resolves both labor products from the local catalog, then picks the selected style and creates a ConnectWise procurement product.
@@ -4342,7 +4542,7 @@ Add a labor line item to an opportunity using one of the two canonical labor cat
### Fetch Committed Quotes
**GET** `/sales/opportunities/:identifier/quotes`
**GET** `/sales/opportunities/opportunity/:identifier/quotes`
Fetch all committed (finalized) quotes for an opportunity, ordered by most recent first.
@@ -4386,7 +4586,7 @@ Fetch all committed (finalized) quotes for an opportunity, ordered by most recen
### Commit Quote
**POST** `/sales/opportunities/:identifier/quote/commit`
**POST** `/sales/opportunities/opportunity/:identifier/quote/commit`
Generate a finalized (non-preview) quote PDF for an opportunity and store it in the database with regeneration metadata and creator attribution.
@@ -4503,7 +4703,7 @@ Generate a finalized (non-preview) quote PDF for an opportunity and store it in
### Preview Quote
**GET** `/sales/opportunities/:identifier/quote/:quoteId/preview`
**GET** `/sales/opportunities/opportunity/:identifier/quote/:quoteId/preview`
Regenerate a preview-stamped version of an existing committed quote PDF using its stored generation parameters. The PDF is not persisted — it is returned as a base64-encoded string.
@@ -4534,7 +4734,7 @@ Regenerate a preview-stamped version of an existing committed quote PDF using it
### Download Quote
**GET** `/sales/opportunities/:identifier/quote/:quoteId/download`
**GET** `/sales/opportunities/opportunity/:identifier/quote/:quoteId/download`
Download a committed quote PDF by its ID. Returns the PDF file as a base64-encoded string. Each call automatically records a download entry with the timestamp, user info, and fetch action in the quote's `downloads` array. Download-time metadata (downloadedAt, downloadedBy) is also injected into the PDF's document properties.
@@ -4588,7 +4788,7 @@ Download a committed quote PDF by its ID. Returns the PDF file as a base64-encod
### Fetch Quote Download History
**GET** `/sales/opportunities/:identifier/quotes/downloads`
**GET** `/sales/opportunities/opportunity/:identifier/quotes/downloads`
Fetch download/print history for all committed quotes on an opportunity. Returns an array of quote summaries, each containing the full `downloads` array with every download/print record. This is an admin-level route intended for audit and tracking purposes.
@@ -4638,7 +4838,7 @@ Fetch download/print history for all committed quotes on an opportunity. Returns
### Get Opportunity Notes
**GET** `/sales/opportunities/:identifier/notes`
**GET** `/sales/opportunities/opportunity/:identifier/notes`
Fetch notes for an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when notes are created, updated, or deleted.
@@ -4678,7 +4878,7 @@ Fetch notes for an opportunity. Data is served from the Redis cache when availab
### Get Single Opportunity Note
**GET** `/sales/opportunities/:identifier/notes/:noteId`
**GET** `/sales/opportunities/opportunity/:identifier/notes/:noteId`
Fetch a single note by its ConnectWise note ID for an opportunity.
@@ -4717,7 +4917,7 @@ Fetch a single note by its ConnectWise note ID for an opportunity.
### Create Opportunity Note
**POST** `/sales/opportunities/:identifier/notes`
**POST** `/sales/opportunities/opportunity/:identifier/notes`
Create a new note on an opportunity in ConnectWise.
@@ -4769,7 +4969,7 @@ Create a new note on an opportunity in ConnectWise.
### Update Opportunity Note
**PATCH** `/sales/opportunities/:identifier/notes/:noteId`
**PATCH** `/sales/opportunities/opportunity/:identifier/notes/:noteId`
Update an existing note on an opportunity in ConnectWise.
@@ -4824,7 +5024,7 @@ Update an existing note on an opportunity in ConnectWise.
### Delete Opportunity Note
**DELETE** `/sales/opportunities/:identifier/notes/:noteId`
**DELETE** `/sales/opportunities/opportunity/:identifier/notes/:noteId`
Delete a note from an opportunity in ConnectWise.
@@ -4851,7 +5051,7 @@ Delete a note from an opportunity in ConnectWise.
### Get Opportunity Contacts
**GET** `/sales/opportunities/:identifier/contacts`
**GET** `/sales/opportunities/opportunity/:identifier/contacts`
Fetch contacts associated with an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when contacts are created, updated, or deleted.
@@ -4905,7 +5105,8 @@ The opportunity workflow system is an internal engine that manages the lifecycle
| 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 |
| ReadyToSend | 63 | Ready to Send | No | Review approved; ready for rep send |
| PendingSent | 60 | Pending Sent | No | Deprecated legacy status; existing records may still use it |
| 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 |
@@ -4919,8 +5120,9 @@ The opportunity workflow system is an internal engine that manages the lifecycle
| --------------- | ------------------------------------------------------------------------------- |
| PendingNew | New |
| New | InternalReview, QuoteSent, Canceled |
| InternalReview | PendingSent (approve), PendingRevision (reject), QuoteSent (send), Canceled |
| PendingSent | QuoteSent |
| InternalReview | ReadyToSend (approve), PendingRevision (reject), QuoteSent (send), Canceled |
| ReadyToSend | QuoteSent |
| PendingSent | QuoteSent _(deprecated legacy path for existing records only)_ |
| PendingRevision | Active |
| QuoteSent | ConfirmedQuote, Won/PendingWon, PendingLost, Active, InternalReview (cold auto) |
| ConfirmedQuote | Won/PendingWon, PendingLost, Active, InternalReview (cold auto) |
@@ -4983,11 +5185,11 @@ Triggered via `triggerColdDetection()` (intended for schedulers/automation, not
### Workflow API Routes
These routes expose the opportunity workflow to the UI. They live under `/v1/sales/opportunities/:identifier/workflow`.
These routes expose the opportunity workflow to the UI. They live under `/v1/sales/opportunities/opportunity/:identifier/workflow`.
---
#### `GET /v1/sales/opportunities/:identifier/workflow`
#### `GET /v1/sales/opportunities/opportunity/: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.
@@ -5038,7 +5240,7 @@ Fetch the current workflow state for an opportunity — current status, stage, a
---
#### `POST /v1/sales/opportunities/:identifier/workflow`
#### `POST /v1/sales/opportunities/opportunity/: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.
@@ -5073,8 +5275,8 @@ Execute a workflow action. Accepts a discriminated union of `{ action, payload }
| ---------------- | --------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------- |
| `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) |
| `reviewDecision` | `note`, `decision` | `timeSpent` | InternalReview → ReadyToSend/PendingRevision/QuoteSent/Canceled |
| `sendQuote` | — | `note`, `timeSpent`, `quoteConfirmed`, `won`, `lost`, `needsRevision` | ReadyToSend/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 |
@@ -5094,8 +5296,8 @@ Execute a workflow action. Accepts a discriminated union of `{ action, payload }
"message": "Workflow action completed successfully.",
"status": 200,
"data": {
"previousStatusId": 60,
"previousStatus": "PendingSent",
"previousStatusId": 63,
"previousStatus": "ReadyToSend",
"newStatusId": 43,
"newStatus": "QuoteSent",
"activitiesCreated": [ { "...activity JSON..." } ],
@@ -5122,7 +5324,7 @@ Execute a workflow action. Accepts a discriminated union of `{ action, payload }
---
#### `GET /v1/sales/opportunities/:identifier/workflow/history`
#### `GET /v1/sales/opportunities/opportunity/: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.