Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0a4d4f919 | |||
| 0ce1eda606 |
+276
-1
@@ -137,7 +137,46 @@ See [PERMISSIONS.md](PERMISSIONS.md) for the full list of field-level permission
|
|||||||
|
|
||||||
## Authentication Routes
|
## Authentication Routes
|
||||||
|
|
||||||
## ConnectWise Callback Routes
|
## ConnectWise Routes
|
||||||
|
|
||||||
|
### Fetch CW Members
|
||||||
|
|
||||||
|
**GET** `/cw/members`
|
||||||
|
|
||||||
|
Returns all ConnectWise members from the server-side member cache, sorted alphabetically by name. By default only active members are returned.
|
||||||
|
|
||||||
|
**Authentication Required:** Yes (any authenticated user)
|
||||||
|
|
||||||
|
**Permissions Required:** None
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
| --------- | ------ | ------- | ------------------------------------------ |
|
||||||
|
| `active` | string | `true` | Set to `false` to include inactive members |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 200,
|
||||||
|
"message": "CW members fetched successfully!",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 250,
|
||||||
|
"identifier": "jroberts",
|
||||||
|
"firstName": "John",
|
||||||
|
"lastName": "Roberts",
|
||||||
|
"name": "John Roberts",
|
||||||
|
"officeEmail": "jroberts@totaltech.net",
|
||||||
|
"inactive": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Receive ConnectWise Callback
|
### Receive ConnectWise Callback
|
||||||
|
|
||||||
@@ -3344,6 +3383,242 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise. The
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Create Opportunity
|
||||||
|
|
||||||
|
**POST** `/sales/opportunities`
|
||||||
|
|
||||||
|
Create a new opportunity in ConnectWise. The created opportunity is synced to the local database and returned in the response. `name` and `expectedCloseDate` are required; all other fields are optional.
|
||||||
|
|
||||||
|
**Authentication Required:** Yes
|
||||||
|
|
||||||
|
**Required Permissions:** `sales.opportunity.create`
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Acme Corp Network Refresh",
|
||||||
|
"expectedCloseDate": "2026-06-01",
|
||||||
|
"notes": "Initial scoping phase",
|
||||||
|
"rating": { "id": 1 },
|
||||||
|
"type": { "id": 2 },
|
||||||
|
"stage": { "id": 1 },
|
||||||
|
"status": { "id": 1 },
|
||||||
|
"priority": { "id": 2 },
|
||||||
|
"campaign": { "id": 5 },
|
||||||
|
"primarySalesRep": { "id": 10 },
|
||||||
|
"secondarySalesRep": { "id": 12 },
|
||||||
|
"company": { "id": 100 },
|
||||||
|
"contact": { "id": 200 },
|
||||||
|
"site": { "id": 50 },
|
||||||
|
"source": "Referral",
|
||||||
|
"customerPO": "PO-12345",
|
||||||
|
"locationId": 1,
|
||||||
|
"businessUnitId": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| ------------------- | ------------------------ | -------- | --------------------------------------------------------- |
|
||||||
|
| `name` | `string` | Yes | Opportunity name |
|
||||||
|
| `expectedCloseDate` | `string` | Yes | Expected close date (date string, e.g. `2026-06-01`) |
|
||||||
|
| `primarySalesRep` | `{ id: number }` | Yes | CW member reference for primary sales rep |
|
||||||
|
| `company` | `{ id: number }` | Yes | CW company reference |
|
||||||
|
| `contact` | `{ id: number }` | Yes | CW contact reference |
|
||||||
|
| `notes` | `string` | No | Opportunity description / notes |
|
||||||
|
| `rating` | `{ id: number }` | No | CW rating reference |
|
||||||
|
| `type` | `{ id: number }` | No | CW opportunity type reference |
|
||||||
|
| `stage` | `{ id: number }` | No | CW pipeline stage reference |
|
||||||
|
| `status` | `{ id: number }` | No | CW status reference |
|
||||||
|
| `priority` | `{ id: number }` | No | CW priority reference |
|
||||||
|
| `campaign` | `{ id: number }` | No | CW campaign reference |
|
||||||
|
| `secondarySalesRep` | `{ id: number } \| null` | No | CW member reference for secondary sales rep (null clears) |
|
||||||
|
| `site` | `{ id: number } \| null` | No | CW site reference (null clears) |
|
||||||
|
| `source` | `string \| null` | No | Opportunity source (null clears) |
|
||||||
|
| `customerPO` | `string \| null` | No | Customer PO number (null clears) |
|
||||||
|
| `locationId` | `number` | No | CW location ID |
|
||||||
|
| `businessUnitId` | `number` | No | CW business unit ID |
|
||||||
|
|
||||||
|
**Response (201):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 201,
|
||||||
|
"message": "Opportunity created successfully!",
|
||||||
|
"data": {
|
||||||
|
"id": "clx...",
|
||||||
|
"cwOpportunityId": 789,
|
||||||
|
"name": "Acme Corp Network Refresh",
|
||||||
|
"notes": "Initial scoping phase",
|
||||||
|
"type": { "id": 2, "name": "Existing" },
|
||||||
|
"stage": { "id": 1, "name": "Prospect" },
|
||||||
|
"status": { "id": 1, "name": "Open" },
|
||||||
|
"priority": { "id": 2, "name": "High" },
|
||||||
|
"rating": { "id": 1, "name": "Hot" },
|
||||||
|
"source": "Referral",
|
||||||
|
"campaign": { "id": 5, "name": "Q2 Push" },
|
||||||
|
"primarySalesRep": {
|
||||||
|
"id": 10,
|
||||||
|
"identifier": "JDoe",
|
||||||
|
"name": "John Doe"
|
||||||
|
},
|
||||||
|
"secondarySalesRep": {
|
||||||
|
"id": 12,
|
||||||
|
"identifier": "ASmith",
|
||||||
|
"name": "Alice Smith"
|
||||||
|
},
|
||||||
|
"company": { "id": 100, "name": "Acme Corp" },
|
||||||
|
"contact": { "id": 200, "name": "Jane Smith" },
|
||||||
|
"site": { "id": 50, "name": "Main Office" },
|
||||||
|
"customerPO": "PO-12345",
|
||||||
|
"totalSalesTax": 0,
|
||||||
|
"probability": 0,
|
||||||
|
"location": { "id": 1, "name": "Murray" },
|
||||||
|
"department": null,
|
||||||
|
"expectedCloseDate": "2026-06-01T00:00:00.000Z",
|
||||||
|
"pipelineChangeDate": null,
|
||||||
|
"dateBecameLead": null,
|
||||||
|
"closedDate": null,
|
||||||
|
"closedFlag": false,
|
||||||
|
"closedBy": null,
|
||||||
|
"companyId": "clx...",
|
||||||
|
"cwLastUpdated": "2026-03-07T10:00:00.000Z",
|
||||||
|
"createdAt": "2026-03-07T10:00:00.000Z",
|
||||||
|
"updatedAt": "2026-03-07T10:00:00.000Z",
|
||||||
|
"customFields": [],
|
||||||
|
"activities": []
|
||||||
|
},
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
|
||||||
|
| Status | Scenario |
|
||||||
|
| ------- | ------------------------------------------------------- |
|
||||||
|
| 400 | Zod validation failure (missing name/expectedCloseDate) |
|
||||||
|
| 401 | Missing or invalid auth token |
|
||||||
|
| 403 | User lacks `sales.opportunity.create` permission |
|
||||||
|
| 4xx/5xx | ConnectWise API error (forwarded status + message) |
|
||||||
|
| 500 | Unexpected server error |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Update Opportunity
|
||||||
|
|
||||||
|
**PATCH** `/sales/opportunities/:identifier`
|
||||||
|
|
||||||
|
Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company, contact, site, description). Only the provided fields are patched; omitted fields remain unchanged. The updated opportunity is synced back to the local database and returned in the response.
|
||||||
|
|
||||||
|
**Authentication Required:** Yes
|
||||||
|
|
||||||
|
**Required Permissions:** `sales.opportunity.update`
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||||
|
|
||||||
|
**Request Body (all fields optional, at least one required):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Acme Corp Network Refresh — Phase 2",
|
||||||
|
"notes": "Updated project scope to include wireless",
|
||||||
|
"rating": { "id": 1 },
|
||||||
|
"type": { "id": 2 },
|
||||||
|
"stage": { "id": 4 },
|
||||||
|
"status": { "id": 1 },
|
||||||
|
"priority": { "id": 2 },
|
||||||
|
"campaign": { "id": 5 },
|
||||||
|
"primarySalesRep": { "id": 10 },
|
||||||
|
"secondarySalesRep": { "id": 12 },
|
||||||
|
"company": { "id": 100 },
|
||||||
|
"contact": { "id": 200 },
|
||||||
|
"site": { "id": 50 },
|
||||||
|
"expectedCloseDate": "2026-05-01",
|
||||||
|
"customerPO": "PO-12345",
|
||||||
|
"source": "Referral",
|
||||||
|
"locationId": 1,
|
||||||
|
"businessUnitId": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------------- | ------------------------ | --------------------------------------------------------- |
|
||||||
|
| `name` | `string` | Opportunity name |
|
||||||
|
| `notes` | `string` | Opportunity description / notes |
|
||||||
|
| `rating` | `{ id: number }` | CW rating reference |
|
||||||
|
| `type` | `{ id: number }` | CW opportunity type reference |
|
||||||
|
| `stage` | `{ id: number }` | CW pipeline stage reference |
|
||||||
|
| `status` | `{ id: number }` | CW status reference |
|
||||||
|
| `priority` | `{ id: number }` | CW priority reference |
|
||||||
|
| `campaign` | `{ id: number }` | CW campaign reference |
|
||||||
|
| `primarySalesRep` | `{ id: number }` | CW member reference for primary sales rep |
|
||||||
|
| `secondarySalesRep` | `{ id: number } \| null` | CW member reference for secondary sales rep (null clears) |
|
||||||
|
| `company` | `{ id: number }` | CW company reference |
|
||||||
|
| `contact` | `{ id: number } \| null` | CW contact reference (null clears) |
|
||||||
|
| `site` | `{ id: number } \| null` | CW site reference (null clears) |
|
||||||
|
| `expectedCloseDate` | `string` | Expected close date (ISO date string) |
|
||||||
|
| `customerPO` | `string \| null` | Customer PO number (null clears) |
|
||||||
|
| `source` | `string \| null` | Opportunity source (null clears) |
|
||||||
|
| `locationId` | `number` | CW location ID |
|
||||||
|
| `businessUnitId` | `number` | CW business unit ID |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 200,
|
||||||
|
"message": "Opportunity updated successfully!",
|
||||||
|
"data": {
|
||||||
|
"id": "clx...",
|
||||||
|
"cwOpportunityId": 456,
|
||||||
|
"name": "Acme Corp Network Refresh — Phase 2",
|
||||||
|
"description": "Updated project scope to include wireless",
|
||||||
|
"type": { "id": 2, "name": "Existing" },
|
||||||
|
"stage": { "id": 4, "name": "Negotiation" },
|
||||||
|
"status": { "id": 1, "name": "Open" },
|
||||||
|
"priority": { "id": 2, "name": "High" },
|
||||||
|
"rating": { "id": 1, "name": "Hot" },
|
||||||
|
"source": "Referral",
|
||||||
|
"campaign": { "id": 5, "name": "Q2 Push" },
|
||||||
|
"primarySalesRep": {
|
||||||
|
"id": 10,
|
||||||
|
"identifier": "JDoe",
|
||||||
|
"name": "John Doe"
|
||||||
|
},
|
||||||
|
"secondarySalesRep": {
|
||||||
|
"id": 12,
|
||||||
|
"identifier": "ASmith",
|
||||||
|
"name": "Alice Smith"
|
||||||
|
},
|
||||||
|
"company": { "id": 100, "name": "Acme Corp" },
|
||||||
|
"contact": { "id": 200, "name": "Jane Smith" },
|
||||||
|
"site": { "id": 50, "name": "Main Office" },
|
||||||
|
"customerPO": "PO-12345",
|
||||||
|
"totalSalesTax": 0,
|
||||||
|
"probability": 50,
|
||||||
|
"location": { "id": 1, "name": "Murray" },
|
||||||
|
"department": { "id": 5, "name": "Sales" },
|
||||||
|
"expectedCloseDate": "2026-05-01T00:00:00.000Z",
|
||||||
|
"pipelineChangeDate": "2026-02-25T00:00:00.000Z",
|
||||||
|
"dateBecameLead": "2026-01-10T00:00:00.000Z",
|
||||||
|
"closedDate": null,
|
||||||
|
"closedFlag": false,
|
||||||
|
"closedBy": null,
|
||||||
|
"companyId": "clx...",
|
||||||
|
"cwLastUpdated": "2026-03-07T10:00:00.000Z",
|
||||||
|
"createdAt": "2026-02-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-03-07T10:00:00.000Z",
|
||||||
|
"customFields": [],
|
||||||
|
"activities": []
|
||||||
|
},
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Get Opportunity Products
|
### Get Opportunity Products
|
||||||
|
|
||||||
**GET** `/sales/opportunities/:identifier/products`
|
**GET** `/sales/opportunities/:identifier/products`
|
||||||
|
|||||||
+11
-4
@@ -124,13 +124,16 @@ Admin-specific UI permissions that control visibility and data loading for admin
|
|||||||
| `procurement.catalog.inventory.refresh` | Refresh on-hand inventory for a catalog item from ConnectWise | [src/api/procurement/[id]/refreshInventory.ts](src/api/procurement/[id]/refreshInventory.ts) | `procurement.catalog.fetch` |
|
| `procurement.catalog.inventory.refresh` | Refresh on-hand inventory for a catalog item from ConnectWise | [src/api/procurement/[id]/refreshInventory.ts](src/api/procurement/[id]/refreshInventory.ts) | `procurement.catalog.fetch` |
|
||||||
| `procurement.catalog.link` | Link or unlink catalog items to each other | [src/api/procurement/[id]/link.ts](src/api/procurement/[id]/link.ts), [src/api/procurement/[id]/unlink.ts](src/api/procurement/[id]/unlink.ts) | `procurement.catalog.fetch` |
|
| `procurement.catalog.link` | Link or unlink catalog items to each other | [src/api/procurement/[id]/link.ts](src/api/procurement/[id]/link.ts), [src/api/procurement/[id]/unlink.ts](src/api/procurement/[id]/unlink.ts) | `procurement.catalog.fetch` |
|
||||||
|
|
||||||
### ConnectWise Callback Routes
|
### ConnectWise Routes
|
||||||
|
|
||||||
|
`GET /v1/cw/members` requires only authentication (any logged-in user) and does **not** require a specific permission node.
|
||||||
|
|
||||||
`POST /v1/cw/callback/:secret/:resource` is intentionally unauthenticated for inbound ConnectWise callbacks and does **not** require a permission node.
|
`POST /v1/cw/callback/:secret/:resource` is intentionally unauthenticated for inbound ConnectWise callbacks and does **not** require a permission node.
|
||||||
|
|
||||||
| Permission Node | Description | Used In | Dependencies |
|
| Permission Node | Description | Used In | Dependencies |
|
||||||
| --------------- | ------------------------------------------------------------------------------- | ------------------------------------------------ | ------------ |
|
| --------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------ |
|
||||||
| _None_ | Inbound callback route; secured operationally (network controls / source trust) | [src/api/cw/callback.ts](src/api/cw/callback.ts) | N/A |
|
| _None_ | Fetch CW members (auth only) | [src/api/cw/fetchMembers.ts](src/api/cw/fetchMembers.ts) | N/A |
|
||||||
|
| _None_ | Inbound callback route; secured operationally (network controls / source trust) | [src/api/cw/callback.ts](src/api/cw/callback.ts) | N/A |
|
||||||
|
|
||||||
### Sales Permissions
|
### Sales Permissions
|
||||||
|
|
||||||
@@ -143,6 +146,8 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy
|
|||||||
| `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` | 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.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.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.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.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.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` |
|
||||||
@@ -155,6 +160,8 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy
|
|||||||
| `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.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.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.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` |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
|
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
|
||||||
|
|||||||
@@ -72,3 +72,8 @@ export type Credential = Prisma.CredentialModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
|
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
|
||||||
|
/**
|
||||||
|
* Model CwMember
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CwMember = Prisma.CwMemberModel
|
||||||
|
|||||||
@@ -94,3 +94,8 @@ export type Credential = Prisma.CredentialModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
|
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
|
||||||
|
/**
|
||||||
|
* Model CwMember
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type CwMember = Prisma.CwMemberModel
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -394,7 +394,8 @@ export const ModelName = {
|
|||||||
CredentialType: 'CredentialType',
|
CredentialType: 'CredentialType',
|
||||||
SecureValue: 'SecureValue',
|
SecureValue: 'SecureValue',
|
||||||
Credential: 'Credential',
|
Credential: 'Credential',
|
||||||
GeneratedQuotes: 'GeneratedQuotes'
|
GeneratedQuotes: 'GeneratedQuotes',
|
||||||
|
CwMember: 'CwMember'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||||
@@ -410,7 +411,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
omit: GlobalOmitOptions
|
omit: GlobalOmitOptions
|
||||||
}
|
}
|
||||||
meta: {
|
meta: {
|
||||||
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential" | "generatedQuotes"
|
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential" | "generatedQuotes" | "cwMember"
|
||||||
txIsolationLevel: TransactionIsolationLevel
|
txIsolationLevel: TransactionIsolationLevel
|
||||||
}
|
}
|
||||||
model: {
|
model: {
|
||||||
@@ -1228,6 +1229,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
CwMember: {
|
||||||
|
payload: Prisma.$CwMemberPayload<ExtArgs>
|
||||||
|
fields: Prisma.CwMemberFieldRefs
|
||||||
|
operations: {
|
||||||
|
findUnique: {
|
||||||
|
args: Prisma.CwMemberFindUniqueArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload> | null
|
||||||
|
}
|
||||||
|
findUniqueOrThrow: {
|
||||||
|
args: Prisma.CwMemberFindUniqueOrThrowArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
|
||||||
|
}
|
||||||
|
findFirst: {
|
||||||
|
args: Prisma.CwMemberFindFirstArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload> | null
|
||||||
|
}
|
||||||
|
findFirstOrThrow: {
|
||||||
|
args: Prisma.CwMemberFindFirstOrThrowArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
|
||||||
|
}
|
||||||
|
findMany: {
|
||||||
|
args: Prisma.CwMemberFindManyArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
|
||||||
|
}
|
||||||
|
create: {
|
||||||
|
args: Prisma.CwMemberCreateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
|
||||||
|
}
|
||||||
|
createMany: {
|
||||||
|
args: Prisma.CwMemberCreateManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
createManyAndReturn: {
|
||||||
|
args: Prisma.CwMemberCreateManyAndReturnArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
|
||||||
|
}
|
||||||
|
delete: {
|
||||||
|
args: Prisma.CwMemberDeleteArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
|
||||||
|
}
|
||||||
|
update: {
|
||||||
|
args: Prisma.CwMemberUpdateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
|
||||||
|
}
|
||||||
|
deleteMany: {
|
||||||
|
args: Prisma.CwMemberDeleteManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
updateMany: {
|
||||||
|
args: Prisma.CwMemberUpdateManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
updateManyAndReturn: {
|
||||||
|
args: Prisma.CwMemberUpdateManyAndReturnArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>[]
|
||||||
|
}
|
||||||
|
upsert: {
|
||||||
|
args: Prisma.CwMemberUpsertArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$CwMemberPayload>
|
||||||
|
}
|
||||||
|
aggregate: {
|
||||||
|
args: Prisma.CwMemberAggregateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.AggregateCwMember>
|
||||||
|
}
|
||||||
|
groupBy: {
|
||||||
|
args: Prisma.CwMemberGroupByArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.CwMemberGroupByOutputType>[]
|
||||||
|
}
|
||||||
|
count: {
|
||||||
|
args: Prisma.CwMemberCountArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.CwMemberCountAggregateOutputType> | number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} & {
|
} & {
|
||||||
other: {
|
other: {
|
||||||
@@ -1477,6 +1552,23 @@ export const GeneratedQuotesScalarFieldEnum = {
|
|||||||
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
|
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const CwMemberScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
cwMemberId: 'cwMemberId',
|
||||||
|
identifier: 'identifier',
|
||||||
|
firstName: 'firstName',
|
||||||
|
lastName: 'lastName',
|
||||||
|
officeEmail: 'officeEmail',
|
||||||
|
inactiveFlag: 'inactiveFlag',
|
||||||
|
apiKey: 'apiKey',
|
||||||
|
cwLastUpdated: 'cwLastUpdated',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type CwMemberScalarFieldEnum = (typeof CwMemberScalarFieldEnum)[keyof typeof CwMemberScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const SortOrder = {
|
export const SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@@ -1719,6 +1811,7 @@ export type GlobalOmitConfig = {
|
|||||||
secureValue?: Prisma.SecureValueOmit
|
secureValue?: Prisma.SecureValueOmit
|
||||||
credential?: Prisma.CredentialOmit
|
credential?: Prisma.CredentialOmit
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesOmit
|
generatedQuotes?: Prisma.GeneratedQuotesOmit
|
||||||
|
cwMember?: Prisma.CwMemberOmit
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Types for Logging */
|
/* Types for Logging */
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export const ModelName = {
|
|||||||
CredentialType: 'CredentialType',
|
CredentialType: 'CredentialType',
|
||||||
SecureValue: 'SecureValue',
|
SecureValue: 'SecureValue',
|
||||||
Credential: 'Credential',
|
Credential: 'Credential',
|
||||||
GeneratedQuotes: 'GeneratedQuotes'
|
GeneratedQuotes: 'GeneratedQuotes',
|
||||||
|
CwMember: 'CwMember'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||||
@@ -290,6 +291,23 @@ export const GeneratedQuotesScalarFieldEnum = {
|
|||||||
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
|
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const CwMemberScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
cwMemberId: 'cwMemberId',
|
||||||
|
identifier: 'identifier',
|
||||||
|
firstName: 'firstName',
|
||||||
|
lastName: 'lastName',
|
||||||
|
officeEmail: 'officeEmail',
|
||||||
|
inactiveFlag: 'inactiveFlag',
|
||||||
|
apiKey: 'apiKey',
|
||||||
|
cwLastUpdated: 'cwLastUpdated',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type CwMemberScalarFieldEnum = (typeof CwMemberScalarFieldEnum)[keyof typeof CwMemberScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const SortOrder = {
|
export const SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ export type * from './models/CredentialType.ts'
|
|||||||
export type * from './models/SecureValue.ts'
|
export type * from './models/SecureValue.ts'
|
||||||
export type * from './models/Credential.ts'
|
export type * from './models/Credential.ts'
|
||||||
export type * from './models/GeneratedQuotes.ts'
|
export type * from './models/GeneratedQuotes.ts'
|
||||||
|
export type * from './models/CwMember.ts'
|
||||||
export type * from './commonInputTypes.ts'
|
export type * from './commonInputTypes.ts'
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
|||||||
|
-- AlterTable: GeneratedQuotes — add columns missing from prior db push
|
||||||
|
ALTER TABLE "GeneratedQuotes" ADD COLUMN "quoteRegenParams" JSONB NOT NULL DEFAULT '{}';
|
||||||
|
ALTER TABLE "GeneratedQuotes" ADD COLUMN "quoteRegenHash" TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE "GeneratedQuotes" ADD COLUMN "downloads" JSONB NOT NULL DEFAULT '[]';
|
||||||
|
|
||||||
|
-- AlterTable: GeneratedQuotes — set default on existing quoteRegenData column
|
||||||
|
ALTER TABLE "GeneratedQuotes" ALTER COLUMN "quoteRegenData" SET DEFAULT '{}';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "GeneratedQuotes_quoteRegenHash_key" ON "GeneratedQuotes"("quoteRegenHash");
|
||||||
@@ -270,3 +270,20 @@ model GeneratedQuotes {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CwMember {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
cwMemberId Int @unique
|
||||||
|
identifier String @unique
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
officeEmail String?
|
||||||
|
inactiveFlag Boolean @default(false)
|
||||||
|
|
||||||
|
apiKey String?
|
||||||
|
|
||||||
|
cwLastUpdated DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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 { getMemberCache } from "../../modules/cw-utils/members/memberCache";
|
||||||
|
|
||||||
|
/* GET /v1/cw/members */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/members"],
|
||||||
|
async (c) => {
|
||||||
|
const cache = await getMemberCache();
|
||||||
|
|
||||||
|
const activeOnly = c.req.query("active") !== "false";
|
||||||
|
|
||||||
|
const members = cache
|
||||||
|
.filter((m) => (activeOnly ? !m.inactiveFlag : true))
|
||||||
|
.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
identifier: m.identifier,
|
||||||
|
firstName: m.firstName,
|
||||||
|
lastName: m.lastName,
|
||||||
|
name: `${m.firstName} ${m.lastName}`.trim(),
|
||||||
|
officeEmail: m.officeEmail,
|
||||||
|
inactive: m.inactiveFlag,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sorted = members.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"CW members fetched successfully!",
|
||||||
|
sorted,
|
||||||
|
);
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware(),
|
||||||
|
);
|
||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
import { default as callback } from "./callback";
|
import { default as callback } from "./callback";
|
||||||
|
import { default as fetchMembers } from "./fetchMembers";
|
||||||
|
|
||||||
export { callback };
|
export { callback, fetchMembers };
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { default as fetchAll } from "./opportunities/fetchAll";
|
import { default as fetchAll } from "./opportunities/fetchAll";
|
||||||
|
import { default as createOpportunity } from "./opportunities/create";
|
||||||
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
|
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
|
||||||
import { default as count } from "./opportunities/count";
|
import { default as count } from "./opportunities/count";
|
||||||
import { default as fetch } from "./opportunities/[id]/fetch";
|
import { default as fetch } from "./opportunities/[id]/fetch";
|
||||||
import { default as refresh } from "./opportunities/[id]/refresh";
|
import { default as refresh } from "./opportunities/[id]/refresh";
|
||||||
|
import { default as updateOpportunity } from "./opportunities/[id]/update";
|
||||||
import { default as products } from "./opportunities/[id]/products/fetchAll";
|
import { default as products } from "./opportunities/[id]/products/fetchAll";
|
||||||
import { default as addProduct } from "./opportunities/[id]/products/add";
|
import { default as addProduct } from "./opportunities/[id]/products/add";
|
||||||
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
|
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
|
||||||
@@ -29,6 +31,7 @@ export {
|
|||||||
laborOptions,
|
laborOptions,
|
||||||
addSpecialOrderProduct,
|
addSpecialOrderProduct,
|
||||||
count,
|
count,
|
||||||
|
createOpportunity,
|
||||||
fetch,
|
fetch,
|
||||||
fetchAll,
|
fetchAll,
|
||||||
fetchOpportunityTypes,
|
fetchOpportunityTypes,
|
||||||
@@ -48,4 +51,5 @@ export {
|
|||||||
downloadQuote,
|
downloadQuote,
|
||||||
fetchDownloads,
|
fetchDownloads,
|
||||||
refresh,
|
refresh,
|
||||||
|
updateOpportunity,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const updateSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
rating: z.object({ id: z.number() }).optional(),
|
||||||
|
type: z.object({ id: z.number() }).optional(),
|
||||||
|
stage: z.object({ id: z.number() }).optional(),
|
||||||
|
status: z.object({ id: z.number() }).optional(),
|
||||||
|
priority: z.object({ id: z.number() }).optional(),
|
||||||
|
campaign: z.object({ id: z.number() }).optional(),
|
||||||
|
primarySalesRep: z.object({ id: z.number() }).optional(),
|
||||||
|
secondarySalesRep: z.object({ id: z.number() }).nullable().optional(),
|
||||||
|
company: z.object({ id: z.number() }).optional(),
|
||||||
|
contact: z.object({ id: z.number() }).nullable().optional(),
|
||||||
|
site: z.object({ id: z.number() }).nullable().optional(),
|
||||||
|
expectedCloseDate: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => (v ? new Date(v).toISOString() : v)),
|
||||||
|
customerPO: z.string().nullable().optional(),
|
||||||
|
source: z.string().nullable().optional(),
|
||||||
|
locationId: z.number().optional(),
|
||||||
|
businessUnitId: z.number().optional(),
|
||||||
|
})
|
||||||
|
.refine((d) => Object.values(d).some((v) => v !== undefined), {
|
||||||
|
message: "At least one field must be provided",
|
||||||
|
});
|
||||||
|
|
||||||
|
/* PATCH /v1/sales/opportunities/:identifier */
|
||||||
|
export default createRoute(
|
||||||
|
"patch",
|
||||||
|
["/opportunities/:identifier"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const body = await c.req.json();
|
||||||
|
const data = updateSchema.parse(body);
|
||||||
|
|
||||||
|
const item = await opportunities.fetchRecord(identifier);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await item.updateOpportunity(data);
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Opportunity updated successfully!",
|
||||||
|
updated.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
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 cwData = axiosErr.response?.data;
|
||||||
|
const cwMessage: string =
|
||||||
|
cwData?.message ?? "Failed to update the opportunity in ConnectWise";
|
||||||
|
const cwErrors: unknown[] | undefined = Array.isArray(cwData?.errors)
|
||||||
|
? cwData.errors
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
status: cwStatus,
|
||||||
|
message: cwMessage,
|
||||||
|
error: "ConnectWiseUpdateError",
|
||||||
|
successful: false,
|
||||||
|
errors: cwErrors,
|
||||||
|
meta: { timestamp: Date.now() },
|
||||||
|
},
|
||||||
|
cwStatus as ContentfulStatusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new GenericError({
|
||||||
|
status: 500,
|
||||||
|
name: "OpportunitySaveError",
|
||||||
|
message: "Failed to save opportunity data",
|
||||||
|
cause: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.update"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
expectedCloseDate: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.transform((v) => new Date(v).toISOString().replace(/\.\d{3}Z$/, "Z")),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
rating: z.object({ id: z.number() }).optional(),
|
||||||
|
type: z.object({ id: z.number() }).optional(),
|
||||||
|
stage: z.object({ id: z.number() }).optional(),
|
||||||
|
status: z.object({ id: z.number() }).optional(),
|
||||||
|
priority: z.object({ id: z.number() }).optional(),
|
||||||
|
campaign: z.object({ id: z.number() }).optional(),
|
||||||
|
primarySalesRep: z.object({ id: z.number() }),
|
||||||
|
secondarySalesRep: z.object({ id: z.number() }).nullable().optional(),
|
||||||
|
company: z.object({ id: z.number() }),
|
||||||
|
contact: z.object({ id: z.number() }),
|
||||||
|
site: z.object({ id: z.number() }).nullable().optional(),
|
||||||
|
source: z.string().nullable().optional(),
|
||||||
|
customerPO: z.string().nullable().optional(),
|
||||||
|
locationId: z.number().optional(),
|
||||||
|
businessUnitId: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* POST /v1/sales/opportunities */
|
||||||
|
export default createRoute(
|
||||||
|
"post",
|
||||||
|
["/opportunities"],
|
||||||
|
async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const data = createSchema.parse(body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const item = await opportunities.createItem(data);
|
||||||
|
|
||||||
|
const response = apiResponse.created(
|
||||||
|
"Opportunity created successfully!",
|
||||||
|
item.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
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 cwData = axiosErr.response?.data;
|
||||||
|
const cwMessage: string =
|
||||||
|
cwData?.message ?? "Failed to create the opportunity in ConnectWise";
|
||||||
|
const cwErrors: unknown[] | undefined = Array.isArray(cwData?.errors)
|
||||||
|
? cwData.errors
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
status: cwStatus,
|
||||||
|
message: cwMessage,
|
||||||
|
error: "ConnectWiseCreateError",
|
||||||
|
successful: false,
|
||||||
|
errors: cwErrors,
|
||||||
|
meta: { timestamp: Date.now() },
|
||||||
|
},
|
||||||
|
cwStatus as ContentfulStatusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new GenericError({
|
||||||
|
status: 500,
|
||||||
|
name: "OpportunityCreateError",
|
||||||
|
message: "Failed to create opportunity",
|
||||||
|
cause: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.create"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { CwMember } from "../../generated/prisma/client";
|
||||||
|
import type { CWMember } from "../modules/cw-utils/members/fetchAllMembers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CW Member Controller
|
||||||
|
*
|
||||||
|
* Domain model class that encapsulates a ConnectWise Member entity,
|
||||||
|
* providing access to member data and serialization for the API.
|
||||||
|
*/
|
||||||
|
export class CwMemberController {
|
||||||
|
public readonly id: string;
|
||||||
|
public readonly cwMemberId: number;
|
||||||
|
public readonly identifier: string;
|
||||||
|
public firstName: string;
|
||||||
|
public lastName: string;
|
||||||
|
public officeEmail: string | null;
|
||||||
|
public inactiveFlag: boolean;
|
||||||
|
public apiKey: string | null;
|
||||||
|
public cwLastUpdated: Date | null;
|
||||||
|
public readonly createdAt: Date;
|
||||||
|
public readonly updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(data: CwMember) {
|
||||||
|
this.id = data.id;
|
||||||
|
this.cwMemberId = data.cwMemberId;
|
||||||
|
this.identifier = data.identifier;
|
||||||
|
this.firstName = data.firstName;
|
||||||
|
this.lastName = data.lastName;
|
||||||
|
this.officeEmail = data.officeEmail;
|
||||||
|
this.inactiveFlag = data.inactiveFlag;
|
||||||
|
this.apiKey = data.apiKey;
|
||||||
|
this.cwLastUpdated = data.cwLastUpdated;
|
||||||
|
this.createdAt = data.createdAt;
|
||||||
|
this.updatedAt = data.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full Name
|
||||||
|
*
|
||||||
|
* Returns the member's full name, falling back to the identifier.
|
||||||
|
*/
|
||||||
|
public get fullName(): string {
|
||||||
|
const name = `${this.firstName} ${this.lastName}`.trim();
|
||||||
|
return name || this.identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map CW Member → Prisma create/update payload
|
||||||
|
*
|
||||||
|
* Static helper used by both the controller and the refresh sync.
|
||||||
|
*/
|
||||||
|
public static mapCwToDb(item: CWMember) {
|
||||||
|
return {
|
||||||
|
identifier: item.identifier,
|
||||||
|
firstName: item.firstName ?? "",
|
||||||
|
lastName: item.lastName ?? "",
|
||||||
|
officeEmail: item.officeEmail ?? null,
|
||||||
|
inactiveFlag: item.inactiveFlag ?? false,
|
||||||
|
cwLastUpdated: item._info?.lastUpdated
|
||||||
|
? new Date(item._info.lastUpdated)
|
||||||
|
: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To JSON
|
||||||
|
*
|
||||||
|
* Serializes the member into a safe, API-friendly object.
|
||||||
|
*/
|
||||||
|
public toJson(): Record<string, any> {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
cwMemberId: this.cwMemberId,
|
||||||
|
identifier: this.identifier,
|
||||||
|
firstName: this.firstName,
|
||||||
|
lastName: this.lastName,
|
||||||
|
fullName: this.fullName,
|
||||||
|
officeEmail: this.officeEmail,
|
||||||
|
inactiveFlag: this.inactiveFlag,
|
||||||
|
apiKey: this.apiKey,
|
||||||
|
cwLastUpdated: this.cwLastUpdated,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
updatedAt: this.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
CWForecastItemCreate,
|
CWForecastItemCreate,
|
||||||
CWOpportunity,
|
CWOpportunity,
|
||||||
CWOpportunityNote,
|
CWOpportunityNote,
|
||||||
|
CWOpportunityUpdate,
|
||||||
CWProcurementProduct,
|
CWProcurementProduct,
|
||||||
CWProcurementProductCreate,
|
CWProcurementProductCreate,
|
||||||
} from "../modules/cw-utils/opportunities/opportunity.types";
|
} from "../modules/cw-utils/opportunities/opportunity.types";
|
||||||
@@ -292,6 +293,30 @@ export class OpportunityController {
|
|||||||
return new OpportunityController(updated);
|
return new OpportunityController(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Opportunity
|
||||||
|
*
|
||||||
|
* Patches the opportunity in ConnectWise with the provided fields,
|
||||||
|
* then syncs the updated data back to the local database.
|
||||||
|
*
|
||||||
|
* @param data — Partial fields to update on the CW opportunity
|
||||||
|
* @returns A fresh OpportunityController with the updated data
|
||||||
|
*/
|
||||||
|
public async updateOpportunity(
|
||||||
|
data: CWOpportunityUpdate,
|
||||||
|
): Promise<OpportunityController> {
|
||||||
|
const cwData = await opportunityCw.update(this.cwOpportunityId, data);
|
||||||
|
const mapped = OpportunityController.mapCwToDb(cwData);
|
||||||
|
|
||||||
|
const updated = await prisma.opportunity.update({
|
||||||
|
where: { id: this.id },
|
||||||
|
data: mapped,
|
||||||
|
include: { company: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new OpportunityController(updated);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch raw CW data
|
* Fetch raw CW data
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { listenInventoryAdjustments } from "./modules/cw-utils/procurement/liste
|
|||||||
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
|
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
|
||||||
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
|
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
|
||||||
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
|
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
|
||||||
|
import { refreshCwMembers } from "./modules/cw-utils/members/refreshCwMembers";
|
||||||
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
|
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
|
||||||
import { events, setupEventDebugger } from "./modules/globalEvents";
|
import { events, setupEventDebugger } from "./modules/globalEvents";
|
||||||
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
||||||
@@ -188,6 +189,17 @@ setInterval(
|
|||||||
30 * 60 * 1000,
|
30 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Refresh CW members DB table every hour
|
||||||
|
await safeStartup("refreshCwMembers", refreshCwMembers);
|
||||||
|
setInterval(
|
||||||
|
() => {
|
||||||
|
return refreshCwMembers().catch((err) =>
|
||||||
|
console.error(`[interval] refreshCwMembers failed: ${briefErr(err)}`),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
await safeStartup("syncSites", () => unifiSites.syncSites());
|
await safeStartup("syncSites", () => unifiSites.syncSites());
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
return unifiSites
|
return unifiSites
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { prisma } from "../constants";
|
||||||
|
import { CwMemberController } from "../controllers/CwMemberController";
|
||||||
|
import GenericError from "../Errors/GenericError";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CW Members Manager
|
||||||
|
*
|
||||||
|
* Thin persistence layer wrapping Prisma calls for the CwMember model.
|
||||||
|
* Returns CwMemberController instances as domain objects.
|
||||||
|
*/
|
||||||
|
export const cwMembers = {
|
||||||
|
/**
|
||||||
|
* Fetch a single CW member by internal ID, CW member ID, or identifier.
|
||||||
|
*/
|
||||||
|
fetch: async (idOrIdentifier: string): Promise<CwMemberController> => {
|
||||||
|
const isNumeric = /^\d+$/.test(idOrIdentifier);
|
||||||
|
|
||||||
|
const record = await prisma.cwMember.findFirst({
|
||||||
|
where: isNumeric
|
||||||
|
? { cwMemberId: Number(idOrIdentifier) }
|
||||||
|
: {
|
||||||
|
OR: [{ id: idOrIdentifier }, { identifier: idOrIdentifier }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new GenericError({
|
||||||
|
status: 404,
|
||||||
|
name: "CwMemberNotFound",
|
||||||
|
message: `CW Member "${idOrIdentifier}" not found`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CwMemberController(record);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all CW members with optional filtering.
|
||||||
|
*/
|
||||||
|
fetchAll: async (opts?: {
|
||||||
|
includeInactive?: boolean;
|
||||||
|
}): Promise<CwMemberController[]> => {
|
||||||
|
const where = opts?.includeInactive ? {} : { inactiveFlag: false };
|
||||||
|
|
||||||
|
const records = await prisma.cwMember.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { lastName: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((r) => new CwMemberController(r));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count CW members.
|
||||||
|
*/
|
||||||
|
count: async (opts?: { includeInactive?: boolean }): Promise<number> => {
|
||||||
|
const where = opts?.includeInactive ? {} : { inactiveFlag: false };
|
||||||
|
return prisma.cwMember.count({ where });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the API key for a CW member.
|
||||||
|
*/
|
||||||
|
updateApiKey: async (
|
||||||
|
idOrIdentifier: string,
|
||||||
|
apiKey: string | null,
|
||||||
|
): Promise<CwMemberController> => {
|
||||||
|
const member = await cwMembers.fetch(idOrIdentifier);
|
||||||
|
|
||||||
|
const updated = await prisma.cwMember.update({
|
||||||
|
where: { id: member.id },
|
||||||
|
data: { apiKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new CwMemberController(updated);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { OpportunityController } from "../controllers/OpportunityController";
|
|||||||
import GenericError from "../Errors/GenericError";
|
import GenericError from "../Errors/GenericError";
|
||||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||||
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
||||||
|
import { CWOpportunityCreate } from "../modules/cw-utils/opportunities/opportunity.types";
|
||||||
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
|
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
|
||||||
import {
|
import {
|
||||||
getCachedActivities,
|
getCachedActivities,
|
||||||
@@ -127,6 +128,45 @@ async function buildActivities(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const opportunities = {
|
export const opportunities = {
|
||||||
|
/**
|
||||||
|
* Create Opportunity
|
||||||
|
*
|
||||||
|
* Creates a new opportunity in ConnectWise, then stores the resulting
|
||||||
|
* record in the local database and returns an OpportunityController.
|
||||||
|
*
|
||||||
|
* @param data — Fields required by the ConnectWise `POST /sales/opportunities` endpoint
|
||||||
|
* @returns {Promise<OpportunityController>}
|
||||||
|
*/
|
||||||
|
async createItem(data: CWOpportunityCreate): Promise<OpportunityController> {
|
||||||
|
const cwData = await opportunityCw.create(data);
|
||||||
|
const mapped = OpportunityController.mapCwToDb(cwData);
|
||||||
|
|
||||||
|
// Resolve optional local company relation
|
||||||
|
const companyId = cwData.company?.id
|
||||||
|
? ((
|
||||||
|
await prisma.company.findFirst({
|
||||||
|
where: { cw_CompanyId: cwData.company.id },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
)?.id ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const record = await prisma.opportunity.create({
|
||||||
|
data: {
|
||||||
|
cwOpportunityId: cwData.id,
|
||||||
|
...mapped,
|
||||||
|
companyId,
|
||||||
|
},
|
||||||
|
include: { company: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new OpportunityController(record, {
|
||||||
|
company: record.company
|
||||||
|
? new CompanyController(record.company)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch Record (lightweight)
|
* Fetch Record (lightweight)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -17,20 +17,26 @@ export interface CWMember {
|
|||||||
* Fetches every member from ConnectWise using pagination and returns them
|
* Fetches every member from ConnectWise using pagination and returns them
|
||||||
* in a Collection keyed by their identifier (e.g. "jroberts").
|
* in a Collection keyed by their identifier (e.g. "jroberts").
|
||||||
*
|
*
|
||||||
|
* @param opts.conditions - Optional CW conditions string to filter members
|
||||||
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
|
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
|
||||||
*/
|
*/
|
||||||
export const fetchAllCwMembers = async (): Promise<
|
export const fetchAllCwMembers = async (opts?: {
|
||||||
Collection<string, CWMember>
|
conditions?: string;
|
||||||
> => {
|
}): Promise<Collection<string, CWMember>> => {
|
||||||
const members = new Collection<string, CWMember>();
|
const members = new Collection<string, CWMember>();
|
||||||
const pageSize = 1000;
|
const pageSize = 1000;
|
||||||
|
const conditionsParam = opts?.conditions
|
||||||
|
? `&conditions=${encodeURIComponent(opts.conditions)}`
|
||||||
|
: "";
|
||||||
|
|
||||||
const { data: countData } = await connectWiseApi.get("/system/members/count");
|
const { data: countData } = await connectWiseApi.get(
|
||||||
|
`/system/members/count${conditionsParam ? `?${conditionsParam.slice(1)}` : ""}`,
|
||||||
|
);
|
||||||
const totalPages = Math.ceil(countData.count / pageSize);
|
const totalPages = Math.ceil(countData.count / pageSize);
|
||||||
|
|
||||||
for (let page = 0; page < totalPages; page++) {
|
for (let page = 0; page < totalPages; page++) {
|
||||||
const { data } = await connectWiseApi.get<CWMember[]>(
|
const { data } = await connectWiseApi.get<CWMember[]>(
|
||||||
`/system/members?page=${page + 1}&pageSize=${pageSize}`,
|
`/system/members?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const member of data) {
|
for (const member of data) {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { prisma } from "../../../constants";
|
||||||
|
import { events } from "../../globalEvents";
|
||||||
|
import { fetchAllCwMembers, type CWMember } from "./fetchAllMembers";
|
||||||
|
import { setMemberCache } from "./memberCache";
|
||||||
|
import { CwMemberController } from "../../../controllers/CwMemberController";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is Regular User
|
||||||
|
*
|
||||||
|
* Returns true if the CW member looks like a real person rather than
|
||||||
|
* a service account (e.g. "labtech", "Admin"). A regular user must
|
||||||
|
* have a last name and an email address.
|
||||||
|
*/
|
||||||
|
const isRegularUser = (member: CWMember): boolean =>
|
||||||
|
!member.inactiveFlag &&
|
||||||
|
Boolean(member.lastName?.trim()) &&
|
||||||
|
Boolean(member.officeEmail?.trim());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh CW Members
|
||||||
|
*
|
||||||
|
* Syncs local CwMember records with ConnectWise using a stale-check
|
||||||
|
* pattern:
|
||||||
|
* 1. Fetch all members from CW
|
||||||
|
* 2. Filter to regular users (active, non-service accounts)
|
||||||
|
* 3. Compare against local cwLastUpdated timestamps
|
||||||
|
* 4. Upsert stale/new records
|
||||||
|
* 5. Also refreshes the in-memory member cache
|
||||||
|
*/
|
||||||
|
export const refreshCwMembers = async () => {
|
||||||
|
events.emit("cw:members:db:refresh:check");
|
||||||
|
|
||||||
|
// 1. Fetch all members from CW
|
||||||
|
const allCwMembers = await fetchAllCwMembers();
|
||||||
|
|
||||||
|
// Also refresh the in-memory cache with ALL members (used for name resolution)
|
||||||
|
await setMemberCache(allCwMembers);
|
||||||
|
|
||||||
|
// 2. Filter to regular users only (active, has last name + email)
|
||||||
|
const cwMembers = allCwMembers.filter(isRegularUser);
|
||||||
|
|
||||||
|
// 2. Fetch all DB records with their identifier and cwLastUpdated
|
||||||
|
const dbItems = await prisma.cwMember.findMany({
|
||||||
|
select: { cwMemberId: true, cwLastUpdated: true },
|
||||||
|
});
|
||||||
|
const dbMap = new Map(
|
||||||
|
dbItems.map((item) => [item.cwMemberId, item.cwLastUpdated]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Determine stale / new IDs
|
||||||
|
const staleIds: number[] = [];
|
||||||
|
|
||||||
|
for (const [, member] of cwMembers) {
|
||||||
|
const cwLastUpdated = member._info?.lastUpdated
|
||||||
|
? new Date(member._info.lastUpdated)
|
||||||
|
: null;
|
||||||
|
const dbLastUpdated = dbMap.get(member.id) ?? null;
|
||||||
|
|
||||||
|
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
|
||||||
|
staleIds.push(member.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staleIds.length === 0) {
|
||||||
|
events.emit("cw:members:db:refresh:skipped", {
|
||||||
|
totalCw: cwMembers.size,
|
||||||
|
totalDb: dbItems.length,
|
||||||
|
staleCount: 0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.emit("cw:members:db:refresh:started", {
|
||||||
|
totalCw: cwMembers.size,
|
||||||
|
totalDb: dbItems.length,
|
||||||
|
staleCount: staleIds.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Upsert stale/new items
|
||||||
|
const staleIdSet = new Set(staleIds);
|
||||||
|
const updatedCount = (
|
||||||
|
await Promise.all(
|
||||||
|
[...cwMembers.values()]
|
||||||
|
.filter((m) => staleIdSet.has(m.id))
|
||||||
|
.map(async (member) => {
|
||||||
|
const mapped = CwMemberController.mapCwToDb(member);
|
||||||
|
|
||||||
|
return prisma.cwMember.upsert({
|
||||||
|
where: { cwMemberId: member.id },
|
||||||
|
create: {
|
||||||
|
cwMemberId: member.id,
|
||||||
|
...mapped,
|
||||||
|
},
|
||||||
|
update: mapped,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter(Boolean).length;
|
||||||
|
|
||||||
|
events.emit("cw:members:db:refresh:completed", {
|
||||||
|
totalCw: cwMembers.size,
|
||||||
|
totalDb: dbItems.length,
|
||||||
|
staleCount: staleIds.length,
|
||||||
|
itemsUpdated: updatedCount,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import { Collection } from "@discordjs/collection";
|
|||||||
import { connectWiseApi } from "../../../constants";
|
import { connectWiseApi } from "../../../constants";
|
||||||
import {
|
import {
|
||||||
CWOpportunity,
|
CWOpportunity,
|
||||||
|
CWOpportunityCreate,
|
||||||
CWOpportunitySummary,
|
CWOpportunitySummary,
|
||||||
CWForecast,
|
CWForecast,
|
||||||
CWForecastItem,
|
CWForecastItem,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
CWOpportunityNoteCreate,
|
CWOpportunityNoteCreate,
|
||||||
CWOpportunityNoteUpdate,
|
CWOpportunityNoteUpdate,
|
||||||
CWOpportunityContact,
|
CWOpportunityContact,
|
||||||
|
CWOpportunityUpdate,
|
||||||
} from "./opportunity.types";
|
} from "./opportunity.types";
|
||||||
|
|
||||||
export const opportunityCw = {
|
export const opportunityCw = {
|
||||||
@@ -100,6 +102,45 @@ export const opportunityCw = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Opportunity
|
||||||
|
*
|
||||||
|
* Creates a new opportunity in ConnectWise via POST.
|
||||||
|
* Strips null/undefined values from the payload — CW rejects
|
||||||
|
* null reference objects on create; omitting them lets CW apply
|
||||||
|
* its own defaults.
|
||||||
|
*/
|
||||||
|
create: async (data: CWOpportunityCreate): Promise<CWOpportunity> => {
|
||||||
|
const cleaned = Object.fromEntries(
|
||||||
|
Object.entries(data).filter(([, v]) => v != null),
|
||||||
|
);
|
||||||
|
const response = await connectWiseApi.post("/sales/opportunities", cleaned);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Opportunity
|
||||||
|
*
|
||||||
|
* Applies a JSON Patch update to an opportunity record in ConnectWise.
|
||||||
|
* Each key in `data` produces a replace operation.
|
||||||
|
*/
|
||||||
|
update: async (
|
||||||
|
opportunityId: number,
|
||||||
|
data: CWOpportunityUpdate,
|
||||||
|
): Promise<CWOpportunity> => {
|
||||||
|
const operations = Object.entries(data).map(([key, value]) => ({
|
||||||
|
op: "replace" as const,
|
||||||
|
path: key,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await connectWiseApi.patch(
|
||||||
|
`/sales/opportunities/${opportunityId}`,
|
||||||
|
operations,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch Opportunities by Company
|
* Fetch Opportunities by Company
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -263,6 +263,48 @@ export interface CWProcurementProduct {
|
|||||||
_info?: Record<string, string>;
|
_info?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CWOpportunityUpdate {
|
||||||
|
name?: string;
|
||||||
|
notes?: string;
|
||||||
|
rating?: { id: number };
|
||||||
|
type?: { id: number };
|
||||||
|
stage?: { id: number };
|
||||||
|
status?: { id: number };
|
||||||
|
priority?: { id: number };
|
||||||
|
campaign?: { id: number };
|
||||||
|
primarySalesRep?: { id: number };
|
||||||
|
secondarySalesRep?: { id: number } | null;
|
||||||
|
company?: { id: number };
|
||||||
|
contact?: { id: number } | null;
|
||||||
|
site?: { id: number } | null;
|
||||||
|
expectedCloseDate?: string;
|
||||||
|
customerPO?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
locationId?: number;
|
||||||
|
businessUnitId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CWOpportunityCreate {
|
||||||
|
name: string;
|
||||||
|
expectedCloseDate: string;
|
||||||
|
primarySalesRep: { id: number };
|
||||||
|
company: { id: number };
|
||||||
|
contact: { id: number };
|
||||||
|
type?: { id: number };
|
||||||
|
stage?: { id: number };
|
||||||
|
status?: { id: number };
|
||||||
|
priority?: { id: number };
|
||||||
|
campaign?: { id: number };
|
||||||
|
secondarySalesRep?: { id: number } | null;
|
||||||
|
site?: { id: number } | null;
|
||||||
|
notes?: string;
|
||||||
|
rating?: { id: number };
|
||||||
|
source?: string | null;
|
||||||
|
customerPO?: string | null;
|
||||||
|
locationId?: number;
|
||||||
|
businessUnitId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CWOpportunitySummary {
|
export interface CWOpportunitySummary {
|
||||||
id: number;
|
id: number;
|
||||||
_info?: Record<string, string>;
|
_info?: Record<string, string>;
|
||||||
|
|||||||
@@ -205,6 +205,25 @@ interface EventTypes {
|
|||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
usersUpdated: number;
|
usersUpdated: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
|
// ConnectWise Members DB Sync Events
|
||||||
|
"cw:members:db:refresh:check": () => void;
|
||||||
|
"cw:members:db:refresh:started": (data: {
|
||||||
|
totalCw: number;
|
||||||
|
totalDb: number;
|
||||||
|
staleCount: number;
|
||||||
|
}) => void;
|
||||||
|
"cw:members:db:refresh:completed": (data: {
|
||||||
|
totalCw: number;
|
||||||
|
totalDb: number;
|
||||||
|
staleCount: number;
|
||||||
|
itemsUpdated: number;
|
||||||
|
}) => void;
|
||||||
|
"cw:members:db:refresh:skipped": (data: {
|
||||||
|
totalCw: number;
|
||||||
|
totalDb: number;
|
||||||
|
staleCount: number;
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const events = new Eventra<EventTypes>();
|
export const events = new Eventra<EventTypes>();
|
||||||
|
|||||||
@@ -423,6 +423,18 @@ export const PERMISSION_NODES = {
|
|||||||
usedIn: ["src/api/sales/opportunities/[id]/refresh.ts"],
|
usedIn: ["src/api/sales/opportunities/[id]/refresh.ts"],
|
||||||
dependencies: ["sales.opportunity.fetch"],
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.update",
|
||||||
|
description:
|
||||||
|
"Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise",
|
||||||
|
usedIn: ["src/api/sales/opportunities/[id]/update.ts"],
|
||||||
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.create",
|
||||||
|
description: "Create a new opportunity in ConnectWise",
|
||||||
|
usedIn: ["src/api/sales/opportunities/create.ts"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
node: "sales.opportunity.note.create",
|
node: "sales.opportunity.note.create",
|
||||||
description: "Create a new note on an opportunity",
|
description: "Create a new note on an opportunity",
|
||||||
@@ -531,6 +543,20 @@ export const PERMISSION_NODES = {
|
|||||||
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts"],
|
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts"],
|
||||||
dependencies: ["sales.opportunity.fetch"],
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.view_margin",
|
||||||
|
description:
|
||||||
|
"View margin and markup data on opportunity products. Controls visibility of margin %, markup %, and related progress bars in the UI.",
|
||||||
|
usedIn: [],
|
||||||
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.view_cost",
|
||||||
|
description:
|
||||||
|
"View cost data on opportunity products. Controls visibility of unit cost, total cost, and recurring cost in the UI.",
|
||||||
|
usedIn: [],
|
||||||
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user