feat: restructure sales, add PDF quote generation and WebSocket support
This commit is contained in:
+407
-1
@@ -8,6 +8,111 @@ This document provides a comprehensive overview of all API routes available in t
|
||||
http://localhost:3000/v1
|
||||
```
|
||||
|
||||
## WebSocket Base URL
|
||||
|
||||
```
|
||||
ws://localhost:3000/socket.io/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
### Secure Namespace
|
||||
|
||||
**Namespace:** `/secure`
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
Provide authorization during Socket.IO handshake using one of:
|
||||
|
||||
- `handshake.headers.authorization` as `Bearer <accessToken>` or `Key <accessToken>`
|
||||
- `handshake.auth.authorization` as `Bearer <accessToken>` or `Key <accessToken>`
|
||||
- `handshake.auth.token` as raw access token
|
||||
|
||||
When connected, server emits `secure:connected` with `{ userId }`.
|
||||
|
||||
If the linked session expires, is invalidated, or no longer exists, server emits `secure:session:expired` and disconnects.
|
||||
|
||||
### Register Live Quote Preview Channel
|
||||
|
||||
**Client → Server Event:** `opp:live_quote_preview`
|
||||
|
||||
Registers a per-opportunity live preview channel.
|
||||
|
||||
**Required Permissions:** `sales.opportunity.fetch`
|
||||
|
||||
**Payload:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<opportunity-id>"
|
||||
}
|
||||
```
|
||||
|
||||
**Ack Response (success):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"event": "opp:live_quote_preview:<id>:data"
|
||||
}
|
||||
```
|
||||
|
||||
**Ack Response (error):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "Missing opportunity id"
|
||||
}
|
||||
```
|
||||
|
||||
Server may also emit `opp:live_quote_preview:ready` and `opp:live_quote_preview:error`.
|
||||
|
||||
### Live Quote Preview Data Channel
|
||||
|
||||
**Client → Server Event:** `opp:live_quote_preview:<id>:data`
|
||||
|
||||
After registration, clients send live quote preview options on this dynamic event.
|
||||
|
||||
The server will:
|
||||
|
||||
1. Relay the incoming `...:data` payload to other sockets in the same preview room.
|
||||
2. Fetch the target opportunity.
|
||||
3. Generate a preview PDF using `generateQuote` with `showPreview: true`.
|
||||
4. Emit the generated preview on `opp:live_quote_preview:<id>:preview`.
|
||||
|
||||
**Payload (all optional):**
|
||||
|
||||
```json
|
||||
{
|
||||
"lineItemPricing": true,
|
||||
"includeQuoteNarrative": true,
|
||||
"includeItemNarratives": true,
|
||||
"logoPath": "/absolute/or/runtime/path/to/logo.png"
|
||||
}
|
||||
```
|
||||
|
||||
**Server → Client Event:** `opp:live_quote_preview:<id>:preview`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<opportunity-id>",
|
||||
"mimeType": "application/pdf",
|
||||
"contentBase64": "JVBERi0xLjQKJ..."
|
||||
}
|
||||
```
|
||||
|
||||
**Server → Client Event (error):** `opp:live_quote_preview:error`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<opportunity-id>",
|
||||
"message": "Failed to generate live quote preview"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Object Type Field-Level Gating
|
||||
@@ -2874,6 +2979,7 @@ Fetch a paginated list of opportunities. Supports search. Each opportunity inclu
|
||||
"site": { "id": 50, "name": "Main Office" },
|
||||
"customerPO": null,
|
||||
"totalSalesTax": 0,
|
||||
"probability": 50,
|
||||
"location": { "id": 1, "name": "Murray" },
|
||||
"department": { "id": 5, "name": "Sales" },
|
||||
"expectedCloseDate": "2026-04-15T00:00:00.000Z",
|
||||
@@ -2988,7 +3094,9 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `include` _(optional)_ — Comma-separated list of sub-resources to embed in the response. Supported values: `notes`, `contacts`, `products`. Example: `?include=notes,contacts,products`. Sub-resources are fetched in parallel and added as top-level keys on the response object. When `notes` is included, `data.notes` is returned as an array of note objects and the original opportunity text note is preserved under `data.opportunityNoteText`.
|
||||
- `include` _(optional)_ — Comma-separated list of sub-resources to embed in the response. Supported values: `notes`, `contacts`, `products`, `quotes`. Example: `?include=notes,contacts,products,quotes`. Sub-resources are fetched in parallel and added as top-level keys on the response object. When `notes` is included, `data.notes` is returned as an array of note objects and the original opportunity text note is preserved under `data.opportunityNoteText`. When `quotes` is included, `data.quotes` is returned as an array of committed quote metadata objects (PDF file data is excluded).
|
||||
- `includeRegenData` _(optional)_ — When `"true"`, includes `quoteRegenData` on each quote object (applies to `?include=quotes`). Default: `false`.
|
||||
- `includeRegenParams` _(optional)_ — When `"true"`, includes `quoteRegenParams` on each quote object (applies to `?include=quotes`). Default: `false`.
|
||||
|
||||
**Response:**
|
||||
|
||||
@@ -3062,6 +3170,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The
|
||||
},
|
||||
"customerPO": null,
|
||||
"totalSalesTax": 0,
|
||||
"probability": 50,
|
||||
"location": { "id": 1, "name": "Murray" },
|
||||
"department": { "id": 5, "name": "Sales" },
|
||||
"expectedCloseDate": "2026-04-15T00:00:00.000Z",
|
||||
@@ -3183,6 +3292,7 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise. The
|
||||
"site": { "id": 50, "name": "Main Office" },
|
||||
"customerPO": null,
|
||||
"totalSalesTax": 0,
|
||||
"probability": 50,
|
||||
"location": { "id": 1, "name": "Murray" },
|
||||
"department": { "id": 5, "name": "Sales" },
|
||||
"expectedCloseDate": "2026-04-15T00:00:00.000Z",
|
||||
@@ -3835,6 +3945,302 @@ Add a labor line item to an opportunity using one of the two canonical labor cat
|
||||
|
||||
---
|
||||
|
||||
### Fetch Committed Quotes
|
||||
|
||||
**GET** `/sales/opportunities/:identifier/quotes`
|
||||
|
||||
Fetch all committed (finalized) quotes for an opportunity, ordered by most recent first.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.quote.fetch`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `includeRegenData` _(optional)_ — When `"true"`, includes `quoteRegenData` on each quote object. Default: `false`.
|
||||
- `includeRegenParams` _(optional)_ — When `"true"`, includes `quoteRegenParams` on each quote object. Default: `false`.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Committed quotes fetched successfully!",
|
||||
"data": [
|
||||
{
|
||||
"id": "clx9abc123...",
|
||||
"quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
|
||||
"quoteRegenHash": "a1b2c3d4e5f6...",
|
||||
"opportunityId": "clx9xyz789...",
|
||||
"createdById": "clx9user456...",
|
||||
"quoteRegenData": "(included when ?includeRegenData=true)",
|
||||
"quoteRegenParams": "(included when ?includeRegenParams=true)",
|
||||
"createdAt": "2026-03-06T12:00:00.000Z",
|
||||
"updatedAt": "2026-03-06T12:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Commit Quote
|
||||
|
||||
**POST** `/sales/opportunities/:identifier/quote/commit`
|
||||
|
||||
Generate a finalized (non-preview) quote PDF for an opportunity and store it in the database with regeneration metadata and creator attribution.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.quote.commit`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
|
||||
**Request Body (optional):**
|
||||
|
||||
```json
|
||||
{
|
||||
"lineItemPricing": true,
|
||||
"includeQuoteNarrative": true,
|
||||
"includeItemNarratives": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
| ----------------------- | ------- | -------- | ------- | ---------------------------------------------- |
|
||||
| `lineItemPricing` | boolean | No | `true` | Include per-line-item pricing in the quote PDF |
|
||||
| `includeQuoteNarrative` | boolean | No | `true` | Include the quote-level narrative section |
|
||||
| `includeItemNarratives` | boolean | No | `true` | Include per-item narrative sections |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 201,
|
||||
"message": "Quote committed successfully!",
|
||||
"data": {
|
||||
"id": "clx9abc123...",
|
||||
"quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
|
||||
"quoteRegenHash": "a1b2c3d4e5f6...",
|
||||
"opportunityId": "clx9xyz789...",
|
||||
"createdById": "clx9user456...",
|
||||
"quoteRegenData": {
|
||||
"options": {
|
||||
"lineItemPricing": true,
|
||||
"includeQuoteNarrative": true,
|
||||
"includeItemNarratives": true
|
||||
},
|
||||
"opportunity": {
|
||||
"id": "clx9xyz789...",
|
||||
"cwOpportunityId": 12345,
|
||||
"name": "Network Refresh – Acme Corp",
|
||||
"totalSalesTax": 245.5,
|
||||
"contactName": "Jane Smith",
|
||||
"companyName": "Acme Corp"
|
||||
},
|
||||
"customer": {
|
||||
"preparedFor": "Jane Smith",
|
||||
"companyName": "Acme Corp",
|
||||
"primaryContact": {
|
||||
"firstName": "Jane",
|
||||
"lastName": "Smith",
|
||||
"email": "jane@acme.com",
|
||||
"phone": "801-555-1234"
|
||||
},
|
||||
"siteAddress": ["123 Main St", "Salt Lake City UT 84101"],
|
||||
"companyAddress": ["456 Corporate Blvd", "Murray UT 84107"]
|
||||
},
|
||||
"salesRep": {
|
||||
"name": "John Roberts",
|
||||
"email": "jroberts@example.com"
|
||||
},
|
||||
"quoteNarrative": "Full network infrastructure refresh including...",
|
||||
"products": [
|
||||
{
|
||||
"cwForecastId": 101,
|
||||
"forecastDescription": "UniFi U6-Pro AP",
|
||||
"productDescription": "UniFi U6-Pro Access Point",
|
||||
"customerDescription": "Wireless access point",
|
||||
"productNarrative": "Install and validate onsite",
|
||||
"productClass": "Product",
|
||||
"forecastType": "Products",
|
||||
"catalogItem": { "id": 500, "identifier": "U6-PRO" },
|
||||
"quantity": 4,
|
||||
"effectiveQuantity": 4,
|
||||
"revenue": 800.0,
|
||||
"cost": 520.0,
|
||||
"margin": 280.0,
|
||||
"percentage": 35.0,
|
||||
"includeFlag": true,
|
||||
"taxableFlag": true,
|
||||
"recurringFlag": false,
|
||||
"recurringRevenue": 0,
|
||||
"recurringCost": 0,
|
||||
"sequenceNumber": 1,
|
||||
"cancelledFlag": false,
|
||||
"cancellationType": null,
|
||||
"quantityCancelled": 0,
|
||||
"cancelledReason": null,
|
||||
"cancelledDate": null
|
||||
}
|
||||
],
|
||||
"snapshotTimestamp": "2026-03-06T12:00:00.000Z"
|
||||
},
|
||||
"quoteRegenParams": {
|
||||
"opportunityId": "clx9xyz789...",
|
||||
"cwOpportunityId": 12345
|
||||
},
|
||||
"createdAt": "2026-03-06T12:00:00.000Z",
|
||||
"updatedAt": "2026-03-06T12:00:00.000Z"
|
||||
},
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Preview Quote
|
||||
|
||||
**GET** `/sales/opportunities/:identifier/quote/:quoteId/preview`
|
||||
|
||||
Regenerate a preview-stamped version of an existing committed quote PDF using its stored generation parameters. The PDF is not persisted — it is returned as a base64-encoded string.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.quote.preview`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
- `quoteId` — The generated quote's internal ID (uuid)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Quote preview generated successfully!",
|
||||
"data": {
|
||||
"mimeType": "application/pdf",
|
||||
"contentBase64": "JVBERi0xLjQK... (base64-encoded PDF)"
|
||||
},
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Download Quote
|
||||
|
||||
**GET** `/sales/opportunities/:identifier/quote/:quoteId/download`
|
||||
|
||||
Download a committed quote PDF by its ID. Returns the PDF file as a base64-encoded string. Each call automatically records a download entry with the timestamp, user info, and fetch action in the quote's `downloads` array. Download-time metadata (downloadedAt, downloadedBy) is also injected into the PDF's document properties.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.quote.download`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
- `quoteId` — The generated quote's internal ID (uuid)
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `fetchAction` **(required)** — The action being performed. Must be one of: `download`, `print`. Tracked in the download record for audit purposes.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Quote downloaded successfully!",
|
||||
"data": {
|
||||
"id": "a1b2c3d4-e5f6-...",
|
||||
"quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
|
||||
"mimeType": "application/pdf",
|
||||
"contentBase64": "JVBERi0xLjQK... (base64-encoded PDF)"
|
||||
},
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
**Download Record Shape (stored in `downloads` JSON array):**
|
||||
|
||||
```json
|
||||
{
|
||||
"downloadedAt": "2026-03-06T15:30:00.000Z",
|
||||
"fetchAction": "download",
|
||||
"userId": "clx9user456...",
|
||||
"userName": "John Roberts",
|
||||
"userEmail": "jroberts@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
|
||||
- `400` — Missing or invalid `fetchAction` query parameter.
|
||||
- `404` — Generated quote not found.
|
||||
|
||||
---
|
||||
|
||||
### Fetch Quote Download History
|
||||
|
||||
**GET** `/sales/opportunities/:identifier/quotes/downloads`
|
||||
|
||||
Fetch download/print history for all committed quotes on an opportunity. Returns an array of quote summaries, each containing the full `downloads` array with every download/print record. This is an admin-level route intended for audit and tracking purposes.
|
||||
|
||||
**Authentication Required:** Yes
|
||||
|
||||
**Required Permissions:** `sales.opportunity.quote.fetch_downloads`
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Download logs fetched successfully!",
|
||||
"data": [
|
||||
{
|
||||
"quoteId": "a1b2c3d4-e5f6-...",
|
||||
"quoteFileName": "OPP-12345-2026-03-06T12-00-00-000Z.pdf",
|
||||
"createdById": "clx9user123...",
|
||||
"createdAt": "2026-03-06T12:00:00.000Z",
|
||||
"downloads": [
|
||||
{
|
||||
"downloadedAt": "2026-03-06T15:30:00.000Z",
|
||||
"fetchAction": "download",
|
||||
"userId": "clx9user456...",
|
||||
"userName": "John Roberts",
|
||||
"userEmail": "jroberts@example.com"
|
||||
},
|
||||
{
|
||||
"downloadedAt": "2026-03-06T16:00:00.000Z",
|
||||
"fetchAction": "print",
|
||||
"userId": "clx9user789...",
|
||||
"userName": "Jane Smith",
|
||||
"userEmail": "jsmith@example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"successful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Opportunity Notes
|
||||
|
||||
**GET** `/sales/opportunities/:identifier/notes`
|
||||
|
||||
Reference in New Issue
Block a user