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); + }); +});