Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0a4d4f919 | |||
| 0ce1eda606 | |||
| 6c310ed753 |
+276
-1
@@ -137,7 +137,46 @@ See [PERMISSIONS.md](PERMISSIONS.md) for the full list of field-level permission
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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** `/sales/opportunities/:identifier/products`
|
||||
|
||||
+9
-2
@@ -124,12 +124,15 @@ 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.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.
|
||||
|
||||
| Permission Node | Description | Used In | Dependencies |
|
||||
| --------------- | ------------------------------------------------------------------------------- | ------------------------------------------------ | ------------ |
|
||||
| --------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------ |
|
||||
| _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
|
||||
@@ -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.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` |
|
||||
@@ -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.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` |
|
||||
|
||||
<details>
|
||||
<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
|
||||
/**
|
||||
* Model CwMember
|
||||
*
|
||||
*/
|
||||
export type CwMember = Prisma.CwMemberModel
|
||||
|
||||
@@ -94,3 +94,8 @@ export type Credential = Prisma.CredentialModel
|
||||
*
|
||||
*/
|
||||
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',
|
||||
SecureValue: 'SecureValue',
|
||||
Credential: 'Credential',
|
||||
GeneratedQuotes: 'GeneratedQuotes'
|
||||
GeneratedQuotes: 'GeneratedQuotes',
|
||||
CwMember: 'CwMember'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -410,7 +411,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
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
|
||||
}
|
||||
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: {
|
||||
@@ -1477,6 +1552,23 @@ export const 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 = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
@@ -1719,6 +1811,7 @@ export type GlobalOmitConfig = {
|
||||
secureValue?: Prisma.SecureValueOmit
|
||||
credential?: Prisma.CredentialOmit
|
||||
generatedQuotes?: Prisma.GeneratedQuotesOmit
|
||||
cwMember?: Prisma.CwMemberOmit
|
||||
}
|
||||
|
||||
/* Types for Logging */
|
||||
|
||||
@@ -61,7 +61,8 @@ export const ModelName = {
|
||||
CredentialType: 'CredentialType',
|
||||
SecureValue: 'SecureValue',
|
||||
Credential: 'Credential',
|
||||
GeneratedQuotes: 'GeneratedQuotes'
|
||||
GeneratedQuotes: 'GeneratedQuotes',
|
||||
CwMember: 'CwMember'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
@@ -290,6 +291,23 @@ export const 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 = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
|
||||
@@ -19,4 +19,5 @@ export type * from './models/CredentialType.ts'
|
||||
export type * from './models/SecureValue.ts'
|
||||
export type * from './models/Credential.ts'
|
||||
export type * from './models/GeneratedQuotes.ts'
|
||||
export type * from './models/CwMember.ts'
|
||||
export type * from './commonInputTypes.ts'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: Opportunity
|
||||
ALTER TABLE "Opportunity" ADD COLUMN "probability" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
@@ -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())
|
||||
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 fetchMembers } from "./fetchMembers";
|
||||
|
||||
export { callback };
|
||||
export { callback, fetchMembers };
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { default as fetchAll } from "./opportunities/fetchAll";
|
||||
import { default as createOpportunity } from "./opportunities/create";
|
||||
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
|
||||
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 products } from "./opportunities/[id]/products/fetchAll";
|
||||
import { default as addProduct } from "./opportunities/[id]/products/add";
|
||||
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
|
||||
@@ -29,6 +31,7 @@ export {
|
||||
laborOptions,
|
||||
addSpecialOrderProduct,
|
||||
count,
|
||||
createOpportunity,
|
||||
fetch,
|
||||
fetchAll,
|
||||
fetchOpportunityTypes,
|
||||
@@ -48,4 +51,5 @@ export {
|
||||
downloadQuote,
|
||||
fetchDownloads,
|
||||
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,
|
||||
CWOpportunity,
|
||||
CWOpportunityNote,
|
||||
CWOpportunityUpdate,
|
||||
CWProcurementProduct,
|
||||
CWProcurementProductCreate,
|
||||
} from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
@@ -292,6 +293,30 @@ export class OpportunityController {
|
||||
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
|
||||
*
|
||||
|
||||
@@ -17,6 +17,7 @@ import { listenInventoryAdjustments } from "./modules/cw-utils/procurement/liste
|
||||
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
|
||||
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
|
||||
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
|
||||
import { refreshCwMembers } from "./modules/cw-utils/members/refreshCwMembers";
|
||||
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
|
||||
import { events, setupEventDebugger } from "./modules/globalEvents";
|
||||
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
||||
@@ -188,6 +189,17 @@ setInterval(
|
||||
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());
|
||||
setInterval(() => {
|
||||
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 { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
||||
import { CWOpportunityCreate } from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
|
||||
import {
|
||||
getCachedActivities,
|
||||
@@ -127,6 +128,45 @@ async function buildActivities(
|
||||
}
|
||||
|
||||
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)
|
||||
*
|
||||
|
||||
@@ -17,20 +17,26 @@ export interface CWMember {
|
||||
* Fetches every member from ConnectWise using pagination and returns them
|
||||
* 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
|
||||
*/
|
||||
export const fetchAllCwMembers = async (): Promise<
|
||||
Collection<string, CWMember>
|
||||
> => {
|
||||
export const fetchAllCwMembers = async (opts?: {
|
||||
conditions?: string;
|
||||
}): Promise<Collection<string, CWMember>> => {
|
||||
const members = new Collection<string, CWMember>();
|
||||
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);
|
||||
|
||||
for (let page = 0; page < totalPages; page++) {
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
CWOpportunity,
|
||||
CWOpportunityCreate,
|
||||
CWOpportunitySummary,
|
||||
CWForecast,
|
||||
CWForecastItem,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
CWOpportunityNoteCreate,
|
||||
CWOpportunityNoteUpdate,
|
||||
CWOpportunityContact,
|
||||
CWOpportunityUpdate,
|
||||
} from "./opportunity.types";
|
||||
|
||||
export const opportunityCw = {
|
||||
@@ -100,6 +102,45 @@ export const opportunityCw = {
|
||||
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
|
||||
*
|
||||
|
||||
@@ -263,6 +263,48 @@ export interface CWProcurementProduct {
|
||||
_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 {
|
||||
id: number;
|
||||
_info?: Record<string, string>;
|
||||
|
||||
@@ -205,6 +205,25 @@ interface EventTypes {
|
||||
totalUsers: number;
|
||||
usersUpdated: number;
|
||||
}) => 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>();
|
||||
|
||||
@@ -423,6 +423,18 @@ export const PERMISSION_NODES = {
|
||||
usedIn: ["src/api/sales/opportunities/[id]/refresh.ts"],
|
||||
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",
|
||||
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"],
|
||||
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