# Optima API Routes Documentation

This document provides a comprehensive overview of all API routes available in the Optima API.

## Base URL

```
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

All fetch and fetchAll endpoints gate response object keys via `processObjectValuePerms`. Each key on the returned object is checked against `<scope>.<field>` — only fields the user has permission for are included in the response. Grant `<scope>.*` to see all fields.

| Object Type     | Scope                  | Affected Routes                                                                                                                                                                |
| --------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Company         | `obj.company`          | `GET /company/companies`, `GET /company/companies/:identifier`                                                                                                                 |
| Credential      | `obj.credential`       | `GET /credential/credentials/:id`, `GET /credential/credentials/company/:companyId`, `GET /credential/credentials/:id/sub-credentials`, `GET /credential-type/:id/credentials` |
| Credential Type | `obj.credentialType`   | `GET /credential-type/:identifier`, `GET /credential-type/`                                                                                                                    |
| User            | `obj.user`             | `GET /user/@me`, `GET /user/users/:identifier`, `GET /user/users`, `GET /role/:identifier/users`                                                                               |
| Role            | `obj.role`             | `GET /role/:identifier`, `GET /role/`, `GET /user/users/:identifier/roles`                                                                                                     |
| Catalog Item    | `obj.catalogItem`      | `GET /procurement/items`, `GET /procurement/items/:identifier`, `GET /procurement/items/:identifier/linked`                                                                    |
| Opportunity     | `obj.opportunity`      | `GET /sales/opportunities`, `GET /sales/opportunities/opportunity/:identifier`                                                                                                 |
| UniFi Site      | `obj.unifiSite`        | `GET /unifi/sites`, `GET /unifi/site/:id`, `GET /company/companies/:identifier/unifi/sites`                                                                                    |
| WiFi Network    | `unifi.site.wifi.read` | `GET /unifi/site/:id/wifi`                                                                                                                                                     |

See [PERMISSIONS.md](PERMISSIONS.md) for the full list of field-level permission nodes within each scope.

---

## Authentication 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

**POST** `/cw/callback/:secret/:resource`

Receives ConnectWise callback/webhook payloads for supported resources and returns a normalized success response.

**Authentication Required:** No

**Path Parameters:**

- `secret` — Shared callback secret, validated against `CW_CALLBACK_SECRET` when configured
- `resource` — one of: `opportunity`, `ticket`, `company`, `activity`

**Behavior:**

- Parses JSON request body when present.
- Decodes JSON-encoded payload fields such as `Entity`.
- Logs a concise callback summary to console.

**Response:**

```json
{
  "status": 200,
  "message": "CW callback received.",
  "data": {
    "resource": "ticket",
    "summary": {
      "resource": "ticket",
      "messageId": "1bec7421-204a-4b30-8b06-465915e9a0f5",
      "action": "updated",
      "type": "ticket",
      "id": 36073,
      "memberId": "jroberts",
      "entityStatus": "In Progress",
      "entitySummary": "Onsite Installation: Rough-In",
      "entityUpdatedBy": "cirvine",
      "entityLastUpdated": "2026-03-03T21:43:29.903"
    },
    "bodyParsed": {},
    "receivedAt": "2026-03-04T00:00:00.000Z"
  },
  "successful": true
}
```

---

### Get Authentication URI

**GET** `/auth/uri`

Get the Microsoft OAuth authentication URI for user login.

**Authentication Required:** No

**Response:**

```json
{
  "status": 200,
  "message": "Successfully fetch Auth URI",
  "data": {
    "uri": "https://login.microsoftonline.com/...",
    "callbackKey": "ck123..."
  },
  "successful": true
}
```

---

### OAuth Redirect Handler

**GET** `/auth/redirect`

Handles the OAuth redirect callback from Microsoft. This endpoint processes the authorization code and creates a user session.

**Authentication Required:** No

**Query Parameters:**

- `code` - Authorization code from Microsoft
- `state` - Callback key for WebSocket notification

**Response:**
Closes the browser window and emits authentication tokens via WebSocket.

---

### Refresh Access Token

**POST** `/auth/refresh`

Refresh an expired access token using a valid refresh token.

**Authentication Required:** Yes (Refresh Token)

**Headers:**

- `x-refresh-token` - The refresh token

**Response:**

```json
{
  "status": 201,
  "message": "Token refreshed successfully!",
  "data": {
    "accessToken": "eyJhbGc...",
    "refreshToken": "eyJhbGc..."
  },
  "successful": true
}
```

---

## User Routes

### Get Current User

**GET** `/user/@me`

Fetch the currently authenticated user's information.

**Authentication Required:** Yes

**Required Scopes:** `user.read`

**Field-Level Gating:** `obj.user` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**Response:**

```json
{
  "status": 200,
  "message": "Fetched user.",
  "data": {
    "id": "ckx...",
    "name": "John Doe",
    "email": "john.doe@example.com",
    "login": "john.doe",
    "image": "https://...",
    "roles": ["admin"],
    "createdAt": "2026-01-01T00:00:00.000Z",
    "updatedAt": "2026-02-14T00:00:00.000Z"
  },
  "successful": true
}
```

---

### Update Current User

**PATCH** `/user/@me`

Update the currently authenticated user's information.

**Authentication Required:** Yes

**Required Scopes:** `user.write`

**Request Body:**

```json
{
  "name": "Jane Doe",
  "image": "https://example.com/avatar.jpg"
}
```

**Response:**

```json
{
  "status": 200,
  "message": "Successfully updated user.",
  "data": {
    "id": "ckx...",
    "name": "Jane Doe",
    "email": "jane.doe@example.com",
    "image": "https://example.com/avatar.jpg"
  },
  "successful": true
}
```

---

### Check User Permissions

**POST** `/user/@me/check-permission`

Check if the currently authenticated user has specific permissions. Accepts an array of permission nodes and returns the status for each.

**Authentication Required:** Yes

**Required Scopes:** `user.read`

**Request Body:**

```json
{
  "permissions": ["user.read", "company.create", "credential.write"]
}
```

**Response:**

```json
{
  "status": 200,
  "message": "Permission check completed.",
  "data": {
    "results": [
      {
        "permission": "user.read",
        "hasPermission": true
      },
      {
        "permission": "company.create",
        "hasPermission": false
      },
      {
        "permission": "credential.write",
        "hasPermission": true
      }
    ]
  },
  "successful": true
}
```

---

## Other User Routes

### Get All Users

**GET** `/user/users`

Fetch a list of all users.

**Authentication Required:** Yes

**Required Permissions:** `user.read.other`, `user.list.other`

**Field-Level Gating:** `obj.user` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**Response:**

```json
{
  "status": 200,
  "message": "Users Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "John Doe",
      "email": "john.doe@example.com",
      "login": "john.doe",
      "image": "https://...",
      "roles": ["admin"]
    }
  ],
  "successful": true
}
```

---

### Get User by ID

**GET** `/user/users/:identifier`

Fetch a specific user by their ID.

**Authentication Required:** Yes

**Required Permissions:** `user.read.other`

**Field-Level Gating:** `obj.user` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**Path Parameters:**

- `identifier` - The user's ID

**Response:**

```json
{
  "status": 200,
  "message": "User Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "John Doe",
    "email": "john.doe@example.com",
    "login": "john.doe",
    "image": "https://...",
    "roles": ["admin"]
  },
  "successful": true
}
```

**Error Response (404):**

```json
{
  "status": 404,
  "message": "User with identifier 'ckx...' was not found.",
  "error": "UserNotFound",
  "successful": false
}
```

---

### Update User by ID

**PATCH** `/user/users/:identifier`

Update a specific user's information. Supports updating profile fields, roles, and direct permissions.

**Authentication Required:** Yes

**Required Permissions:** `user.write.other`

**Conditional Permissions:**

- If `roles` is included in the body: `user.roles.other` is also required
- If `permissions` is included in the body: `user.permissions.other` is also required

**Path Parameters:**

- `identifier` - The user's ID

**Request Body:**

All fields are optional. Include only the fields you want to update.

```json
{
  "name": "Jane Doe",
  "image": "https://example.com/avatar.jpg",
  "roles": ["admin", "moderator"],
  "permissions": ["credential.fetch", "company.fetch"]
}
```

| Field         | Type       | Description                                                    |
| ------------- | ---------- | -------------------------------------------------------------- |
| `name`        | `string`   | The user's display name                                        |
| `image`       | `string`   | URL to the user's avatar image                                 |
| `roles`       | `string[]` | Array of role ids or monikers to assign (replaces all roles)   |
| `permissions` | `string[]` | Array of permission nodes to assign (replaces all permissions) |

**Response:**

```json
{
  "status": 200,
  "message": "User Updated Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Jane Doe",
    "email": "jane.doe@example.com",
    "login": "jane.doe",
    "image": "https://example.com/avatar.jpg",
    "roles": ["admin", "moderator"]
  },
  "successful": true
}
```

**Error Response (403 - Missing role permission):**

```json
{
  "status": 403,
  "message": "You do not have permission to modify roles on another user.",
  "error": "InsufficientPermission",
  "successful": false
}
```

---

### Delete User by ID

**DELETE** `/user/users/:identifier`

Delete a specific user.

**Authentication Required:** Yes

**Required Permissions:** `user.delete.other`

**Path Parameters:**

- `identifier` - The user's ID

**Response:**

```json
{
  "status": 200,
  "message": "User Deleted Successfully!",
  "data": {
    "id": "ckx...",
    "name": "John Doe",
    "email": "john.doe@example.com",
    "login": "john.doe",
    "image": "https://...",
    "roles": ["admin"]
  },
  "successful": true
}
```

---

### Get User Roles

**GET** `/user/users/:identifier/roles`

Fetch all roles assigned to a specific user.

**Authentication Required:** Yes

**Required Permissions:** `user.read.other`, `role.read`

**Field-Level Gating:** `obj.role` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**Path Parameters:**

- `identifier` - The user's ID

**Response:**

```json
{
  "status": 200,
  "message": "User Roles Fetched Successfully!",
  "data": [
    {
      "id": "uuid...",
      "title": "Administrator",
      "moniker": "admin",
      "permissions": ["*"]
    }
  ],
  "successful": true
}
```

---

### Check User Permissions (Other User)

**POST** `/user/users/:identifier/check-permission`

Check if a specific user has certain permissions.

**Authentication Required:** Yes

**Required Permissions:** `user.read.other`

**Path Parameters:**

- `identifier` - The user's ID

**Request Body:**

```json
{
  "permissions": ["user.read", "company.fetch", "credential.write"]
}
```

**Response:**

```json
{
  "status": 200,
  "message": "Permission check completed.",
  "data": {
    "results": [
      {
        "permission": "user.read",
        "hasPermission": true
      },
      {
        "permission": "company.fetch",
        "hasPermission": false
      },
      {
        "permission": "credential.write",
        "hasPermission": true
      }
    ]
  },
  "successful": true
}
```

---

## Company Routes

### Get All Companies

**GET** `/company/companies`

Fetch a paginated list of all companies with optional search functionality.

**Authentication Required:** Yes

**Required Permissions:** `company.fetch.many`

**Field-Level Gating:** `obj.company` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**Query Parameters:**

- `page` (optional) - Page number (default: 1)
- `rpp` (optional) - Records per page (default: 30)
- `search` (optional) - Search query to filter companies

**Response:**

```json
{
  "status": 200,
  "message": "Companies Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "Acme Corp",
      "cw_CompanyId": 12345,
      "cw_Identifier": "AcmeCorp"
    }
  ],
  "pagination": {
    "previousPage": null,
    "currentPage": 1,
    "nextPage": 2,
    "totalPages": 10,
    "totalRecords": 300,
    "listedRecords": 30
  },
  "successful": true
}
```

---

### Get Company by ID

**GET** `/company/companies/:identifier`

Fetch a single company by its ID. Automatically fetches fresh data from ConnectWise and returns it along with internal company data.

**Authentication Required:** Yes

**Required Permissions:**

- `company.fetch` (base permission)
- `company.fetch.address` (required when `includeAddress=true`)
- `company.fetch.contacts` (required when `includeAllContacts=true`)

**Field-Level Gating:** `obj.company` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**URL Parameters:**

- `identifier` - Company ID (internal database ID)

**Query Parameters:**

- `includeAddress` (optional) - Set to "true" to include full address information. Requires `company.fetch.address` permission. (default: false)
- `includePrimaryContact` (optional) - Set to "true" to include the company's default contact from ConnectWise. (default: false)
- `includeAllContacts` (optional) - Set to "true" to include all contacts for the company from ConnectWise. Requires `company.fetch.contacts` permission. (default: false)

**Response (without optional query params):**

```json
{
  "status": 200,
  "message": "Company Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Acme Corp",
    "cw_CompanyId": 12345,
    "cw_Identifier": "AcmeCorp",
    "cw_Data": {}
  },
  "successful": true
}
```

**Response (with includeAddress=true):**

```json
{
  "status": 200,
  "message": "Company Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Acme Corp",
    "cw_CompanyId": 12345,
    "cw_Identifier": "AcmeCorp",
    "cw_Data": {
      "address": {
        "line1": "123 Main St",
        "line2": null,
        "city": "Springfield",
        "state": "IL",
        "zip": "62701",
        "country": "United States"
      }
    }
  },
  "successful": true
}
```

**Response (with includePrimaryContact=true):**

```json
{
  "status": 200,
  "message": "Company Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Acme Corp",
    "cw_CompanyId": 12345,
    "cw_Identifier": "AcmeCorp",
    "cw_Data": {
      "primaryContact": {
        "firstName": "John",
        "lastName": "Doe",
        "cwId": 456,
        "inactive": false,
        "title": "IT Manager",
        "phone": "555-0123",
        "email": "john.doe@acmecorp.com"
      }
    }
  },
  "successful": true
}
```

**Response (with includeAllContacts=true):**

```json
{
  "status": 200,
  "message": "Company Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Acme Corp",
    "cw_CompanyId": 12345,
    "cw_Identifier": "AcmeCorp",
    "cw_Data": {
      "allContacts": [
        {
          "firstName": "John",
          "lastName": "Doe",
          "cwId": 456,
          "inactive": false,
          "title": "IT Manager",
          "phone": "555-0123",
          "email": "john.doe@acmecorp.com"
        },
        {
          "firstName": "Jane",
          "lastName": "Smith",
          "cwId": 789,
          "inactive": false,
          "title": "CTO",
          "phone": "555-0456",
          "email": "jane.smith@acmecorp.com"
        }
      ]
    }
  },
  "successful": true
}
```

---

### Get Company Configurations

**GET** `/company/companies/:identifier/configurations`

Fetch configurations for a specific company from ConnectWise.

**Authentication Required:** Yes

**Required Permissions:** `company.fetch`, `company.fetch.configurations`

**URL Parameters:**

- `identifier` - Company ID

**Response:**

```json
{
  "status": 200,
  "message": "Company Configurations Fetched Successfully!",
  "data": {
    // ConnectWise configuration data
  },
  "successful": true
}
```

---

### Get Company Sites

**GET** `/company/companies/:identifier/sites`

Fetch all ConnectWise sites for a specific company.

**Authentication Required:** Yes

**Required Permissions:** `company.fetch`, `company.fetch.sites`

**URL Parameters:**

- `identifier` - Company ID

**Response:**

```json
{
  "status": 200,
  "message": "Company Sites Fetched Successfully!",
  "data": [
    {
      "id": 1,
      "name": "Main Office",
      "address": {
        "line1": "123 Main St",
        "line2": null,
        "city": "Springfield",
        "state": "Illinois",
        "zip": "62704",
        "country": "United States"
      },
      "phoneNumber": "555-123-4567",
      "faxNumber": null,
      "primaryAddressFlag": true,
      "defaultShippingFlag": true,
      "defaultBillingFlag": true,
      "defaultMailingFlag": true
    }
  ],
  "successful": true
}
```

---

### Get Company UniFi Sites

**GET** `/company/companies/:identifier/unifi/sites`

Fetch all UniFi sites linked to a specific company.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `company.fetch`

**Field-Level Gating:** `obj.unifiSite` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**URL Parameters:**

- `identifier` - Company ID

**Response:**

```json
{
  "status": 200,
  "message": "Company UniFi Sites Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "Main Office",
      "siteId": "abc123",
      "companyId": "ckx...",
      "createdAt": "2025-01-01T00:00:00.000Z",
      "updatedAt": "2025-01-01T00:00:00.000Z"
    }
  ],
  "successful": true
}
```

---

## Credential Routes

### Get Value Types

**GET** `/credential/valuetypes`

Returns all available field value types for credential type fields.

**Authentication Required:** Yes

**Response:**

```json
{
  "status": 200,
  "message": "Value Types Fetched Successfully!",
  "data": [
    "plain_text",
    "license_key",
    "ip_address",
    "generic_secret",
    "bitlocker_key",
    "password",
    "multi_credential"
  ],
  "successful": true
}
```

---

### Get Credential by ID

**GET** `/credential/credentials/:id`

Fetch a single credential by its ID.

**Authentication Required:** Yes

**Required Permissions:** `credential.fetch`

**Field-Level Gating:** `obj.credential` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**URL Parameters:**

- `id` - Credential ID

**Response:**

```json
{
  "status": 200,
  "message": "Credential Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "AWS Credentials",
    "notes": null,
    "typeId": "cky...",
    "companyId": "ckz...",
    "fields": [
      {
        "id": "accessKeyId",
        "name": "Access Key ID",
        "secure": false,
        "required": true,
        "valueType": "plain_text",
        "value": "AKIAIOSFODNN7EXAMPLE"
      },
      {
        "id": "secretAccessKey",
        "name": "Secret Access Key",
        "secure": true,
        "required": true,
        "valueType": "password",
        "value": null
      }
    ],
    "type": {
      "id": "cky...",
      "name": "AWS",
      "fields": [...],
      "permissionScope": "aws.credentials"
    },
    "company": {
      "id": "ckz...",
      "name": "Acme Corp"
    },
    "createdAt": "2026-01-01T00:00:00.000Z",
    "updatedAt": "2026-02-14T00:00:00.000Z"
  },
  "successful": true
}
```

---

### Get Credentials by Company

**GET** `/credential/credentials/company/:companyId`

Fetch all credentials associated with a specific company.

**Authentication Required:** Yes

**Required Permissions:** `credential.fetch.many`

**Field-Level Gating:** `obj.credential` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**URL Parameters:**

- `companyId` - Company ID

**Response:**

```json
{
  "status": 200,
  "message": "Company Credentials Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "AWS Credentials",
      "notes": null,
      "typeId": "cky...",
      "companyId": "ckz...",
      "fields": [
        {
          "id": "accessKeyId",
          "name": "Access Key ID",
          "secure": false,
          "required": true,
          "valueType": "plain_text",
          "value": "AKIAIOSFODNN7EXAMPLE"
        },
        {
          "id": "secretAccessKey",
          "name": "Secret Access Key",
          "secure": true,
          "required": true,
          "valueType": "password",
          "value": null
        }
      ],
      "type": {...},
      "company": {...}
    }
  ],
  "successful": true
}
```

---

### Create Credential

**POST** `/credential/credentials`

Create a new credential with validated and encrypted fields.

**Authentication Required:** Yes

**Required Permissions:** `credential.create`

**Request Body:**

```json
{
  "name": "Production AWS Credentials",
  "notes": "Used for production S3 access",
  "typeId": "cky...",
  "companyId": "ckz...",
  "fields": [
    {
      "id": "ckx1...",
      "fieldId": "accessKeyId",
      "value": "AKIAIOSFODNN7EXAMPLE"
    },
    {
      "id": "ckx2...",
      "fieldId": "secretAccessKey",
      "value": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
    }
  ],
  "subCredentials": {
    "tunnels": [
      {
        "name": "Tunnel 1",
        "fields": [
          { "fieldId": "server", "value": "vpn1.example.com" },
          { "fieldId": "port", "value": "443" }
        ]
      }
    ]
  }
}
```

| Field            | Type     | Required | Description                                                                                                         |
| ---------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
| `name`           | `string` | Yes      | Display name for the credential                                                                                     |
| `notes`          | `string` | No       | Optional notes                                                                                                      |
| `typeId`         | `string` | Yes      | The credential type ID                                                                                              |
| `companyId`      | `string` | Yes      | The company ID this credential belongs to                                                                           |
| `fields`         | `array`  | Yes      | Array of field values (`{ fieldId, value }`)                                                                        |
| `subCredentials` | `object` | No       | Keyed by multi-credential field ID. Each value is an array of `{ name, fields }` objects for inline sub-credentials |

````

**Response:**

```json
{
  "status": 201,
  "message": "Credential Created Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Production AWS Credentials",
    "typeId": "cky...",
    "companyId": "ckz...",
    "fields": [
      {
        "id": "accessKeyId",
        "name": "Access Key ID",
        "secure": false,
        "required": true,
        "valueType": "plain_text",
        "value": "AKIAIOSFODNN7EXAMPLE"
      },
      {
        "id": "secretAccessKey",
        "name": "Secret Access Key",
        "secure": true,
        "required": true,
        "valueType": "password",
        "value": null
      }
    ],
    "type": {...},
    "company": {...}
  },
  "successful": true
}
````

---

### Update Credential

**PATCH** `/credential/credentials/:id`

Update a credential's basic properties (name, notes) and/or field values. Secure fields are automatically encrypted.

**Authentication Required:** Yes

**Required Permissions:** `credential.update`

**URL Parameters:**

- `id` - Credential ID

**Request Body:**

All properties are optional. Include only the properties you want to update.

```json
{
  "name": "Updated Credential Name",
  "notes": "Updated notes for this credential",
  "fields": [
    {
      "fieldId": "accessKeyId",
      "value": "AKIAIOSFODNN7EXAMPLE"
    },
    {
      "fieldId": "secretAccessKey",
      "value": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
    }
  ]
}
```

**Response:**

```json
{
  "status": 200,
  "message": "Credential Updated Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Updated Credential Name",
    "notes": "Updated notes for this credential",
    "typeId": "cky...",
    "companyId": "ckz...",
    "fields": [
      {
        "id": "accessKeyId",
        "name": "Access Key ID",
        "secure": false,
        "required": true,
        "valueType": "plain_text",
        "value": "AKIAIOSFODNN7EXAMPLE"
      }
    ],
    "type": {...},
    "company": {...}
  },
  "successful": true
}
```

---

### Update Credential Fields

**PUT** `/credential/credentials/:id/fields`

Validate and update credential field values. Secure fields are automatically encrypted.

**Authentication Required:** Yes

**Required Permissions:** `credential.update`, `credential.fields.update`

**URL Parameters:**

- `id` - Credential ID

**Request Body:**

```json
{
  "fields": [
    {
      "fieldId": "accessKeyId",
      "value": "AKIAIOSFODNN7NEWVALUE"
    },
    {
      "fieldId": "secretAccessKey",
      "value": "newSecretKeyValue123"
    }
  ]
}
```

**Response:**

```json
{
  "status": 200,
  "message": "Credential Fields Updated Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Production AWS Credentials",
    "notes": null,
    "typeId": "cky...",
    "companyId": "ckz...",
    "fields": [
      {
        "id": "accessKeyId",
        "name": "Access Key ID",
        "secure": false,
        "required": true,
        "valueType": "plain_text",
        "value": "AKIAIOSFODNN7NEWVALUE"
      },
      {
        "id": "secretAccessKey",
        "name": "Secret Access Key",
        "secure": true,
        "required": true,
        "valueType": "password",
        "value": null
      }
    ],
    "type": {...},
    "company": {...}
  },
  "successful": true
}
```

---

### Get Credential Fields

**GET** `/credential/credentials/:id/fields`

Fetch all field values for a credential (secure fields returned encrypted).

**Authentication Required:** Yes

**Required Permissions:** `credential.fetch`, `credential.fields.fetch`

**URL Parameters:**

- `id` - Credential ID

**Response:**

```json
{
  "status": 200,
  "message": "Credential Fields Fetched Successfully!",
  "data": [
    {
      "id": "ckx-accessKeyId",
      "fieldId": "accessKeyId",
      "value": "AKIAIOSFODNN7EXAMPLE"
    },
    {
      "id": "ckx1...",
      "fieldId": "secretAccessKey",
      "value": "base64EncryptedValue=="
    }
  ],
  "successful": true
}
```

---

### Read Secure Values

**GET** `/credential/credentials/:id/secure-values`

Decrypt and return all secure field values for a credential.

**Authentication Required:** Yes

**Required Permissions:** `credential.fetch`, `credential.secure_values.read`

**URL Parameters:**

- `id` - Credential ID

**Response:**

```json
{
  "status": 200,
  "message": "Secure Values Fetched Successfully!",
  "data": {
    "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
    "apiKey": "sk_live_123456789abcdef"
  },
  "successful": true
}
```

---

### Read Single Secure Value

**GET** `/credential/credentials/:id/secure-values/:fieldId`

Decrypt and return a single secure field value for a credential.

**Authentication Required:** Yes

**Required Permissions:** `credential.fetch`, `credential.secure_values.read`

**URL Parameters:**

- `id` - Credential ID
- `fieldId` - The field ID of the secure value to read

**Response:**

```json
{
  "status": 200,
  "message": "Secure Value Fetched Successfully!",
  "data": {
    "fieldId": "secretAccessKey",
    "value": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
  },
  "successful": true
}
```

**Error Response (404):**

```json
{
  "status": 404,
  "message": "Secure field not found: unknownField",
  "error": "SecureFieldNotFound",
  "successful": false
}
```

---

### Delete Credential

**DELETE** `/credential/credentials/:id`

Delete a credential and all associated secure values. Sub-credentials are cascade-deleted automatically.

**Authentication Required:** Yes

**Required Permissions:** `credential.delete`

**URL Parameters:**

- `id` - Credential ID

**Response:**

```json
{
  "status": 200,
  "message": "Credential Deleted Successfully!",
  "data": null,
  "successful": true
}
```

---

### Get Sub-Credentials

**GET** `/credential/credentials/:id/sub-credentials`

Fetch all sub-credentials that belong to a specific parent credential.

**Authentication Required:** Yes

**Required Permissions:** `credential.fetch`, `credential.sub_credentials.fetch`

**Field-Level Gating:** `obj.credential` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**URL Parameters:**

- `id` - Parent Credential ID

**Response:**

```json
{
  "status": 200,
  "message": "Sub-Credentials Fetched Successfully!",
  "data": [
    {
      "id": "ckx1...",
      "name": "Tunnel 1",
      "notes": null,
      "typeId": "cky...",
      "companyId": "ckz...",
      "subCredentialOfId": "ckx...",
      "fields": [
        { "id": "server", "value": "vpn1.example.com", "secure": false },
        { "id": "port", "value": "443", "secure": false }
      ],
      "type": { "..." },
      "company": { "..." },
      "createdAt": "2026-02-20T00:00:00.000Z",
      "updatedAt": "2026-02-20T00:00:00.000Z"
    }
  ],
  "successful": true
}
```

---

### Add Sub-Credential

**POST** `/credential/credentials/:id/sub-credentials`

Create a new sub-credential under an existing parent credential for a specific multi-credential field.

**Authentication Required:** Yes

**Required Permissions:** `credential.fetch`, `credential.sub_credentials.create`

**URL Parameters:**

- `id` - Parent Credential ID

**Request Body:**

```json
{
  "fieldId": "tunnels",
  "name": "Tunnel 2",
  "fields": [
    { "fieldId": "server", "value": "vpn2.example.com" },
    { "fieldId": "port", "value": "1194" }
  ]
}
```

| Field     | Type     | Required | Description                                                     |
| --------- | -------- | -------- | --------------------------------------------------------------- |
| `fieldId` | `string` | Yes      | The multi-credential field ID on the parent credential type     |
| `name`    | `string` | Yes      | Display name for the sub-credential                             |
| `fields`  | `array`  | Yes      | Array of field values matching the multi-credential's subFields |

**Response:**

```json
{
  "status": 201,
  "message": "Sub-Credential Created Successfully!",
  "data": {
    "id": "ckx2...",
    "name": "Tunnel 2",
    "typeId": "cky...",
    "companyId": "ckz...",
    "subCredentialOfId": "ckx...",
    "fields": [
      { "id": "server", "value": "vpn2.example.com", "secure": false },
      { "id": "port", "value": "1194", "secure": false }
    ],
    "type": { "..." },
    "company": { "..." },
    "createdAt": "2026-02-20T00:00:00.000Z",
    "updatedAt": "2026-02-20T00:00:00.000Z"
  },
  "successful": true
}
```

---

### Remove Sub-Credential

**DELETE** `/credential/credentials/:id/sub-credentials/:subId`

Delete a sub-credential and remove its reference from the parent credential's multi-credential field.

**Authentication Required:** Yes

**Required Permissions:** `credential.fetch`, `credential.sub_credentials.delete`

**URL Parameters:**

- `id` - Parent Credential ID
- `subId` - Sub-Credential ID to remove

**Response:**

```json
{
  "status": 200,
  "message": "Sub-Credential Removed Successfully!",
  "data": null,
  "successful": true
}
```

**Error Response (404):**

```json
{
  "status": 404,
  "message": "Sub-credential not found",
  "error": "SubCredentialNotFound",
  "successful": false
}
```

---

## Credential Type Routes

### Get Credential Type by ID or Name

**GET** `/credential-type/:identifier`

Fetch a single credential type by its ID or name.

**Authentication Required:** Yes

**Required Permissions:** `credential_type.fetch`

**Field-Level Gating:** `obj.credentialType` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**URL Parameters:**

- `identifier` - Credential Type ID or name

**Response:**

```json
{
  "status": 200,
  "message": "Credential Type Fetched Successfully!",
  "data": {
    "id": "cky...",
    "name": "AWS",
    "permissionScope": "aws.credentials",
    "icon": "https://aws.amazon.com/favicon.ico",
    "fields": [
      {
        "id": "accessKeyId",
        "name": "Access Key ID",
        "required": true,
        "secure": false,
        "valueType": "plain_text"
      },
      {
        "id": "secretAccessKey",
        "name": "Secret Access Key",
        "required": true,
        "secure": true,
        "valueType": "password"
      }
    ],
    "credentialCount": 5,
    "createdAt": "2026-01-01T00:00:00.000Z",
    "updatedAt": "2026-02-14T00:00:00.000Z"
  },
  "successful": true
}
```

---

### Get All Credential Types

**GET** `/credential-type`

Fetch all credential types in the system.

**Authentication Required:** Yes

**Required Permissions:** `credential_type.fetch.many`

**Field-Level Gating:** `obj.credentialType` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**Response:**

```json
{
  "status": 200,
  "message": "Credential Types Fetched Successfully!",
  "data": [
    {
      "id": "cky...",
      "name": "AWS",
      "permissionScope": "aws.credentials",
      "icon": "https://aws.amazon.com/favicon.ico",
      "fields": [...],
      "credentialCount": 5
    },
    {
      "id": "ckz...",
      "name": "Azure",
      "permissionScope": "azure.credentials",
      "icon": "https://azure.microsoft.com/favicon.ico",
      "fields": [...],
      "credentialCount": 3
    }
  ],
  "successful": true
}
```

---

### Create Credential Type

**POST** `/credential-type`

Create a new credential type with field definitions.

**Authentication Required:** Yes

**Required Permissions:** `credential_type.create`

**Request Body:**

```json
{
  "name": "GitHub",
  "permissionScope": "github.credentials",
  "icon": "https://github.com/favicon.ico",
  "fields": [
    {
      "id": "username",
      "name": "Username",
      "required": true,
      "secure": false,
      "valueType": "plain_text"
    },
    {
      "id": "personalAccessToken",
      "name": "Personal Access Token",
      "required": true,
      "secure": true,
      "valueType": "password"
    }
  ]
}
```

**Multi-Credential Example:**

Fields with `valueType: "multi_credential"` support an optional `subFields` array that defines the field structure for each sub-credential entry. Sub-fields use the same schema and can be nested recursively.

```json
{
  "name": "VPN Config",
  "permissionScope": "vpn.credentials",
  "icon": "https://example.com/vpn.ico",
  "fields": [
    {
      "id": "hostname",
      "name": "Hostname",
      "required": true,
      "secure": false,
      "valueType": "plain_text"
    },
    {
      "id": "tunnels",
      "name": "Tunnels",
      "required": false,
      "secure": false,
      "valueType": "multi_credential",
      "subFields": [
        {
          "id": "server",
          "name": "Server",
          "required": true,
          "secure": false,
          "valueType": "plain_text"
        },
        {
          "id": "psk",
          "name": "Pre-Shared Key",
          "required": true,
          "secure": true,
          "valueType": "password"
        }
      ]
    }
  ]
}
```

| Field                | Type     | Required | Description                                                                                    |
| -------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------- |
| `fields[].id`        | `string` | Yes      | Unique identifier for the field                                                                |
| `fields[].name`      | `string` | Yes      | Display name for the field                                                                     |
| `fields[].required`  | `bool`   | Yes      | Whether the field is required when creating a credential                                       |
| `fields[].secure`    | `bool`   | Yes      | Whether the field value should be encrypted at rest                                            |
| `fields[].valueType` | `string` | Yes      | One of the supported value types (see `GET /credential/valuetypes`)                            |
| `fields[].subFields` | `array`  | No       | Only for `multi_credential` fields. Defines the field structure for each nested sub-credential |

**Response:**

```json
{
  "status": 201,
  "message": "Credential Type Created Successfully!",
  "data": {
    "id": "ck1...",
    "name": "GitHub",
    "permissionScope": "github.credentials",
    "icon": "https://github.com/favicon.ico",
    "fields": [...]
  },
  "successful": true
}
```

---

### Update Credential Type

**PATCH** `/credential-type/:id`

Update a credential type's properties or field definitions.

**Authentication Required:** Yes

**Required Permissions:** `credential_type.update`

**URL Parameters:**

- `id` - Credential Type ID

**Request Body:**

```json
{
  "name": "GitHub Enterprise",
  "icon": "https://github.enterprise.com/favicon.ico",
  "fields": [
    {
      "id": "username",
      "name": "Username",
      "required": true,
      "secure": false,
      "valueType": "plain_text"
    },
    {
      "id": "personalAccessToken",
      "name": "Personal Access Token",
      "required": true,
      "secure": true,
      "valueType": "password"
    },
    {
      "id": "enterpriseUrl",
      "name": "Enterprise URL",
      "required": true,
      "secure": false,
      "valueType": "plain_text"
    }
  ]
}
```

> **Note:** Fields with `valueType: "multi_credential"` support an optional `subFields` array — see the [Create Credential Type](#create-credential-type) section for the full schema and example.

**Response:**

```json
{
  "status": 200,
  "message": "Credential Type Updated Successfully!",
  "data": {
    "id": "ck1...",
    "name": "GitHub Enterprise",
    "fields": [...]
  },
  "successful": true
}
```

---

### Delete Credential Type

**DELETE** `/credential-type/:id`

Delete a credential type. This will cascade delete all credentials of this type.

**Authentication Required:** Yes

**Required Permissions:** `credential_type.delete`

**URL Parameters:**

- `id` - Credential Type ID

**Response:**

```json
{
  "status": 200,
  "message": "Credential Type Deleted Successfully!",
  "data": null,
  "successful": true
}
```

---

### Get Credentials by Type

**GET** `/credential-type/:id/credentials`

Fetch all credentials that use a specific credential type.

**Authentication Required:** Yes

**Required Permissions:** `credential_type.fetch`, `credential.fetch.many`

**Field-Level Gating:** `obj.credential` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**URL Parameters:**

- `id` - Credential Type ID

**Response:**

```json
{
  "status": 200,
  "message": "Credentials Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "Production AWS",
      "typeId": "cky...",
      "companyId": "ckz...",
      "fields": {...}
    },
    {
      "id": "ck2...",
      "name": "Staging AWS",
      "typeId": "cky...",
      "companyId": "ckz...",
      "fields": {...}
    }
  ],
  "successful": true
}
```

---

## Role Routes

### Create Role

**POST** `/role`

Create a new role with a title, moniker, and optional permissions.

**Authentication Required:** Yes

**Required Permissions:** `role.create`

**Request Body:**

```json
{
  "title": "System Administrator",
  "moniker": "system_admin",
  "permissions": [
    "user.read",
    "user.write",
    "company.fetch",
    "credential.create"
  ]
}
```

**Response:**

```json
{
  "status": 201,
  "message": "Role Created Successfully!",
  "data": {
    "id": "ckx...",
    "title": "System Administrator",
    "moniker": "system_admin",
    "permissions": [
      "user.read",
      "user.write",
      "company.fetch",
      "credential.create"
    ],
    "createdAt": "2026-02-17T00:00:00.000Z",
    "updatedAt": "2026-02-17T00:00:00.000Z"
  },
  "successful": true
}
```

---

### Get Role by ID or Moniker

**GET** `/role/:identifier`

Fetch a single role by its ID or moniker.

**Authentication Required:** Yes

**Required Permissions:** `role.read`

**Field-Level Gating:** `obj.role` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**URL Parameters:**

- `identifier` - Role ID or moniker

**Response:**

```json
{
  "status": 200,
  "message": "Role Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "title": "System Administrator",
    "moniker": "system_admin",
    "permissions": [
      "user.read",
      "user.write",
      "company.fetch",
      "credential.create"
    ],
    "createdAt": "2026-02-17T00:00:00.000Z",
    "updatedAt": "2026-02-17T00:00:00.000Z"
  },
  "successful": true
}
```

---

### Get All Roles

**GET** `/role`

Fetch all roles in the system.

**Authentication Required:** Yes

**Required Permissions:** `role.read`, `role.list`

**Field-Level Gating:** `obj.role` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**Response:**

```json
{
  "status": 200,
  "message": "Roles Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "title": "System Administrator",
      "moniker": "system_admin",
      "permissions": ["user.read", "user.write"],
      "createdAt": "2026-02-17T00:00:00.000Z",
      "updatedAt": "2026-02-17T00:00:00.000Z"
    },
    {
      "id": "cky...",
      "title": "Viewer",
      "moniker": "viewer",
      "permissions": ["user.read", "company.fetch"],
      "createdAt": "2026-02-17T00:00:00.000Z",
      "updatedAt": "2026-02-17T00:00:00.000Z"
    }
  ],
  "successful": true
}
```

---

### Update Role

**PATCH** `/role/:identifier`

Update a role's title, moniker, or permissions.

**Authentication Required:** Yes

**Required Permissions:** `role.modify`

**URL Parameters:**

- `identifier` - Role ID or moniker

**Request Body:**

```json
{
  "title": "Super Administrator",
  "moniker": "super_admin",
  "permissions": ["*"]
}
```

**Response:**

```json
{
  "status": 200,
  "message": "Role Updated Successfully!",
  "data": {
    "id": "ckx...",
    "title": "Super Administrator",
    "moniker": "super_admin",
    "permissions": ["*"],
    "createdAt": "2026-02-17T00:00:00.000Z",
    "updatedAt": "2026-02-17T12:00:00.000Z"
  },
  "successful": true
}
```

---

### Delete Role

**DELETE** `/role/:identifier`

Delete a role. This will remove the role from all users that have it assigned.

**Authentication Required:** Yes

**Required Permissions:** `role.delete`

**URL Parameters:**

- `identifier` - Role ID or moniker

**Response:**

```json
{
  "status": 200,
  "message": "Role Deleted Successfully!",
  "data": {
    "id": "ckx...",
    "title": "System Administrator",
    "moniker": "system_admin",
    "createdAt": "2026-02-17T00:00:00.000Z",
    "updatedAt": "2026-02-17T12:00:00.000Z"
  },
  "successful": true
}
```

---

### Add Permissions to Role

**POST** `/role/:identifier/permissions`

Add one or more permissions to an existing role. The new permissions will be merged with existing permissions.

**Authentication Required:** Yes

**Required Permissions:** `role.modify`

**URL Parameters:**

- `identifier` - Role ID or moniker

**Request Body:**

```json
{
  "permissions": ["credential.update", "credential.delete"]
}
```

**Response:**

```json
{
  "status": 200,
  "message": "Permissions Added Successfully!",
  "data": {
    "id": "ckx...",
    "title": "System Administrator",
    "moniker": "system_admin",
    "permissions": [
      "user.read",
      "user.write",
      "company.fetch",
      "credential.create",
      "credential.update",
      "credential.delete"
    ],
    "createdAt": "2026-02-17T00:00:00.000Z",
    "updatedAt": "2026-02-17T12:30:00.000Z"
  },
  "successful": true
}
```

---

### Remove Permissions from Role

**DELETE** `/role/:identifier/permissions`

Remove one or more permissions from an existing role.

**Authentication Required:** Yes

**Required Permissions:** `role.modify`

**URL Parameters:**

- `identifier` - Role ID or moniker

**Request Body:**

```json
{
  "permissions": ["credential.delete"]
}
```

**Response:**

```json
{
  "status": 200,
  "message": "Permissions Removed Successfully!",
  "data": {
    "id": "ckx...",
    "title": "System Administrator",
    "moniker": "system_admin",
    "permissions": [
      "user.read",
      "user.write",
      "company.fetch",
      "credential.create",
      "credential.update"
    ],
    "createdAt": "2026-02-17T00:00:00.000Z",
    "updatedAt": "2026-02-17T12:45:00.000Z"
  },
  "successful": true
}
```

---

### Get Users with Role

**GET** `/role/:identifier/users`

Fetch all users that have been assigned a specific role.

**Authentication Required:** Yes

**Required Permissions:** `role.read`, `user.read`

**Field-Level Gating:** `obj.user` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**URL Parameters:**

- `identifier` - Role ID or moniker

**Response:**

```json
{
  "status": 200,
  "message": "Users Fetched Successfully!",
  "data": [
    {
      "id": "cku...",
      "name": "John Doe",
      "login": "john.doe",
      "roles": ["ckx..."],
      "createdAt": "2026-01-15T00:00:00.000Z",
      "updatedAt": "2026-02-10T00:00:00.000Z"
    },
    {
      "id": "ckv...",
      "name": "Jane Smith",
      "login": "jane.smith",
      "roles": ["ckx...", "cky..."],
      "createdAt": "2026-01-20T00:00:00.000Z",
      "updatedAt": "2026-02-12T00:00:00.000Z"
    }
  ],
  "successful": true
}
```

---

## Permission Routes

### Get All Permission Nodes (Categorized)

**GET** `/permissions`

Fetch all permission nodes organized by category. Returns the full permission node definition object with categories as keys.

**Authentication Required:** Yes

**Required Permissions:** `role.read`

**Response:**

```json
{
  "status": 200,
  "message": "Permission Nodes Fetched Successfully!",
  "data": {
    "global": {
      "name": "Global Permissions",
      "description": "Global wildcard permissions that grant access to all resources",
      "permissions": [
        {
          "node": "*",
          "description": "Full access to all resources and actions (administrator role)",
          "usedIn": []
        }
      ]
    },
    "company": { "..." },
    "credential": { "..." },
    "...additional categories": { "..." }
  },
  "successful": true
}
```

---

### Get All Permission Nodes (Flat)

**GET** `/permissions/nodes`

Fetch a flat array of all permission nodes across all categories.

**Authentication Required:** Yes

**Required Permissions:** `role.read`

**Response:**

```json
{
  "status": 200,
  "message": "All Permission Nodes Fetched Successfully!",
  "data": [
    {
      "node": "*",
      "description": "Full access to all resources and actions (administrator role)",
      "usedIn": []
    },
    {
      "node": "company.fetch",
      "description": "Fetch a single company",
      "usedIn": ["src/api/companies/[id]/fetch.ts"]
    },
    "...additional nodes"
  ],
  "successful": true
}
```

---

### Get Permission Nodes by Category

**GET** `/permissions/:category`

Fetch all permission nodes for a specific category.

**Authentication Required:** Yes

**Required Permissions:** `role.read`

**Path Parameters:**

- `category` - The category key (e.g., `global`, `company`, `credential`, `credentialType`, `role`, `user`, `permission`, `uiNavigation`, `adminUI`)

**Response (Success):**

```json
{
  "status": 200,
  "message": "Permission Category Fetched Successfully!",
  "data": {
    "name": "Company Permissions",
    "description": "Permissions for accessing and managing company resources",
    "permissions": [
      {
        "node": "company.fetch",
        "description": "Fetch a single company",
        "usedIn": ["src/api/companies/[id]/fetch.ts"]
      },
      {
        "node": "company.fetch.address",
        "description": "View company address information",
        "usedIn": ["src/api/companies/[id]/fetch.ts"],
        "dependencies": ["company.fetch"]
      }
    ]
  },
  "successful": true
}
```

**Response (Not Found):**

```json
{
  "status": 404,
  "message": "Permission category \"invalidCategory\" not found",
  "error": "NotFound",
  "successful": false
}
```

---

## Utility Routes

### Teapot

**GET** `/teapot`

A fun Easter egg endpoint that returns HTTP 418 (I'm a teapot).

**Authentication Required:** No

**Response:**

```json
{
  "status": 418,
  "message": "I'm a teapot",
  "successful": false
}
```

---

## Procurement Routes

### Get All Catalog Items

**GET** `/procurement/items`

Fetch a paginated list of catalog items. Supports search.

**Authentication Required:** Yes

**Required Permissions:** `procurement.catalog.fetch.many`

**Field-Level Gating:** `obj.catalogItem` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**Query Parameters:**

- `page` (optional, default `1`) — Page number
- `rpp` (optional, default `30`) — Records per page
- `search` (optional) — Search by name, description, part number, vendor SKU, or manufacturer
- `includeInactive` (optional, default `false`) — Include inactive catalog items in results
- `category` (optional) — Filter by CW category **name or CW ID** (e.g. `Technology` or `18`)
- `subcategory` (optional) — Filter by CW subcategory **name or CW ID** (e.g. `Network-Switch` or `112`)
- `group` (optional) — Filter by umbrella group name (e.g. `Network`, `AlarmBurg`, `Cables`). When used with `category`, returns items whose subcategory belongs to that group within the category.
- `manufacturer` (optional) — Filter by manufacturer name (case-insensitive contains match)
- `ecosystem` (optional) — Filter by ecosystem name (e.g. `Networking`, `Video Surveillance`, `Burg/Alarm`). Applies manufacturer + category + subcategory-prefix matching rules.
- `inStock` (optional, default `false`) — When `true`, only return items with `onHand > 0`
- `minPrice` (optional) — Minimum price filter
- `maxPrice` (optional) — Maximum price filter

**Response:**

```json
{
  "status": 200,
  "message": "Catalog items fetched successfully!",
  "data": [
    {
      "id": "clx...",
      "cwCatalogId": 123,
      "name": "Dell OptiPlex 7020",
      "description": "Dell OptiPlex 7020 SFF Desktop",
      "customerDescription": "Business Desktop Computer",
      "internalNotes": null,
      "category": "Technology",
      "categoryCwId": 18,
      "subcategory": "Computer-Desktop",
      "subcategoryCwId": 106,
      "manufacturer": "Dell",
      "manufactureCwId": 45,
      "partNumber": "OPT7020-SFF",
      "vendorName": "Dell Direct",
      "vendorSku": "DELL-OPT7020",
      "vendorCwId": 12,
      "price": 899.99,
      "cost": 650.0,
      "inactive": false,
      "salesTaxable": true,
      "onHand": 5,
      "cwLastUpdated": "2026-02-25T10:00:00.000Z",
      "createdAt": "2026-01-15T00:00:00.000Z",
      "updatedAt": "2026-02-25T10:00:00.000Z"
    }
  ],
  "meta": {
    "pagination": {
      "previousPage": null,
      "currentPage": 1,
      "nextPage": 2,
      "totalPages": 10,
      "totalRecords": 300,
      "listedRecords": 30
    }
  },
  "successful": true
}
```

---

### Get Catalog Item

**GET** `/procurement/items/:identifier`

Fetch a single catalog item by its internal ID or ConnectWise catalog ID.

**Authentication Required:** Yes

**Required Permissions:** `procurement.catalog.fetch`

**Field-Level Gating:** `obj.catalogItem` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise catalog ID (numeric)

**Query Parameters:**

- `includeLinkedItems` (optional, default `false`) — Include linked catalog items in the response

**Response:**

```json
{
  "status": 200,
  "message": "Catalog item fetched successfully!",
  "data": {
    "id": "clx...",
    "cwCatalogId": 123,
    "name": "Dell OptiPlex 7020",
    "description": "Dell OptiPlex 7020 SFF Desktop",
    "customerDescription": "Business Desktop Computer",
    "internalNotes": null,
    "manufacturer": "Dell",
    "manufactureCwId": 45,
    "partNumber": "OPT7020-SFF",
    "vendorName": "Dell Direct",
    "vendorSku": "DELL-OPT7020",
    "vendorCwId": 12,
    "price": 899.99,
    "cost": 650.0,
    "inactive": false,
    "salesTaxable": true,
    "onHand": 5,
    "cwLastUpdated": "2026-02-25T10:00:00.000Z",
    "linkedItems": [
      {
        "id": "clx...",
        "cwCatalogId": 456,
        "name": "Dell Warranty - 3 Year"
      }
    ],
    "createdAt": "2026-01-15T00:00:00.000Z",
    "updatedAt": "2026-02-25T10:00:00.000Z"
  },
  "successful": true
}
```

---

### Get Catalog Item Count

**GET** `/procurement/count`

Get the total number of catalog items.

**Authentication Required:** Yes

**Required Permissions:** `procurement.catalog.fetch.many`

**Query Parameters:**

- `activeOnly` (optional, default `false`) — Only count active (non-inactive) items

**Response:**

```json
{
  "status": 200,
  "message": "Catalog item count fetched successfully!",
  "data": {
    "count": 300
  },
  "successful": true
}
```

---

### Refresh Catalog Item Inventory

**POST** `/procurement/items/:identifier/refresh-inventory`

Refresh the on-hand inventory count for a catalog item by fetching the latest data from ConnectWise.

**Authentication Required:** Yes

**Required Permissions:** `procurement.catalog.inventory.refresh`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise catalog ID (numeric)

**Response:**

```json
{
  "status": 200,
  "message": "Inventory refreshed successfully!",
  "data": {
    "id": "clx...",
    "cwCatalogId": 123,
    "name": "Dell OptiPlex 7020",
    "onHand": 7,
    "price": 899.99,
    "cost": 650.0,
    "inactive": false,
    "createdAt": "2026-01-15T00:00:00.000Z",
    "updatedAt": "2026-02-26T12:00:00.000Z"
  },
  "successful": true
}
```

---

### Get Linked Catalog Items

**GET** `/procurement/items/:identifier/linked`

Fetch all catalog items linked to a specific item.

**Authentication Required:** Yes

**Required Permissions:** `procurement.catalog.fetch`

**Field-Level Gating:** `obj.catalogItem` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**Path Parameters:**

- `identifier` — Internal ID (cuid), CW identifier string, or CW catalog ID (numeric)

**Response:**

```json
{
  "status": 200,
  "message": "Linked catalog items fetched successfully!",
  "data": [
    {
      "id": "clx...",
      "cwCatalogId": 456,
      "identifier": "DELL-WAR-3YR",
      "name": "Dell Warranty - 3 Year",
      "description": "Dell 3 Year ProSupport Warranty",
      "price": 199.99,
      "cost": 120.0,
      "inactive": false,
      "onHand": 0,
      "createdAt": "2026-01-15T00:00:00.000Z",
      "updatedAt": "2026-02-25T10:00:00.000Z"
    }
  ],
  "successful": true
}
```

---

### Link Catalog Items

**POST** `/procurement/items/:identifier/link`

Link a target catalog item to the specified source item. The source item is identified by the URL parameter and the target by the request body.

**Authentication Required:** Yes

**Required Permissions:** `procurement.catalog.link`

**Path Parameters:**

- `identifier` — Internal ID (cuid), CW identifier string, or CW catalog ID (numeric) of the source item

**Request Body:**

```json
{
  "targetId": "clx..."
}
```

**Response:**

```json
{
  "status": 200,
  "message": "Catalog item linked successfully!",
  "data": {
    "id": "clx...",
    "cwCatalogId": 123,
    "identifier": "OPT7020-SFF",
    "name": "Dell OptiPlex 7020",
    "linkedItems": [
      {
        "id": "clx...",
        "cwCatalogId": 456,
        "identifier": "DELL-WAR-3YR",
        "name": "Dell Warranty - 3 Year"
      }
    ],
    "createdAt": "2026-01-15T00:00:00.000Z",
    "updatedAt": "2026-02-26T12:00:00.000Z"
  },
  "successful": true
}
```

---

### Unlink Catalog Items

**POST** `/procurement/items/:identifier/unlink`

Remove the link between a source catalog item and a target catalog item.

**Authentication Required:** Yes

**Required Permissions:** `procurement.catalog.link`

**Path Parameters:**

- `identifier` — Internal ID (cuid), CW identifier string, or CW catalog ID (numeric) of the source item

**Request Body:**

```json
{
  "targetId": "clx..."
}
```

**Response:**

```json
{
  "status": 200,
  "message": "Catalog item unlinked successfully!",
  "data": {
    "id": "clx...",
    "cwCatalogId": 123,
    "identifier": "OPT7020-SFF",
    "name": "Dell OptiPlex 7020",
    "linkedItems": [],
    "createdAt": "2026-01-15T00:00:00.000Z",
    "updatedAt": "2026-02-26T12:00:00.000Z"
  },
  "successful": true
}
```

---

### Get Categories & Ecosystems

**GET** `/procurement/categories`

Fetch the full category tree and ecosystem tree. The category tree defines the three-level hierarchy (Category → Group/Subcategory → Subcategory) used for organizing catalog items. The ecosystem tree defines cross-cutting product groupings by manufacturer + category + subcategory-prefix rules.

**Authentication Required:** Yes

**Required Permissions:** `procurement.catalog.fetch.many`

**Response:**

```json
{
  "status": 200,
  "message": "Category and ecosystem data fetched successfully!",
  "data": {
    "categories": [
      {
        "name": "Technology",
        "cwId": 18,
        "entries": [
          {
            "type": "subcategory",
            "name": "GeneralEquip",
            "cwId": 57
          },
          {
            "type": "group",
            "name": "Network",
            "subcategories": [
              { "name": "Network-Other", "cwId": 174 },
              { "name": "Network-Router", "cwId": 119 },
              { "name": "Network-Switch", "cwId": 112 },
              { "name": "Network-Wireless", "cwId": 111 }
            ]
          }
        ]
      }
    ],
    "ecosystems": [
      {
        "name": "Networking",
        "manufacturers": [
          {
            "name": "Ubiquiti",
            "cwId": 248,
            "category": "Technology",
            "subcategoryPrefix": "Network-"
          },
          {
            "name": "TP-Link",
            "cwId": 259,
            "category": "Technology",
            "subcategoryPrefix": "Network-"
          }
        ]
      }
    ]
  },
  "successful": true
}
```

---

### Get Filter Values

**GET** `/procurement/filters`

Fetch the distinct values available for filter dropdowns (categories, subcategories, manufacturers) in the current dataset. Optionally scope the results with category/subcategory filters to cascade dependent dropdowns.

**Authentication Required:** Yes

**Required Permissions:** `procurement.catalog.fetch.many`

**Query Parameters:**

- `category` (optional) — Scope subcategories and manufacturers to items in this category (accepts CW category name or CW ID)
- `subcategory` (optional) — Scope manufacturers to items in this subcategory (accepts CW subcategory name or CW ID)
- `includeInactive` (optional, default `false`) — Include inactive catalog items

**Response:**

```json
{
  "status": 200,
  "message": "Available filter values fetched successfully!",
  "data": {
    "categories": ["Field", "General", "Technology"],
    "subcategories": [
      "Network-Other",
      "Network-Router",
      "Network-Switch",
      "Network-Wireless"
    ],
    "manufacturers": ["TP-Link", "Ubiquiti"]
  },
  "successful": true
}
```

---

## Sales Routes

Sales routes serve opportunity data stored locally and synced from ConnectWise. All opportunity responses include hydrated company data (address, contacts) and an `activities` array. Single-opportunity fetches additionally include full site details (address, phone, flags). Sub-resource routes (products, notes, contacts) return data keyed by the opportunity's CW ID.

**Caching:** Most CW data for opportunities is served from a **Redis cache** that is proactively warmed by a background refresh cycle (every 30 seconds). This includes opportunity CW data, activities, notes, contacts, products, and company data. Cache TTLs are adaptive — recently updated opportunities have shorter TTLs (30s–60s) for fresher data, while inactive opportunities use longer TTLs (5–15 minutes). If a cache miss occurs at request time, data is fetched live from CW and cached. See [CACHING.md](CACHING.md) for full details on TTL algorithms, background refresh mechanics, and debugging tools.

### Get Opportunity Types

**GET** `/sales/opportunity-types`

Fetch the list of all opportunity quote statuses (types). Returns a static list of canonical quote statuses with their ConnectWise IDs and legacy Optima equivalency mappings.

**Authentication Required:** Yes

**Required Permissions:**

- Base access: `sales.opportunity.fetch.many`
- `scope=all`: `sales.opportunity.metrics.all`
- `identifier=<cwIdentifier>` (when different from caller's own identifier): `sales.opportunity.metrics.identifier.override`

**Response:**

```json
{
  "status": 200,
  "message": "Opportunity Types Fetched Successfully!",
  "data": [
    {
      "id": 51,
      "name": "00. FutureLead",
      "wonFlag": false,
      "lostFlag": false,
      "closedFlag": false,
      "inactiveFlag": false,
      "defaultFlag": false,
      "enteredBy": "crobinso",
      "dateEntered": "2023-07-11T23:13:19Z",
      "_info": {
        "lastUpdated": "2024-04-28T15:03:57Z",
        "updatedBy": "crobinso"
      },
      "connectWiseId": "070f72a3-70d0-ef11-b2e0-000c29c55070",
      "optimaEquivalency": [35, 36]
    }
  ],
  "successful": true
}
```

---

### Get All Opportunities

**GET** `/sales/opportunities`

Fetch a paginated list of opportunities. Supports search. Each opportunity includes hydrated company data (with address and contacts from ConnectWise) when a linked company exists.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.fetch.many`

**Field-Level Gating:** `obj.opportunity` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**Query Parameters:**

- `page` (optional, default `1`) — Page number
- `rpp` (optional, default `30`) — Records per page
- `search` (optional) — Search by opportunity name
- `includeClosed` (optional, default `false`) — Include closed opportunities in results

**Response:**

```json
{
  "status": 200,
  "message": "Opportunities fetched successfully!",
  "data": [
    {
      "id": "clx...",
      "cwOpportunityId": 456,
      "name": "Acme Corp Network Refresh",
      "notes": "Full network redesign and hardware refresh",
      "type": { "id": 1, "name": "New" },
      "stage": { "id": 3, "name": "Proposal" },
      "status": { "id": 1, "name": "Open" },
      "priority": { "id": 2, "name": "High" },
      "rating": { "id": 1, "name": "Hot" },
      "source": "Referral",
      "campaign": null,
      "primarySalesRep": {
        "id": 10,
        "identifier": "JDoe",
        "name": "John Doe"
      },
      "secondarySalesRep": null,
      "company": {
        "id": "clx...",
        "name": "Acme Corp",
        "cw_Identifier": "AcmeCorp",
        "cw_CompanyId": 100,
        "cw_Data": {
          "address": {
            "line1": "123 Main St",
            "line2": null,
            "city": "Murray",
            "state": "Kentucky",
            "zip": "42071",
            "country": "United States"
          },
          "allContacts": [
            {
              "firstName": "Jane",
              "lastName": "Smith",
              "cwId": 200,
              "inactive": false,
              "title": "IT Manager",
              "phone": "555-0100",
              "email": "jane.smith@acme.com"
            }
          ]
        }
      },
      "contact": { "id": 200, "name": "Jane Smith" },
      "site": { "id": 50, "name": "Main Office" },
      "customerPO": null,
      "totalSalesTax": 0,
      "expectedSalesTax": 0.06,
      "probability": 50,
      "location": { "id": 1, "name": "Murray" },
      "department": { "id": 5, "name": "Sales" },
      "expectedCloseDate": "2026-04-15T00:00:00.000Z",
      "pipelineChangeDate": "2026-02-20T00:00:00.000Z",
      "dateBecameLead": "2026-01-10T00:00:00.000Z",
      "closedDate": null,
      "closedFlag": false,
      "closedBy": null,
      "companyId": "clx...",
      "cwLastUpdated": "2026-02-26T10:00:00.000Z",
      "productSequence": [31848, 31846, 31847],
      "createdAt": "2026-02-01T00:00:00.000Z",
      "updatedAt": "2026-02-26T10:00:00.000Z",
      "customFields": [],
      "activities": [
        {
          "cwActivityId": 789,
          "name": "Follow-up Call",
          "notes": "Discuss proposal details",
          "type": { "id": 1, "name": "Call" },
          "status": { "id": 1, "name": "Open" },
          "company": {
            "id": 100,
            "identifier": "AcmeCorp",
            "name": "Acme Corp"
          },
          "contact": { "id": 200, "name": "Jane Smith" },
          "phoneNumber": "555-0100",
          "email": "jane.smith@acme.com",
          "opportunity": { "id": 456, "name": "Acme Corp Network Refresh" },
          "ticket": null,
          "agreement": null,
          "campaign": null,
          "assignTo": { "id": 10, "identifier": "JDoe", "name": "John Doe" },
          "scheduleStatus": null,
          "reminder": null,
          "where": null,
          "dateStart": "2026-03-01T10:00:00.000Z",
          "dateEnd": "2026-03-01T10:30:00.000Z",
          "notifyFlag": false,
          "currency": null,
          "mobileGuid": null,
          "customFields": [],
          "cwLastUpdated": "2026-02-26T10:00:00.000Z",
          "cwDateEntered": "2026-02-20T09:00:00.000Z",
          "cwEnteredBy": "JDoe",
          "cwUpdatedBy": "JDoe"
        }
      ]
    }
  ],
  "meta": {
    "pagination": {
      "previousPage": null,
      "currentPage": 1,
      "nextPage": 2,
      "totalPages": 5,
      "totalRecords": 150,
      "listedRecords": 30
    }
  },
  "successful": true
}
```

---

### Get Opportunity Count

**GET** `/sales/opportunities/count`

Get the total number of opportunities.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.fetch.many`

**Query Parameters:**

- `openOnly` (optional, default `false`) — Only count open (non-closed) opportunities

**Response:**

```json
{
  "status": 200,
  "message": "Opportunity count fetched successfully!",
  "data": {
    "count": 150
  },
  "successful": true
}
```

---

### Get My Opportunities

**GET** `/sales/opportunities/@me`

Returns all opportunities where the authenticated user is assigned as the primary or secondary sales rep. The user's ConnectWise member identifier is resolved from their account.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.fetch.many`

**Query Parameters:**

- `includeClosed` _(optional, default `false`)_ — When `true`, includes closed opportunities in the result.

**Response:**

```json
{
  "status": 200,
  "message": "Opportunities fetched successfully!",
  "data": [
    /* array of opportunity objects */
  ],
  "successful": true
}
```

**Notes:**

- Returns an empty array if the authenticated user has no ConnectWise identifier linked to their account.

---

### Get Opportunities by User ID

**GET** `/sales/opportunities/user/:id`

Returns all opportunities where the specified user is assigned as the primary or secondary sales rep.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.fetch.many`

**Path Parameters:**

- `id` — Internal user ID (cuid)

**Query Parameters:**

- `includeClosed` _(optional, default `false`)_ — When `true`, includes closed opportunities in the result.

**Response:**

```json
{
  "status": 200,
  "message": "Opportunities fetched successfully!",
  "data": [
    /* array of opportunity objects */
  ],
  "successful": true
}
```

**Error Responses:**

- `404` — User not found
- Returns an empty array if the user has no ConnectWise identifier linked.

---

### Get Sales Opportunity Metrics (Cached)

**GET** `/sales/opportunities/metrics`

Fetch precomputed sales opportunity metrics from Redis. Metrics are refreshed in the background every 5 minutes for active ConnectWise members and are designed for fast dashboard/reporting reads.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.fetch.many`

**Query Parameters:**

- `scope` _(optional, default `me`)_ — `me` returns metrics for the current user's `cwIdentifier`; `all` returns cached metrics for all active CW members.
- `identifier` _(optional)_ — Explicit CW member identifier override (e.g. `jroberts`). Ignored when `scope=all`.

**Strict parameter gating:**

- `scope=all` is rejected with `403` unless caller has `sales.opportunity.metrics.all`.
- `identifier=<cwIdentifier>` is rejected with `403` when the identifier differs from caller's own `cwIdentifier` and caller lacks `sales.opportunity.metrics.identifier.override`.

**Response (`scope=me`):**

```json
{
  "status": 200,
  "message": "Sales opportunity metrics fetched successfully!",
  "data": {
    "identifier": "jroberts",
    "metrics": {
      "memberIdentifier": "jroberts",
      "memberName": "John Roberts",
      "generatedAt": "2026-03-15T20:10:00.000Z",
      "pipelineRevenue": 250000,
      "closedWonRevenueMtd": 142500,
      "closedWonRevenueYtd": 512000,
      "winCount": { "mtd": 3, "ytd": 11 },
      "lossCount": { "mtd": 1, "ytd": 4 },
      "avgDaysToClose": 27.35,
      "openOpportunityCount": 9,
      "weightedPipelineRevenue": 173500,
      "taxablePipelineRevenue": 198000,
      "nonTaxablePipelineRevenue": 52000,
      "avgOpenDealSize": 27777.78,
      "avgWonDealSize": { "mtd": 47500, "ytd": 46545.45 },
      "winRate": { "mtd": 0.75, "ytd": 0.7333 },
      "lossRate": { "mtd": 0.25, "ytd": 0.2667 },
      "assignedOpportunityCount": 14,
      "cacheHitCount": 12,
      "cacheMissCount": 2,
      "cacheHitRate": 0.8571,
      "opportunityBreakdown": {
        "pipeline": [
          {
            "id": "clx1abc123",
            "cwId": 98765,
            "name": "Acme Corp - Network Upgrade",
            "revenue": 45000,
            "taxableRevenue": 45000,
            "nonTaxableRevenue": 0,
            "probability": 70,
            "weightedRevenue": 31500,
            "closedDate": null
          }
        ],
        "closedWonMtd": [
          {
            "id": "clx1def456",
            "cwId": 98720,
            "name": "Globex - Security Stack",
            "revenue": 142500,
            "taxableRevenue": 120000,
            "nonTaxableRevenue": 22500,
            "probability": 100,
            "weightedRevenue": 142500,
            "closedDate": "2026-03-08T00:00:00.000Z"
          }
        ],
        "closedWonYtd": [
          /* same shape, all won opps this year */
        ],
        "closedLostMtd": [
          /* same shape, lost opps this month */
        ],
        "closedLostYtd": [
          /* same shape, lost opps this year */
        ]
      }
    }
  },
  "successful": true
}
```

Each entry in an `opportunityBreakdown` list contains:

| Field               | Type             | Description                            |
| ------------------- | ---------------- | -------------------------------------- |
| `id`                | `string`         | Internal DB id (cuid)                  |
| `cwId`              | `number`         | ConnectWise opportunity ID             |
| `name`              | `string`         | Opportunity name                       |
| `revenue`           | `number`         | Computed total revenue                 |
| `taxableRevenue`    | `number`         | Taxable portion of revenue             |
| `nonTaxableRevenue` | `number`         | Non-taxable portion of revenue         |
| `probability`       | `number`         | Probability as a 0–100 percent value   |
| `weightedRevenue`   | `number`         | `revenue × (probability / 100)`        |
| `closedDate`        | `string \| null` | ISO 8601 close date, or `null` if open |

**Response (`scope=all`):**

```json
{
  "status": 200,
  "message": "Sales opportunity metrics fetched successfully!",
  "data": {
    "generatedAt": "2026-03-15T20:10:00.000Z",
    "activeMemberCount": 26,
    "memberIdentifiers": ["jroberts", "asmith"],
    "members": {
      "jroberts": { "memberIdentifier": "jroberts" },
      "asmith": { "memberIdentifier": "asmith" }
    }
  },
  "successful": true
}
```

---

### Get Opportunity

**GET** `/sales/opportunities/opportunity/:identifier`

Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The response includes hydrated company data (with address and contacts) and full site details (with address) when available. CW data (activities, company, site) is served from the Redis cache when available; on cache miss, data is fetched live from CW and cached with an adaptive TTL.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.fetch`

**Field-Level Gating:** `obj.opportunity` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

**Query Parameters:**

- `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`.

`expectedSalesTax` is computed from address using site address first and company address as fallback. Logic first checks state, then city-level jurisdiction when available in the sales tax data file, and otherwise falls back to the state's `avg_combined_rate`. Tennessee (`TN`) remains hard-coded to `0.0975` regardless of data-file values.

**Response:**

```json
{
  "status": 200,
  "message": "Opportunity fetched successfully!",
  "data": {
    "id": "clx...",
    "cwOpportunityId": 456,
    "name": "Acme Corp Network Refresh",
    "notes": "Full network redesign and hardware refresh",
    "type": { "id": 1, "name": "New" },
    "stage": { "id": 3, "name": "Proposal" },
    "status": { "id": 1, "name": "Open" },
    "priority": { "id": 2, "name": "High" },
    "rating": { "id": 1, "name": "Hot" },
    "source": "Referral",
    "campaign": null,
    "primarySalesRep": {
      "id": 10,
      "identifier": "JDoe",
      "name": "John Doe"
    },
    "secondarySalesRep": null,
    "company": {
      "id": "clx...",
      "name": "Acme Corp",
      "cw_Identifier": "AcmeCorp",
      "cw_CompanyId": 100,
      "cw_Data": {
        "address": {
          "line1": "123 Main St",
          "line2": null,
          "city": "Murray",
          "state": "Kentucky",
          "zip": "42071",
          "country": "United States"
        },
        "allContacts": [
          {
            "firstName": "Jane",
            "lastName": "Smith",
            "cwId": 200,
            "inactive": false,
            "title": "IT Manager",
            "phone": "555-0100",
            "email": "jane.smith@acme.com"
          }
        ]
      }
    },
    "contact": { "id": 200, "name": "Jane Smith" },
    "site": {
      "id": 50,
      "name": "Main Office",
      "address": {
        "line1": "123 Main St",
        "line2": null,
        "city": "Murray",
        "state": "Kentucky",
        "zip": "42071",
        "country": "United States"
      },
      "phoneNumber": "555-0100",
      "faxNumber": null,
      "primaryAddressFlag": true,
      "defaultShippingFlag": true,
      "defaultBillingFlag": true,
      "defaultMailingFlag": true
    },
    "customerPO": null,
    "totalSalesTax": 0,
    "expectedSalesTax": 0.06,
    "probability": 50,
    "location": { "id": 1, "name": "Murray" },
    "department": { "id": 5, "name": "Sales" },
    "expectedCloseDate": "2026-04-15T00:00:00.000Z",
    "pipelineChangeDate": "2026-02-20T00:00:00.000Z",
    "dateBecameLead": "2026-01-10T00:00:00.000Z",
    "closedDate": null,
    "closedFlag": false,
    "closedBy": null,
    "companyId": "clx...",
    "cwLastUpdated": "2026-02-26T10:00:00.000Z",
    "productSequence": [31848, 31846, 31847],
    "createdAt": "2026-02-01T00:00:00.000Z",
    "updatedAt": "2026-02-26T10:00:00.000Z",
    "customFields": [],
    "activities": [
      {
        "cwActivityId": 789,
        "name": "Follow-up Call",
        "notes": "Discuss proposal details",
        "type": { "id": 1, "name": "Call" },
        "status": { "id": 1, "name": "Open" },
        "company": { "id": 100, "identifier": "AcmeCorp", "name": "Acme Corp" },
        "contact": { "id": 200, "name": "Jane Smith" },
        "phoneNumber": "555-0100",
        "email": "jane.smith@acme.com",
        "opportunity": { "id": 456, "name": "Acme Corp Network Refresh" },
        "ticket": null,
        "agreement": null,
        "campaign": null,
        "assignTo": { "id": 10, "identifier": "JDoe", "name": "John Doe" },
        "scheduleStatus": null,
        "reminder": null,
        "where": null,
        "dateStart": "2026-03-01T10:00:00.000Z",
        "dateEnd": "2026-03-01T10:30:00.000Z",
        "notifyFlag": false,
        "currency": null,
        "mobileGuid": null,
        "customFields": [],
        "cwLastUpdated": "2026-02-26T10:00:00.000Z",
        "cwDateEntered": "2026-02-20T09:00:00.000Z",
        "cwEnteredBy": "JDoe",
        "cwUpdatedBy": "JDoe"
      }
    ]
  },
  "successful": true
}
```

---

### Refresh Opportunity

**POST** `/sales/opportunities/opportunity/:identifier/refresh`

Refresh an opportunity's local data by fetching the latest from ConnectWise. The response includes hydrated company data and site details.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.refresh`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

**Response:**

```json
{
  "status": 200,
  "message": "Opportunity refreshed from ConnectWise successfully!",
  "data": {
    "id": "clx...",
    "cwOpportunityId": 456,
    "name": "Acme Corp Network Refresh",
    "notes": "Updated notes from CW",
    "type": { "id": 1, "name": "New" },
    "stage": { "id": 4, "name": "Negotiation" },
    "status": { "id": 1, "name": "Open" },
    "priority": { "id": 2, "name": "High" },
    "rating": { "id": 1, "name": "Hot" },
    "source": "Referral",
    "campaign": null,
    "primarySalesRep": {
      "id": 10,
      "identifier": "JDoe",
      "name": "John Doe"
    },
    "secondarySalesRep": null,
    "company": {
      "id": "clx...",
      "name": "Acme Corp",
      "cw_Identifier": "AcmeCorp",
      "cw_CompanyId": 100,
      "cw_Data": {
        "address": {
          "line1": "123 Main St",
          "line2": null,
          "city": "Murray",
          "state": "Kentucky",
          "zip": "42071",
          "country": "United States"
        },
        "allContacts": [
          {
            "firstName": "Jane",
            "lastName": "Smith",
            "cwId": 200,
            "inactive": false,
            "title": "IT Manager",
            "phone": "555-0100",
            "email": "jane.smith@acme.com"
          }
        ]
      }
    },
    "contact": { "id": 200, "name": "Jane Smith" },
    "site": { "id": 50, "name": "Main Office" },
    "customerPO": null,
    "totalSalesTax": 0,
    "expectedSalesTax": 0.06,
    "probability": 50,
    "location": { "id": 1, "name": "Murray" },
    "department": { "id": 5, "name": "Sales" },
    "expectedCloseDate": "2026-04-15T00: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-02-26T14:00:00.000Z",
    "createdAt": "2026-02-01T00:00:00.000Z",
    "updatedAt": "2026-02-26T14:00:00.000Z",
    "customFields": [],
    "activities": [
      {
        "cwActivityId": 789,
        "name": "Follow-up Call",
        "notes": "Discuss proposal details",
        "type": { "id": 1, "name": "Call" },
        "status": { "id": 1, "name": "Open" },
        "company": { "id": 100, "identifier": "AcmeCorp", "name": "Acme Corp" },
        "contact": { "id": 200, "name": "Jane Smith" },
        "phoneNumber": "555-0100",
        "email": "jane.smith@acme.com",
        "opportunity": { "id": 456, "name": "Acme Corp Network Refresh" },
        "ticket": null,
        "agreement": null,
        "campaign": null,
        "assignTo": { "id": 10, "identifier": "JDoe", "name": "John Doe" },
        "scheduleStatus": null,
        "reminder": null,
        "where": null,
        "dateStart": "2026-03-01T10:00:00.000Z",
        "dateEnd": "2026-03-01T10:30:00.000Z",
        "notifyFlag": false,
        "currency": null,
        "mobileGuid": null,
        "customFields": [],
        "cwLastUpdated": "2026-02-26T10:00:00.000Z",
        "cwDateEntered": "2026-02-20T09:00:00.000Z",
        "cwEnteredBy": "JDoe",
        "cwUpdatedBy": "JDoe"
      }
    ]
  },
  "successful": true
}
```

---

### 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,
    "expectedSalesTax": 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/opportunity/:identifier`

Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company, contact, site, description). Only the provided fields are patched; omitted fields remain unchanged. The updated opportunity is synced back to the local database and returned in the response.

**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,
    "expectedSalesTax": 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
}
```

---

### Delete Opportunity

**DELETE** `/sales/opportunities/opportunity/:identifier`

Delete an opportunity from ConnectWise and the local database. All related Redis caches (activities, notes, contacts, products, CW data) are invalidated.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.delete`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

**Response:**

```json
{
  "status": 200,
  "message": "Opportunity deleted successfully!",
  "successful": true
}
```

| Status  | Description                                        |
| ------- | -------------------------------------------------- |
| 200     | Opportunity deleted successfully                   |
| 401     | Missing or invalid auth token                      |
| 403     | User lacks `sales.opportunity.delete` permission   |
| 404     | Opportunity not found                              |
| 4xx/5xx | ConnectWise API error (forwarded status + message) |
| 500     | Unexpected server error                            |

---

### Get Opportunity Products

**GET** `/sales/opportunities/opportunity/:identifier/products`

Fetch products for an opportunity, scoped to items that have a matching ConnectWise procurement product (`forecastDetailId` link). Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Hot opportunities (updated within 3 days) have a 15-second TTL; others use a 30-minute lazy TTL. Products are returned sorted by the opportunity's local `productSequence` array when set; otherwise, items are sorted by their ConnectWise `sequenceNumber`.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.fetch`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

**Response:**

```json
{
  "status": 200,
  "message": "Opportunity products fetched successfully!",
  "data": [
    {
      "id": 31846,
      "forecastDescription": "Service",
      "opportunity": { "id": 5150, "name": "Example Opportunity" },
      "quantity": 1,
      "status": { "id": 24, "name": "01. New" },
      "cancelled": false,
      "cancellationType": null,
      "quantityCancelled": 0,
      "cancelledReason": null,
      "cancelledDate": null,
      "catalogItem": {
        "id": 3756,
        "identifier": "Labor & Installation - Field"
      },
      "productDescription": "Labor & Installation - Field",
      "productClass": "Service",
      "forecastType": "Service",
      "revenue": 650000,
      "cost": 0,
      "margin": 650000,
      "profit": 650000,
      "percentage": 100,
      "includeFlag": true,
      "linkFlag": true,
      "recurringFlag": false,
      "taxableFlag": true,
      "recurringRevenue": 0,
      "recurringCost": 0,
      "cycles": 0,
      "sequenceNumber": 1,
      "subNumber": 0,
      "cwLastUpdated": "2026-02-28T20:57:52.000Z",
      "cwUpdatedBy": "jroberts",
      "onHand": 12,
      "inStock": true
    }
  ],
  "successful": true
}
```

**Cancellation Fields:**

Product cancellation data is sourced from the ConnectWise procurement products endpoint (not the forecast endpoint). Each product includes:

| Field               | Type        | Description                                                                             |
| ------------------- | ----------- | --------------------------------------------------------------------------------------- |
| `cancelled`         | boolean     | Whether the product has been cancelled (fully or partially)                             |
| `cancellationType`  | string/null | `"full"` if all units cancelled, `"partial"` if some cancelled, `null` if not cancelled |
| `quantityCancelled` | number      | Number of units cancelled                                                               |
| `cancelledReason`   | string/null | Reason for cancellation (if provided)                                                   |
| `cancelledDate`     | string/null | ISO 8601 timestamp of when the item was cancelled                                       |

**Inventory Fields:**

Internal inventory data is sourced from the local CatalogItem database. If the product's catalog item exists locally, these fields are populated; otherwise they are `null`.

| Field     | Type         | Description                                          |
| --------- | ------------ | ---------------------------------------------------- |
| `onHand`  | number/null  | Number of units currently on hand in local inventory |
| `inStock` | boolean/null | Whether the item is in stock (`onHand > 0`)          |

---

### Resequence Opportunity Products

**PATCH** `/sales/opportunities/opportunity/:identifier/products/sequence`

Update the display order of products (forecast items) on an opportunity. The sequence is stored **locally** in the database (`productSequence` field on the Opportunity model) — no modifications are made to ConnectWise. This means forecast item IDs remain stable and procurement product linkages are unaffected.

When a `productSequence` is set, `GET .../products` returns items in that order. Any forecast items not included in the array (e.g. newly added items) are appended at the end in CW `sequenceNumber` order.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.product.update`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

**Request Body:**

```json
{
  "orderedIds": [31848, 31846, 31847]
}
```

- `orderedIds` — Array of CW forecast item IDs in the desired display order. All IDs must exist on the opportunity's forecast in ConnectWise.

**Response:**

```json
{
  "status": 200,
  "message": "Product sequence updated successfully!",
  "data": {
    "products": [
      {
        "id": 31848,
        "forecastDescription": "Hardware",
        "sequenceNumber": 3,
        "..."
      },
      {
        "id": 31846,
        "forecastDescription": "Service",
        "sequenceNumber": 1,
        "..."
      },
      {
        "id": 31847,
        "forecastDescription": "Licensing",
        "sequenceNumber": 2,
        "..."
      }
    ]
  },
  "successful": true
}
```

- `data.products` — Full product objects in the new display order. IDs are unchanged — CW `sequenceNumber` still reflects the original CW order, but the array order matches the locally stored sequence.

---

### Edit Opportunity Product

**PATCH** `/sales/opportunities/opportunity/:identifier/products/:productId/edit`

Edit a product line item on an opportunity. This route supports forecast-backed fields and procurement-backed fields (including custom fields for narrative/notes).

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.product.update`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
- `productId` — Forecast item ID (numeric)

**Request Body:**

At least one field is required.

```json
{
  "productDescription": "Labor & Installation - Field (Updated)",
  "quantity": 2,
  "unitPrice": 125,
  "unitCost": 62.5,
  "customerDescription": "Onsite labor for rack install",
  "productNarrative": "Install, cable, and validate cutover",
  "procurementNotes": "Coordinate site contact before arrival",
  "taxableFlag": true
}
```

| Field                 | Type    | Description                                                |
| --------------------- | ------- | ---------------------------------------------------------- |
| `productDescription`  | string  | Product description                                        |
| `quantity`            | number  | Quantity                                                   |
| `unitPrice`           | number  | Unit price (maps to procurement `price`, forecast revenue) |
| `unitCost`            | number  | Unit cost (maps to procurement `cost`, forecast cost)      |
| `customerDescription` | string  | Customer-facing description                                |
| `productNarrative`    | string  | Custom field `Product Narrative` (`id: 46`)                |
| `procurementNotes`    | string  | Custom field `Procurement Notes` (`id: 29`)                |
| `taxableFlag`         | boolean | Whether this item is taxable (forecast field)              |

**Response:**

```json
{
  "status": 200,
  "message": "Product updated successfully!",
  "data": {
    "id": 32281,
    "productDescription": "Labor & Installation - Field (Updated)",
    "quantity": 2,
    "unitPrice": 125,
    "unitCost": 62.5,
    "customerDescription": "Onsite labor for rack install",
    "productNarrative": "Install, cable, and validate cutover",
    "procurementNotes": "Coordinate site contact before arrival"
  },
  "successful": true
}
```

---

### Cancel / Uncancel Opportunity Product

**PATCH** `/sales/opportunities/opportunity/:identifier/products/:productId/cancel`

Set cancellation state for a product line item using procurement cancellation fields.

- `quantityCancelled = 0` → item is treated as **uncancelled**
- `quantityCancelled > 0 && < quantity` → **partial** cancellation
- `quantityCancelled >= quantity` → **full** cancellation

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.product.update`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
- `productId` — Forecast item ID (numeric)

**Request Body:**

```json
{
  "quantityCancelled": 1,
  "cancellationReason": "Out of stock"
}
```

| Field                | Type             | Required | Description                                                   |
| -------------------- | ---------------- | -------- | ------------------------------------------------------------- |
| `quantityCancelled`  | number (integer) | Yes      | Number of units to cancel. Use `0` to uncancel the line item. |
| `cancellationReason` | string \| null   | No       | Optional reason that is passed through to ConnectWise.        |

**Validation Rules:**

- `quantityCancelled` must be >= `0`
- `quantityCancelled` cannot exceed the line item's quantity

**Response:**

```json
{
  "status": 200,
  "message": "Product cancellation updated successfully!",
  "data": {
    "id": 32281,
    "quantity": 2,
    "cancelled": true,
    "cancellationType": "partial",
    "quantityCancelled": 1,
    "cancelledReason": "Out of stock",
    "cancelledDate": "2026-03-04T00:00:00.000Z"
  },
  "successful": true
}
```

---

### Delete Product from Opportunity

**DELETE** `/sales/opportunities/opportunity/:identifier/products/:productId`

Remove a forecast item (product) from an opportunity in ConnectWise. The item is also removed from the local `productSequence` array and the products cache is invalidated.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.product.delete`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
- `productId` — ConnectWise forecast item ID (positive integer)

**Response:**

```json
{
  "status": 200,
  "message": "Product deleted from opportunity successfully!",
  "successful": true
}
```

| Status  | Description                                              |
| ------- | -------------------------------------------------------- |
| 200     | Product deleted successfully                             |
| 400     | Invalid productId                                        |
| 401     | Missing or invalid auth token                            |
| 403     | User lacks `sales.opportunity.product.delete` permission |
| 404     | Opportunity or forecast item not found                   |
| 4xx/5xx | ConnectWise API error (forwarded status + message)       |
| 500     | Unexpected server error                                  |

---

### Add Product to Opportunity

**POST** `/sales/opportunities/opportunity/:identifier/products`

Add a new product (forecast item) to an opportunity in ConnectWise. The request body is validated with Zod, then each submitted field is gated against `sales.opportunity.product.field.<field>` permissions — only fields the user has permission for are forwarded to ConnectWise.

After creation, the new forecast item ID is appended to the opportunity's local `productSequence` array in the database (if not already present) so display ordering remains stable.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.product.add`

**Field-Level Permission Gating:** Yes — uses `processObjectValuePerms` with scope `sales.opportunity.product.field` on the **input body**. See the field-level permissions table under `sales.opportunity.product.add` in PERMISSIONS.md.

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

**Request Body:**

All fields are optional. Only fields the user has the corresponding `sales.opportunity.product.field.<field>` permission for will be sent to ConnectWise.

```json
{
  "catalogItem": { "id": 1234 },
  "forecastDescription": "Managed Services",
  "productDescription": "Monthly managed services agreement",
  "quantity": 1,
  "status": { "id": 1 },
  "productClass": "Agreement",
  "forecastType": "Product",
  "revenue": 500.0,
  "cost": 250.0,
  "includeFlag": true,
  "linkFlag": false,
  "recurringFlag": true,
  "taxableFlag": true,
  "recurringRevenue": 500.0,
  "recurringCost": 250.0,
  "cycles": 12,
  "sequenceNumber": 1
}
```

| Field                 | Type             | Description                                      |
| --------------------- | ---------------- | ------------------------------------------------ |
| `catalogItem`         | `{ id: number }` | ConnectWise catalog item reference               |
| `forecastDescription` | string           | Forecast description text                        |
| `productDescription`  | string           | Product description text                         |
| `quantity`            | number           | Quantity (must be positive)                      |
| `status`              | `{ id: number }` | ConnectWise status reference                     |
| `productClass`        | string           | Product class (e.g. Product, Service, Agreement) |
| `forecastType`        | string           | Forecast type                                    |
| `revenue`             | number           | Revenue amount                                   |
| `cost`                | number           | Cost amount                                      |
| `includeFlag`         | boolean          | Whether to include in forecast totals            |
| `linkFlag`            | boolean          | Whether the item is linked                       |
| `recurringFlag`       | boolean          | Whether this is a recurring item                 |
| `taxableFlag`         | boolean          | Whether this item is taxable                     |
| `recurringRevenue`    | number           | Recurring revenue amount                         |
| `recurringCost`       | number           | Recurring cost amount                            |
| `cycles`              | number           | Number of recurring cycles (integer, min 0)      |
| `sequenceNumber`      | number           | Display sequence number (integer, min 0)         |

**Response:**

```json
{
  "status": 201,
  "message": "Product added to opportunity successfully!",
  "data": {
    "id": 31855,
    "forecastDescription": "Managed Services",
    "opportunity": { "id": 5678, "name": "Example Opportunity" },
    "quantity": 1,
    "status": { "id": 1, "name": "Open" },
    "catalogItem": { "id": 1234, "identifier": "MSP-001" },
    "productDescription": "Monthly managed services agreement",
    "productClass": "Agreement",
    "forecastType": "Product",
    "revenue": 500.0,
    "cost": 250.0,
    "margin": 250.0,
    "profit": 250.0,
    "percentage": 0,
    "includeFlag": true,
    "linkFlag": false,
    "recurringFlag": true,
    "taxableFlag": true,
    "recurringRevenue": 500.0,
    "recurringCost": 250.0,
    "cycles": 12,
    "sequenceNumber": 1,
    "subNumber": 0,
    "cwLastUpdated": "2026-03-01T00:00:00.000Z",
    "cwUpdatedBy": "Admin1",
    "cancelled": false,
    "cancellationType": null,
    "quantityCancelled": 0,
    "cancelledReason": null,
    "cancelledDate": null,
    "onHand": null,
    "inStock": null
  },
  "successful": true
}
```

---

### Add SPECIAL ORDER Product

**POST** `/sales/opportunities/opportunity/:identifier/products/special-order`

Add one or more products as **SPECIAL ORDER** procurement line items. This route creates ConnectWise procurement products (not forecast items) and enforces stable defaults for quick-entry workflows.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.product.add.specialOrder`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

**Request Body:**

Accepts either a single object or an array of objects.

```json
{
  "desc": "SPECIAL ORDER - Lead time confirmed",
  "customerDesc": "Customer-facing special order description",
  "qty": 1,
  "price": 750,
  "cost": 500,
  "taxable": true,
  "procurementNotes": "Vendor ETA pending confirmation",
  "productNarrative": "Install with existing rack accessories"
}
```

| Field              | Type    | Required | Description                                               |
| ------------------ | ------- | -------- | --------------------------------------------------------- |
| `desc`             | string  | Yes      | Internal/sales line description                           |
| `customerDesc`     | string  | No       | Customer-facing line description                          |
| `qty`              | number  | No       | Quantity (defaults to `1` when omitted)                   |
| `price`            | number  | Yes      | Revenue amount                                            |
| `cost`             | number  | No       | Cost amount                                               |
| `taxable`          | boolean | No       | Taxable flag (defaults to the catalog item's tax setting) |
| `procurementNotes` | string  | No       | Maps to custom field `id: 29`                             |
| `productNarrative` | string  | No       | Maps to custom field `id: 46`                             |

**Route-Enforced Defaults:**

- `catalogItem` is always set to the canonical catalog item with identifier `SPECIAL ORDER`
- `description` is always set from `desc`
- `customerDescription` is always set from `customerDesc` when provided
- `quantity` is always set from `qty` (default `1`)
- `price` is always set from `price`
- `cost` is always set from `cost` when provided
- `dropshipFlag` is always set to `false`
- `billableOption` is always set to `Billable`
- `taxableFlag` is set from `taxable` (or `taxableFlag`), defaulting to the catalog item's `salesTaxable` value
- `customFields` are auto-built when notes are provided:
  - `procurementNotes` → `Procurement Notes` (`id: 29`)
  - `productNarrative` → `Product Narrative` (`id: 46`)
- When CW returns `forecastDetailId`, it is appended to the opportunity's local `productSequence` array in the database (if not already present)

**Response:**

```json
{
  "status": 201,
  "message": "Special-order product added successfully!",
  "data": {
    "id": 88340,
    "forecastDetailId": 32280,
    "description": "SPECIAL ORDER - Lead time confirmed",
    "customerDescription": "Customer-facing special order description",
    "quantity": 1,
    "price": 750,
    "cost": 500,
    "taxableFlag": true
  },
  "successful": true
}
```

---

### Get Labor Product Options

**GET** `/sales/opportunities/opportunity/:identifier/products/labor/options`

Fetch the resolved **Field** and **Tech** labor catalog products plus default labor pricing metadata so the UI can hydrate the labor-entry form without hardcoding catalog IDs.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.product.add.labor`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

**Response:**

```json
{
  "status": 200,
  "message": "Labor product options fetched successfully!",
  "data": {
    "defaults": {
      "customerType": "corporate",
      "rates": {
        "corporate": 100,
        "residential": 85
      },
      "cpuMultiplier": 0.5,
      "quantity": 1
    },
    "options": {
      "field": {
        "cwCatalogId": 3756,
        "identifier": "Labor & Installation - Field",
        "name": "Labor & Installation - Field",
        "taxableFlag": true
      },
      "tech": {
        "cwCatalogId": 3757,
        "identifier": "Labor & Installation - Tech",
        "name": "Labor & Installation - Tech",
        "taxableFlag": true
      }
    }
  },
  "successful": true
}
```

---

### Add Labor Product

**POST** `/sales/opportunities/opportunity/:identifier/products/labor`

Add a labor line item to an opportunity using one of the two canonical labor catalog products (**Field** or **Tech**). The route resolves both labor products from the local catalog, then picks the selected style and creates a ConnectWise procurement product.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.product.add.labor`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

**Request Body:**

```json
{
  "laborStyle": "field",
  "customerType": "corporate",
  "hours": 1,
  "taxable": true,
  "rate": 100,
  "ppu": 100,
  "cpu": 50,
  "procurementNotes": "Schedule with PM before install",
  "productNarrative": "Install and validate onsite",
  "customerDescription": "Onsite installation labor"
}
```

| Field                 | Type                           | Required | Description                                                                |
| --------------------- | ------------------------------ | -------- | -------------------------------------------------------------------------- |
| `laborStyle`          | `"field" \| "tech"`            | Yes      | Chooses which labor catalog product to use                                 |
| `customerType`        | `"corporate" \| "residential"` | No       | Selects default rate (`corporate=100`, `residential=85`) when rate omitted |
| `hours`               | number                         | No       | Quantity/hours (defaults to `1`)                                           |
| `rate`                | number                         | No       | Hourly labor rate used when `ppu` is not provided                          |
| `ppu`                 | number                         | No       | Price per unit/hour (overrides `rate`)                                     |
| `cpu`                 | number                         | No       | Cost per unit/hour (defaults to `50%` of selected price)                   |
| `taxable`             | boolean                        | No       | Taxable flag override                                                      |
| `taxableFlag`         | boolean                        | No       | Alternate taxable flag input (same behavior as `taxable`)                  |
| `description`         | string                         | No       | Internal line description override                                         |
| `customerDescription` | string                         | No       | Customer-facing description                                                |
| `procurementNotes`    | string                         | No       | Maps to custom field `Procurement Notes` (`id: 29`)                        |
| `productNarrative`    | string                         | No       | Maps to custom field `Product Narrative` (`id: 46`)                        |

**Route-Enforced Defaults / Behavior:**

- Resolves both labor products from local catalog and uses `laborStyle` to select one
- Uses `customerType` defaults when `rate/ppu/cpu` are not supplied
- Sets `quantity` from `hours` (default `1`)
- Sets `price` from selected `ppu`, `cost` from selected `cpu`
- Sets `dropshipFlag` to `false` and `billableOption` to `Billable`
- Sets taxable flag from `taxable`/`taxableFlag`, falling back to the selected catalog item's tax setting
- When CW returns `forecastDetailId`, it is appended to the opportunity's local `productSequence` array in the database (if not already present)

**Response:**

```json
{
  "status": 201,
  "message": "Labor added to opportunity successfully!",
  "data": {
    "id": 88341,
    "forecastDetailId": 32281,
    "laborStyle": "field",
    "customerType": "corporate",
    "catalogItem": {
      "id": 3756,
      "identifier": "Labor & Installation - Field",
      "name": "Labor & Installation - Field"
    },
    "description": "Labor & Installation - Field",
    "customerDescription": "Onsite installation labor",
    "quantity": 1,
    "rate": 100,
    "ppu": 100,
    "cpu": 50,
    "revenue": 100,
    "cost": 50,
    "taxableFlag": true,
    "procurementNotes": "Schedule with PM before install",
    "productNarrative": "Install and validate onsite"
  },
  "successful": true
}
```

---

### Fetch Committed Quotes

**GET** `/sales/opportunities/opportunity/: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/opportunity/:identifier/quote/commit`

Generate a finalized (non-preview) quote PDF for an opportunity and store it in the database with regeneration metadata and creator attribution.

**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/opportunity/:identifier/quote/:quoteId/preview`

Regenerate a preview-stamped version of an existing committed quote PDF using its stored generation parameters. The PDF is not persisted — it is returned as a base64-encoded string.

**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/opportunity/:identifier/quote/:quoteId/download`

Download a committed quote PDF by its ID. Returns the PDF file as a base64-encoded string. Each call automatically records a download entry with the timestamp, user info, and fetch action in the quote's `downloads` array. Download-time metadata (downloadedAt, downloadedBy) is also injected into the PDF's document properties.

**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/opportunity/:identifier/quotes/downloads`

Fetch download/print history for all committed quotes on an opportunity. Returns an array of quote summaries, each containing the full `downloads` array with every download/print record. This is an admin-level route intended for audit and tracking purposes.

**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/opportunity/:identifier/notes`

Fetch notes for an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when notes are created, updated, or deleted.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.fetch`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

**Response:**

```json
{
  "status": 200,
  "message": "Opportunity notes fetched successfully!",
  "data": [
    {
      "id": 1,
      "text": "Client expressed interest in a full network refresh.",
      "type": { "id": 2, "name": "Discussion" },
      "flagged": false,
      "enteredBy": {
        "id": "clx1abc123",
        "identifier": "jdoe",
        "name": "John Doe",
        "cwMemberId": 10
      }
    }
  ],
  "successful": true
}
```

---

### Get Single Opportunity Note

**GET** `/sales/opportunities/opportunity/:identifier/notes/:noteId`

Fetch a single note by its ConnectWise note ID for an opportunity.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.fetch`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
- `noteId` — The ConnectWise note ID (numeric)

**Response:**

```json
{
  "status": 200,
  "message": "Opportunity note fetched successfully!",
  "data": {
    "id": 1,
    "text": "Client expressed interest in a full network refresh.",
    "type": { "id": 2, "name": "Discussion" },
    "flagged": false,
    "enteredBy": {
      "id": "clx1abc123",
      "identifier": "jdoe",
      "name": "John Doe",
      "cwMemberId": 10
    }
  },
  "successful": true
}
```

---

### Create Opportunity Note

**POST** `/sales/opportunities/opportunity/:identifier/notes`

Create a new note on an opportunity in ConnectWise.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.note.create`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

**Request Body:**

```json
{
  "text": "Follow up with client about pricing.",
  "flagged": false
}
```

| Field     | Type    | Required | Description                     |
| --------- | ------- | -------- | ------------------------------- |
| `text`    | string  | Yes      | The note text (min 1 character) |
| `flagged` | boolean | No       | Whether the note is flagged     |

**Response:**

```json
{
  "status": 201,
  "message": "Opportunity note created successfully!",
  "data": {
    "id": 42,
    "text": "Follow up with client about pricing.",
    "type": null,
    "flagged": false,
    "enteredBy": {
      "id": "clx2def456",
      "identifier": "jroberts",
      "name": "John Roberts",
      "cwMemberId": 15
    }
  },
  "successful": true
}
```

---

### Update Opportunity Note

**PATCH** `/sales/opportunities/opportunity/:identifier/notes/:noteId`

Update an existing note on an opportunity in ConnectWise.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.note.update`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
- `noteId` — The ConnectWise note ID (numeric)

**Request Body:**

```json
{
  "text": "Updated note text.",
  "flagged": true
}
```

| Field     | Type    | Required | Description                         |
| --------- | ------- | -------- | ----------------------------------- |
| `text`    | string  | No       | Updated note text (min 1 character) |
| `flagged` | boolean | No       | Updated flagged state               |

> At least one of `text` or `flagged` must be provided.

**Response:**

```json
{
  "status": 200,
  "message": "Opportunity note updated successfully!",
  "data": {
    "id": 42,
    "text": "Updated note text.",
    "type": null,
    "flagged": true,
    "enteredBy": {
      "id": "clx2def456",
      "identifier": "jroberts",
      "name": "John Roberts",
      "cwMemberId": 15
    }
  },
  "successful": true
}
```

---

### Delete Opportunity Note

**DELETE** `/sales/opportunities/opportunity/:identifier/notes/:noteId`

Delete a note from an opportunity in ConnectWise.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.note.delete`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)
- `noteId` — The ConnectWise note ID (numeric)

**Response:**

```json
{
  "status": 200,
  "message": "Opportunity note deleted successfully!",
  "successful": true
}
```

---

### Get Opportunity Contacts

**GET** `/sales/opportunities/opportunity/:identifier/contacts`

Fetch contacts associated with an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when contacts are created, updated, or deleted.

**Authentication Required:** Yes

**Required Permissions:** `sales.opportunity.fetch`

**Path Parameters:**

- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric)

**Response:**

```json
{
  "status": 200,
  "message": "Opportunity contacts fetched successfully!",
  "data": [
    {
      "id": 1,
      "contact": { "id": 200, "name": "Jane Smith" },
      "company": {
        "id": 100,
        "identifier": "AcmeCorp",
        "name": "Acme Corp"
      },
      "role": { "id": 1, "name": "Decision Maker" },
      "notes": "Primary point of contact for this deal",
      "referralFlag": false
    }
  ],
  "successful": true
}
```

---

## Opportunity Workflow (Internal Engine)

The opportunity workflow system is an internal engine that manages the lifecycle of opportunities through a defined set of statuses and transitions. Workflow actions are invoked programmatically via `processOpportunityAction()` from [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts), and are exposed to the UI via the HTTP API routes documented below.

**Stage gate:** All workflow actions require the opportunity's `stageName` to be `"Optima"`. Actions on opportunities in any other stage are rejected.

### Statuses

| Enum Key        | CW Status ID | CW Name             | Terminal | Notes                                                             |
| --------------- | ------------ | ------------------- | -------- | ----------------------------------------------------------------- |
| PendingNew      | 37           | 00. Pending New     | No       | Default status before acceptance                                  |
| New             | 24           | 01. New             | No       | Setup phase — assembling line items, discounts, etc.              |
| InternalReview  | 56           | 02. Internal Review | No       | Flagged for internal review (manual or cold-detection automation) |
| QuoteSent       | 43           | 03. Quote Sent      | No       | Quote has been sent to the customer                               |
| ConfirmedQuote  | 57           | 04. Confirmed Quote | No       | Customer acknowledged receipt                                     |
| Active          | 58           | 05. Active          | No       | Quote in revision/flux                                            |
| ReadyToSend     | 63           | Ready to Send       | No       | Review approved; ready for rep send                               |
| PendingSent     | 60           | Pending Sent        | No       | Deprecated legacy status; existing records may still use it       |
| PendingRevision | 61           | Pending Revision    | No       | Review rejected, awaiting rep revision                            |
| PendingWon      | 49           | 91. Pending Won     | No       | Won pending finalization by authorized user                       |
| Won             | 29           | 95. Won             | Yes      | Final positive outcome — immutable                                |
| PendingLost     | 50           | 98. Pending Lost    | No       | Lost pending finalization or resurrection                         |
| Lost            | 53           | 99. Lost            | Yes      | Final negative outcome — immutable                                |
| Canceled        | 59           | Canceled            | No\*     | Not pursued; can be re-opened to Active                           |

### Transition Map

| From            | Allowed Targets                                                                 |
| --------------- | ------------------------------------------------------------------------------- |
| PendingNew      | New                                                                             |
| New             | InternalReview, QuoteSent, Canceled                                             |
| InternalReview  | ReadyToSend (approve), PendingRevision (reject), QuoteSent (send), Canceled     |
| ReadyToSend     | QuoteSent                                                                       |
| PendingSent     | QuoteSent _(deprecated legacy path for existing records only)_                  |
| PendingRevision | Active                                                                          |
| QuoteSent       | ConfirmedQuote, Won/PendingWon, PendingLost, Active, InternalReview (cold auto) |
| ConfirmedQuote  | Won/PendingWon, PendingLost, Active, InternalReview (cold auto)                 |
| Active          | QuoteSent, InternalReview, Canceled                                             |
| PendingWon      | Won                                                                             |
| PendingLost     | Lost, Active (resurrection)                                                     |
| Won             | _(terminal — no transitions)_                                                   |
| Lost            | _(terminal — no transitions)_                                                   |
| Canceled        | Active (re-open)                                                                |

### Workflow Actions

| Action         | Description                                                                 | Required Permission                          | Note Required |
| -------------- | --------------------------------------------------------------------------- | -------------------------------------------- | ------------- |
| acceptNew      | PendingNew → New                                                            | —                                            | No            |
| requestReview  | → InternalReview (manual)                                                   | —                                            | Yes           |
| reviewDecision | InternalReview → approve/reject/send/cancel                                 | `sales.opportunity.cancel` (cancel only)     | Yes           |
| sendQuote      | → QuoteSent (with compound flags: won, lost, needsRevision, quoteConfirmed) | `sales.opportunity.finalize` (won flag only) | No            |
| confirmQuote   | QuoteSent → ConfirmedQuote                                                  | —                                            | No            |
| finalize       | → Won/Lost (or PendingWon/PendingLost without finalize permission)          | `sales.opportunity.finalize`                 | Yes           |
| resurrect      | PendingLost → Active                                                        | —                                            | Yes           |
| beginRevision  | PendingRevision → Active                                                    | —                                            | No            |
| resendQuote    | Active → QuoteSent (re-send after revision)                                 | —                                            | No            |
| cancel         | → Canceled                                                                  | `sales.opportunity.cancel`                   | Yes           |
| reopen         | Canceled → Active                                                           | —                                            | Yes           |

### CW Activity Custom Field — Optima_Type

Every workflow activity is tagged with an `Optima_Type` custom field (CW field ID 45) to identify its type without parsing notes:

| Value                  | Used For                                                      |
| ---------------------- | ------------------------------------------------------------- |
| Opportunity Created    | Initial opportunity creation                                  |
| Opportunity Setup      | New/setup phase activities (stays open until next transition) |
| Opportunity Review     | Review submissions (stays open until next transition)         |
| Quote Sent             | Quote-sent activities                                         |
| Quote Confirmed        | Standalone quote confirmation activities                      |
| Quote Sent & Confirmed | Combined send + confirm in a single action                    |
| Quote Generated        | Quote commit/generation activities                            |
| Revision               | Revision activities (stays open until next transition)        |
| Finalized              | PendingWon, PendingLost, Lost, and cancel activities          |
| Converted              | Won (finalized) activities                                    |

### Cold Detection

The cold-detection algorithm ([src/modules/algorithms/algo.coldThreshold.ts](src/modules/algorithms/algo.coldThreshold.ts)) evaluates stall thresholds:

- **QuoteSent**: 14 days with no activity → auto-transition to InternalReview
- **ConfirmedQuote**: 30 days with no activity → auto-transition to InternalReview

Triggered via `triggerColdDetection()` (intended for schedulers/automation, not user actions).

### Supporting Modules

| File                                                                                                 | Purpose                                                                                       |
| ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| [src/modules/algorithms/algo.coldThreshold.ts](src/modules/algorithms/algo.coldThreshold.ts)         | Cold-detection config and `checkColdStatus()`                                                 |
| [src/modules/algorithms/algo.followUpScheduler.ts](src/modules/algorithms/algo.followUpScheduler.ts) | Follow-up scheduling (placeholder: next business day at 10am)                                 |
| [src/services/cw.opportunityService.ts](src/services/cw.opportunityService.ts)                       | CW integration stubs: `submitTimeEntry()`, `createScheduleEntry()`, `syncOpportunityStatus()` |

### Workflow API Routes

These routes expose the opportunity workflow to the UI. They live under `/v1/sales/opportunities/opportunity/:identifier/workflow`.

---

#### `GET /v1/sales/opportunities/opportunity/:identifier/workflow`

Fetch the current workflow state for an opportunity — current status, stage, available actions, cold-detection result, and whether each action is permitted for the authenticated user.

**Auth:** Bearer token required
**Permission:** `sales.opportunity.fetch`
**Source:** [src/api/sales/opportunities/[id]/workflow/status.ts](src/api/sales/opportunities/[id]/workflow/status.ts)

**Response (200):**

```json
{
  "message": "Workflow status fetched successfully.",
  "status": 200,
  "data": {
    "currentStatusId": 43,
    "currentStatus": "QuoteSent",
    "stageName": "Optima",
    "isOptimaStage": true,
    "isTerminal": false,
    "availableActions": [
      {
        "action": "confirmQuote",
        "label": "Confirm Quote Receipt",
        "targetStatuses": [{ "key": "ConfirmedQuote", "id": 57 }],
        "requiresNote": false,
        "requiresPermission": null,
        "permitted": true
      },
      {
        "action": "finalize",
        "label": "Mark as Won",
        "targetStatuses": [
          { "key": "Won", "id": 29 },
          { "key": "PendingWon", "id": 49 }
        ],
        "requiresNote": true,
        "requiresPermission": null,
        "payloadHints": { "outcome": "\"won\"" },
        "permitted": true
      }
    ],
    "coldCheck": null
  }
}
```

> When `isOptimaStage` is `false`, `availableActions` is always an empty array — no workflow actions are permitted outside the Optima stage.

---

#### `POST /v1/sales/opportunities/opportunity/:identifier/workflow`

Execute a workflow action. Accepts a discriminated union of `{ action, payload }` and routes it through the workflow engine. The body is validated with Zod.

**Auth:** Bearer token required
**Permission:** `sales.opportunity.workflow` (base gate). Additionally:

- `sales.opportunity.finalize` — for `finalize` action producing Won/Lost
- `sales.opportunity.cancel` — for `cancel` action and `reviewDecision` with `decision: "cancel"`

**Source:** [src/api/sales/opportunities/[id]/workflow/dispatch.ts](src/api/sales/opportunities/[id]/workflow/dispatch.ts)

**Request body:**

```json
{
  "action": "sendQuote",
  "payload": {
    "note": "Sending quote to customer after review.",
    "timeSpent": 30,
    "quoteConfirmed": false,
    "won": false,
    "lost": false,
    "needsRevision": false
  }
}
```

<details>
<summary><strong>All action types and their payloads</strong></summary>

| Action           | Required Payload Fields                 | Optional Payload Fields                                               | Description                                                     |
| ---------------- | --------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------- |
| `acceptNew`      | —                                       | `note`, `timeSpent`                                                   | PendingNew → New                                                |
| `requestReview`  | `note`                                  | `timeSpent`                                                           | New/Active → InternalReview                                     |
| `reviewDecision` | `note`, `decision`                      | `timeSpent`                                                           | InternalReview → ReadyToSend/PendingRevision/QuoteSent/Canceled |
| `sendQuote`      | —                                       | `note`, `timeSpent`, `quoteConfirmed`, `won`, `lost`, `needsRevision` | ReadyToSend/New → QuoteSent (+ compound transitions)            |
| `confirmQuote`   | —                                       | `note`, `timeSpent`                                                   | QuoteSent → ConfirmedQuote                                      |
| `finalize`       | `note`, `outcome` (`"won"` or `"lost"`) | `timeSpent`                                                           | Pending → Won/Lost (or direct if permitted)                     |
| `resurrect`      | `note`                                  | `timeSpent`                                                           | PendingLost → Active                                            |
| `beginRevision`  | —                                       | `note`, `timeSpent`                                                   | PendingRevision → Active                                        |
| `resendQuote`    | —                                       | `note`, `timeSpent`, `quoteConfirmed`, `won`, `lost`, `needsRevision` | Active → QuoteSent (+ compound transitions)                     |
| `cancel`         | `note`                                  | `timeSpent`                                                           | New/Active → Canceled (requires cancel perm)                    |
| `reopen`         | `note`                                  | `timeSpent`                                                           | Canceled → Active                                               |

`decision` values for `reviewDecision`: `"approve"`, `"reject"`, `"send"`, `"cancel"`

</details>

**Response (200) — success:**

```json
{
  "message": "Workflow action completed successfully.",
  "status": 200,
  "data": {
    "previousStatusId": 63,
    "previousStatus": "ReadyToSend",
    "newStatusId": 43,
    "newStatus": "QuoteSent",
    "activitiesCreated": [ { "...activity JSON..." } ],
    "coldCheck": null
  }
}
```

**Response (422) — transition rejected:**

```json
{
  "message": "Workflow action failed.",
  "status": 422,
  "name": "WorkflowTransitionFailed",
  "data": {
    "previousStatusId": 29,
    "previousStatus": "Won",
    "newStatusId": null,
    "newStatus": null
  }
}
```

---

#### `GET /v1/sales/opportunities/opportunity/:identifier/workflow/history`

Fetch the workflow activity history for an opportunity. Returns only CW activities that have a valid `Optima_Type` custom field set, sorted newest first.

**Auth:** Bearer token required
**Permission:** `sales.opportunity.fetch`
**Source:** [src/api/sales/opportunities/[id]/workflow/history.ts](src/api/sales/opportunities/[id]/workflow/history.ts)

**Query parameters:**
| Param | Type | Description |
| ------ | ------ | ------------------------------------------------- |
| `type` | string | Filter by Optima_Type value (e.g. `"Quote Sent"`) |

**Response (200):**

```json
{
  "message": "Workflow history fetched successfully.",
  "status": 200,
  "data": {
    "opportunityId": "clx...",
    "cwOpportunityId": 12345,
    "totalActivities": 3,
    "activities": [
      {
        "activity": { "...activity JSON..." },
        "optimaType": "Quote Sent",
        "quoteId": "QUO-12345",
        "closed": true,
        "closedAt": "2026-03-09T05:40:00.000Z"
      }
    ]
  }
}
```

| Field        | Type           | Description                                                                                  |
| ------------ | -------------- | -------------------------------------------------------------------------------------------- |
| `activity`   | object         | Full CW activity JSON from `ActivityController.toJson()`                                     |
| `optimaType` | string         | The `Optima_Type` custom field value (e.g. `"Quote Sent"`, `"Opportunity Setup"`)            |
| `quoteId`    | string \| null | The `QuoteID` custom field value (CW field id 48), or `null` if not set                      |
| `closed`     | boolean        | `true` when the CW activity status is Closed (status id 2)                                   |
| `closedAt`   | string \| null | ISO-8601 timestamp from the `Close Date` custom field (CW field id 49), or `null` if not set |

---

## UniFi Routes

All UniFi routes require the `unifi.access` permission in addition to their route-specific permission. This acts as a gate for the entire UniFi API.

### Get All UniFi Sites

**GET** `/unifi/sites`

Fetch all UniFi site records from the database.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.sites.fetch.many`

**Field-Level Gating:** `obj.unifiSite` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**Response:**

```json
{
  "status": 200,
  "message": "UniFi Sites Fetched Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "Total Tech - Murray Office",
      "siteId": "km9b1v8i",
      "companyId": "ckx...",
      "company": {
        "id": "ckx...",
        "name": "Acme Corp"
      },
      "createdAt": "2025-01-01T00:00:00.000Z",
      "updatedAt": "2025-01-01T00:00:00.000Z"
    }
  ],
  "successful": true
}
```

---

### Sync UniFi Sites

**POST** `/unifi/sites/sync`

Synchronize sites from the UniFi controller into the database. Creates new records for sites not yet tracked and updates names for existing ones.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.sites.sync`

**Response:**

```json
{
  "status": 200,
  "message": "UniFi Sites Synced Successfully!",
  "data": [
    {
      "id": "ckx...",
      "name": "Total Tech - Murray Office",
      "siteId": "km9b1v8i",
      "companyId": null,
      "createdAt": "2025-01-01T00:00:00.000Z",
      "updatedAt": "2025-01-01T00:00:00.000Z"
    }
  ],
  "successful": true
}
```

---

### Create UniFi Site

**POST** `/unifi/sites/create`

Create a new site on the UniFi controller and track it in the database.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.sites.create`

**Request Body:**

```json
{
  "description": "New Office Site"
}
```

| Field         | Type   | Required | Description                                    |
| ------------- | ------ | -------- | ---------------------------------------------- |
| `description` | string | Yes      | Human-readable name / description for the site |

**Response:**

```json
{
  "status": 200,
  "message": "UniFi Site Created Successfully!",
  "data": {
    "id": "ckx...",
    "name": "New Office Site",
    "siteId": "abc123",
    "companyId": null,
    "createdAt": "2025-01-01T00:00:00.000Z",
    "updatedAt": "2025-01-01T00:00:00.000Z"
  },
  "successful": true
}
```

---

### Get UniFi Site

**GET** `/unifi/site/:id`

Fetch a single UniFi site record from the database by its internal ID.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.sites.fetch`

**Field-Level Gating:** `obj.unifiSite` — see [Object Type Field-Level Gating](#object-type-field-level-gating)

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Response:**

```json
{
  "status": 200,
  "message": "UniFi Site Fetched Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Total Tech - Murray Office",
    "siteId": "km9b1v8i",
    "companyId": "ckx...",
    "createdAt": "2025-01-01T00:00:00.000Z",
    "updatedAt": "2025-01-01T00:00:00.000Z"
  },
  "successful": true
}
```

---

### Get Site Overview

**GET** `/unifi/site/:id/overview`

Fetch live site overview data from the UniFi controller, including health status, system info, and site information.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.overview`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Response:**

```json
{
  "status": 200,
  "message": "UniFi Site Overview Fetched Successfully!",
  "data": {
    "health": [
      {
        "subsystem": "wan",
        "status": "ok",
        "numAdopted": 1,
        "numGateway": 1
      }
    ],
    "sysInfo": {
      "timezone": "America/Denver",
      "hostname": "UniFi-Controller",
      "version": "8.x.x"
    },
    "siteInfo": {
      "description": "Total Tech - Murray Office",
      "name": "km9b1v8i"
    }
  },
  "successful": true
}
```

---

### Get Site Devices

**GET** `/unifi/site/:id/devices`

Fetch live device list from the UniFi controller for a specific site.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.devices`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Response:**

```json
{
  "status": 200,
  "message": "UniFi Devices Fetched Successfully!",
  "data": [
    {
      "id": "abc123...",
      "mac": "00:11:22:33:44:55",
      "model": "U6-Pro",
      "name": "Office AP",
      "type": "uap",
      "state": "connected",
      "ip": "192.168.1.10",
      "version": "6.x.x",
      "uptime": 123456,
      "radios": [],
      "uplink": {}
    }
  ],
  "successful": true
}
```

---

### Get Site WiFi Networks

**GET** `/unifi/site/:id/wifi`

Fetch live WiFi network (WLAN) configurations from the UniFi controller for a specific site.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.wifi`

**Field-Level Gating:** `unifi.site.wifi.read.<field>`

This route uses `processObjectValuePerms` to filter each WLAN object on a per-field basis. Only fields whose corresponding `unifi.site.wifi.read.<field>` permission the user holds are included in the response. For example, a user with `unifi.site.wifi.read.name` and `unifi.site.wifi.read.enabled` but _without_ `unifi.site.wifi.read.passphrase` will receive objects containing only `name` and `enabled`. Use `unifi.site.wifi.read.*` to grant access to all fields.

<details>
<summary>All available field-level permission nodes</summary>

| Permission Node                                    | Field                         |
| -------------------------------------------------- | ----------------------------- |
| `unifi.site.wifi.read.id`                          | `id`                          |
| `unifi.site.wifi.read.name`                        | `name`                        |
| `unifi.site.wifi.read.siteId`                      | `siteId`                      |
| `unifi.site.wifi.read.enabled`                     | `enabled`                     |
| `unifi.site.wifi.read.security`                    | `security`                    |
| `unifi.site.wifi.read.wpaMode`                     | `wpaMode`                     |
| `unifi.site.wifi.read.wpaEnc`                      | `wpaEnc`                      |
| `unifi.site.wifi.read.wpa3Support`                 | `wpa3Support`                 |
| `unifi.site.wifi.read.wpa3Transition`              | `wpa3Transition`              |
| `unifi.site.wifi.read.wpa3FastRoaming`             | `wpa3FastRoaming`             |
| `unifi.site.wifi.read.wpa3Enhanced192`             | `wpa3Enhanced192`             |
| `unifi.site.wifi.read.passphrase`                  | `passphrase`                  |
| `unifi.site.wifi.read.passphraseAutogenerated`     | `passphraseAutogenerated`     |
| `unifi.site.wifi.read.hideSSID`                    | `hideSSID`                    |
| `unifi.site.wifi.read.isGuest`                     | `isGuest`                     |
| `unifi.site.wifi.read.band`                        | `band`                        |
| `unifi.site.wifi.read.bands`                       | `bands`                       |
| `unifi.site.wifi.read.networkconfId`               | `networkconfId`               |
| `unifi.site.wifi.read.usergroupId`                 | `usergroupId`                 |
| `unifi.site.wifi.read.apGroupIds`                  | `apGroupIds`                  |
| `unifi.site.wifi.read.apGroupMode`                 | `apGroupMode`                 |
| `unifi.site.wifi.read.pmfMode`                     | `pmfMode`                     |
| `unifi.site.wifi.read.groupRekey`                  | `groupRekey`                  |
| `unifi.site.wifi.read.dtimMode`                    | `dtimMode`                    |
| `unifi.site.wifi.read.dtimNg`                      | `dtimNg`                      |
| `unifi.site.wifi.read.dtimNa`                      | `dtimNa`                      |
| `unifi.site.wifi.read.dtim6e`                      | `dtim6e`                      |
| `unifi.site.wifi.read.l2Isolation`                 | `l2Isolation`                 |
| `unifi.site.wifi.read.fastRoamingEnabled`          | `fastRoamingEnabled`          |
| `unifi.site.wifi.read.bssTransition`               | `bssTransition`               |
| `unifi.site.wifi.read.uapsdEnabled`                | `uapsdEnabled`                |
| `unifi.site.wifi.read.iappEnabled`                 | `iappEnabled`                 |
| `unifi.site.wifi.read.proxyArp`                    | `proxyArp`                    |
| `unifi.site.wifi.read.mcastenhanceEnabled`         | `mcastenhanceEnabled`         |
| `unifi.site.wifi.read.macFilterEnabled`            | `macFilterEnabled`            |
| `unifi.site.wifi.read.macFilterPolicy`             | `macFilterPolicy`             |
| `unifi.site.wifi.read.macFilterList`               | `macFilterList`               |
| `unifi.site.wifi.read.radiusDasEnabled`            | `radiusDasEnabled`            |
| `unifi.site.wifi.read.radiusMacAuthEnabled`        | `radiusMacAuthEnabled`        |
| `unifi.site.wifi.read.radiusMacaclFormat`          | `radiusMacaclFormat`          |
| `unifi.site.wifi.read.minrateSettingPreference`    | `minrateSettingPreference`    |
| `unifi.site.wifi.read.minrateNgEnabled`            | `minrateNgEnabled`            |
| `unifi.site.wifi.read.minrateNgDataRateKbps`       | `minrateNgDataRateKbps`       |
| `unifi.site.wifi.read.minrateNgAdvertisingRates`   | `minrateNgAdvertisingRates`   |
| `unifi.site.wifi.read.minrateNaEnabled`            | `minrateNaEnabled`            |
| `unifi.site.wifi.read.minrateNaDataRateKbps`       | `minrateNaDataRateKbps`       |
| `unifi.site.wifi.read.minrateNaAdvertisingRates`   | `minrateNaAdvertisingRates`   |
| `unifi.site.wifi.read.settingPreference`           | `settingPreference`           |
| `unifi.site.wifi.read.no2ghzOui`                   | `no2ghzOui`                   |
| `unifi.site.wifi.read.privatePreSharedKeysEnabled` | `privatePreSharedKeysEnabled` |
| `unifi.site.wifi.read.privatePreSharedKeys`        | `privatePreSharedKeys`        |
| `unifi.site.wifi.read.saeGroups`                   | `saeGroups`                   |
| `unifi.site.wifi.read.saePsk`                      | `saePsk`                      |
| `unifi.site.wifi.read.schedule`                    | `schedule`                    |
| `unifi.site.wifi.read.scheduleWithDuration`        | `scheduleWithDuration`        |
| `unifi.site.wifi.read.bcFilterList`                | `bcFilterList`                |
| `unifi.site.wifi.read.externalId`                  | `externalId`                  |

</details>

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Response:**

```json
{
  "status": 200,
  "message": "UniFi WiFi Networks Fetched Successfully!",
  "data": [
    {
      "id": "66eb36e54bb53f0ae3fb82bd",
      "name": "TTAD",
      "siteId": "61ae856a8eae567c3905c54f",
      "enabled": true,
      "security": "wpapsk",
      "wpaMode": "wpa2",
      "wpaEnc": "ccmp",
      "wpa3Support": false,
      "wpa3Transition": false,
      "wpa3FastRoaming": false,
      "wpa3Enhanced192": false,
      "passphrase": "S3cur3Alarm!",
      "passphraseAutogenerated": false,
      "hideSSID": false,
      "isGuest": false,
      "band": "2g",
      "bands": ["2g"],
      "networkconfId": "66eb368d4bb53f0ae3fb7e0d",
      "usergroupId": "61ae856a8eae567c3905c55c",
      "apGroupIds": ["66eb36e44bb53f0ae3fb82bc"],
      "apGroupMode": "devices",
      "pmfMode": "disabled",
      "groupRekey": 0,
      "dtimMode": "default",
      "dtimNg": 1,
      "dtimNa": 3,
      "dtim6e": 3,
      "l2Isolation": false,
      "fastRoamingEnabled": false,
      "bssTransition": true,
      "uapsdEnabled": false,
      "iappEnabled": true,
      "proxyArp": false,
      "mcastenhanceEnabled": false,
      "macFilterEnabled": false,
      "macFilterPolicy": "allow",
      "macFilterList": [],
      "radiusDasEnabled": false,
      "radiusMacAuthEnabled": false,
      "radiusMacaclFormat": "none_lower",
      "minrateSettingPreference": "auto",
      "minrateNgEnabled": true,
      "minrateNgDataRateKbps": 1000,
      "minrateNgAdvertisingRates": false,
      "minrateNaEnabled": false,
      "minrateNaDataRateKbps": 6000,
      "minrateNaAdvertisingRates": false,
      "settingPreference": "manual",
      "no2ghzOui": true,
      "privatePreSharedKeysEnabled": false,
      "privatePreSharedKeys": [],
      "saeGroups": [],
      "saePsk": [],
      "schedule": [],
      "scheduleWithDuration": [],
      "bcFilterList": [],
      "externalId": "ba0f579c-6b21-4dfc-8d03-5a1a09243328"
    }
  ],
  "successful": true
}
```

---

### Update WiFi Network

**PATCH** `/unifi/site/:id/wifi/:wlanId`

Update a WiFi network configuration on the UniFi controller.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.wifi`, `unifi.site.wifi.update`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)
- `wlanId` - UniFi WLAN ID

**Request Body:**

```json
{
  "name": "NewSSIDName",
  "x_passphrase": "NewPassword123",
  "enabled": true,
  "security": "wpapsk",
  "wpa_mode": "wpa2",
  "hide_ssid": false,
  "is_guest": false,
  "band": "both"
}
```

All fields are optional. Valid values:

- `security`: `"wpapsk"`, `"wpaeap"`, `"open"`
- `wpa_mode`: `"wpa2"`, `"wpa3"`, `"wpa2wpa3"`
- `band`: `"both"`, `"2g"`, `"5g"`

**Response:**

```json
{
  "status": 200,
  "message": "UniFi WiFi Network Updated Successfully!",
  "data": {
    "id": "66eb36e54bb53f0ae3fb82bd",
    "name": "NewSSIDName",
    "siteId": "61ae856a8eae567c3905c54f",
    "enabled": true,
    "security": "wpapsk",
    "wpaMode": "wpa2",
    "wpaEnc": "ccmp",
    "wpa3Support": false,
    "wpa3Transition": false,
    "wpa3FastRoaming": false,
    "wpa3Enhanced192": false,
    "passphrase": "NewPassword123",
    "passphraseAutogenerated": false,
    "hideSSID": false,
    "isGuest": false,
    "band": "both",
    "bands": ["2g", "5g"],
    "networkconfId": "66eb368d4bb53f0ae3fb7e0d",
    "usergroupId": "61ae856a8eae567c3905c55c",
    "apGroupIds": ["66eb36e44bb53f0ae3fb82bc"],
    "apGroupMode": "devices",
    "pmfMode": "disabled",
    "groupRekey": 0,
    "dtimMode": "default",
    "dtimNg": 1,
    "dtimNa": 3,
    "dtim6e": 3,
    "l2Isolation": false,
    "fastRoamingEnabled": false,
    "bssTransition": true,
    "uapsdEnabled": false,
    "iappEnabled": true,
    "proxyArp": false,
    "mcastenhanceEnabled": false,
    "macFilterEnabled": false,
    "macFilterPolicy": "allow",
    "macFilterList": [],
    "radiusDasEnabled": false,
    "radiusMacAuthEnabled": false,
    "radiusMacaclFormat": "none_lower",
    "minrateSettingPreference": "auto",
    "minrateNgEnabled": true,
    "minrateNgDataRateKbps": 1000,
    "minrateNgAdvertisingRates": false,
    "minrateNaEnabled": false,
    "minrateNaDataRateKbps": 6000,
    "minrateNaAdvertisingRates": false,
    "settingPreference": "manual",
    "no2ghzOui": true,
    "privatePreSharedKeysEnabled": false,
    "privatePreSharedKeys": [],
    "saeGroups": [],
    "saePsk": [],
    "schedule": [],
    "scheduleWithDuration": [],
    "bcFilterList": [],
    "externalId": "ba0f579c-6b21-4dfc-8d03-5a1a09243328"
  },
  "successful": true
}
```

---

### Get Site Networks

**GET** `/unifi/site/:id/networks`

Fetch live network configurations from the UniFi controller for a specific site.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.networks`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Response:**

```json
{
  "status": 200,
  "message": "UniFi Networks Fetched Successfully!",
  "data": [
    {
      "id": "abc123...",
      "name": "LAN",
      "purpose": "corporate",
      "subnet": "192.168.1.0/24",
      "vlanId": null,
      "dhcpEnabled": true,
      "dhcpStart": "192.168.1.100",
      "dhcpStop": "192.168.1.254",
      "domainName": "localdomain",
      "isNat": true,
      "enabled": true
    }
  ],
  "successful": true
}
```

---

### Get WLAN Groups

**GET** `/unifi/site/:id/wlan-groups`

Fetch WLAN groups (AP groups) from the UniFi controller for a specific site. WLAN groups define which WLANs are broadcast on which access points.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.wlan-groups`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Response:**

```json
{
  "status": 200,
  "message": "UniFi WLAN Groups Fetched Successfully!",
  "data": [
    {
      "id": "61ae856a8eae567c3905c55d",
      "name": "Default",
      "siteId": "61ae856a8eae567c3905c550",
      "noDelete": true,
      "noEdit": false,
      "hidden": false
    }
  ],
  "successful": true
}
```

---

### Create WLAN Group

**POST** `/unifi/site/:id/wlan-groups`

Create a new WLAN group (AP broadcasting group) on the UniFi controller. WLAN groups control which WLANs are broadcast on which access points.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.wlan-groups`, `unifi.site.wlan-groups.create`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Request Body:**

```json
{
  "name": "Lobby APs"
}
```

| Field  | Type   | Required | Description            |
| ------ | ------ | -------- | ---------------------- |
| `name` | string | Yes      | Name of the WLAN group |

**Response (201):**

```json
{
  "status": 201,
  "message": "UniFi WLAN Group Created Successfully!",
  "data": {
    "id": "abc123...",
    "name": "Lobby APs",
    "siteId": "61ae856a8eae567c3905c550",
    "noDelete": false,
    "noEdit": false,
    "hidden": false
  },
  "successful": true
}
```

---

### Get AP Groups

**GET** `/unifi/site/:id/ap-groups`

Fetch AP groups from the UniFi controller for a specific site. AP groups define collections of access points — individual WLANs can target specific AP groups to control which APs broadcast a given SSID.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.ap-groups`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Response:**

```json
{
  "status": 200,
  "message": "UniFi AP Groups Fetched Successfully!",
  "data": [
    {
      "id": "61ae856a8eae567c3905c565",
      "name": "All APs",
      "deviceMacs": [
        "18:e8:29:59:25:bc",
        "78:45:58:29:fa:87",
        "68:d7:9a:73:9a:28"
      ],
      "noDelete": true
    },
    {
      "id": "63c5be017e957d08189ed997",
      "name": "301Andrus",
      "deviceMacs": ["78:45:58:29:fa:87", "18:e8:29:59:25:bc"],
      "noDelete": false
    }
  ],
  "successful": true
}
```

---

### Get Access Points

**GET** `/unifi/site/:id/access-points`

Fetch access points (UAPs only) from the UniFi controller for a specific site. This filters the full device list to only return wireless access points.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.access-points`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Response:**

```json
{
  "status": 200,
  "message": "UniFi Access Points Fetched Successfully!",
  "data": [
    {
      "id": "abc123...",
      "mac": "00:11:22:33:44:55",
      "model": "U6-Pro",
      "type": "uap",
      "name": "Office AP",
      "state": 1,
      "adopted": true,
      "ip": "192.168.1.10",
      "version": "7.1.68"
    }
  ],
  "successful": true
}
```

---

### Get WiFi Limits

**GET** `/unifi/site/:id/wifi-limits`

Check the WiFi SSID limits per access point per radio band. UniFi access points support a maximum of 8 SSIDs per radio. This endpoint shows how many SSIDs are active on each radio and how many more can be added.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.wifi-limits`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Response:**

```json
{
  "status": 200,
  "message": "UniFi WiFi Limits Fetched Successfully!",
  "data": [
    {
      "apId": "abc123...",
      "apName": "Office AP",
      "mac": "00:11:22:33:44:55",
      "model": "U6-Pro",
      "radios": [
        {
          "radio": "ng",
          "band": "2g",
          "activeWlans": 3,
          "limit": 8,
          "remaining": 5,
          "wlanNames": ["Corporate", "Guest", "IoT"]
        },
        {
          "radio": "na",
          "band": "5g",
          "activeWlans": 3,
          "limit": 8,
          "remaining": 5,
          "wlanNames": ["Corporate", "Guest", "IoT"]
        }
      ]
    }
  ],
  "successful": true
}
```

---

### Get Speed Profiles

**GET** `/unifi/site/:id/speed-profiles`

Fetch speed limit profiles (user groups) from the UniFi controller for a specific site. Speed profiles define bandwidth limits that can be applied to WiFi networks.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.speed-profiles`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Response:**

```json
{
  "status": 200,
  "message": "UniFi Speed Profiles Fetched Successfully!",
  "data": [
    {
      "id": "61ae856a8eae567c3905c55e",
      "name": "Default",
      "siteId": "61ae856a8eae567c3905c550",
      "noDelete": true,
      "downloadLimitKbps": -1,
      "uploadLimitKbps": -1
    },
    {
      "id": "abc123...",
      "name": "Guest 10Mbps",
      "siteId": "61ae856a8eae567c3905c550",
      "noDelete": false,
      "downloadLimitKbps": 10000,
      "uploadLimitKbps": 5000
    }
  ],
  "successful": true
}
```

> **Note:** A value of `-1` for `downloadLimitKbps` or `uploadLimitKbps` means unlimited.

---

### Create Speed Profile

**POST** `/unifi/site/:id/speed-profiles`

Create a new speed limit profile (user group) on the UniFi controller.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.speed-profiles`, `unifi.site.speed-profiles.create`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Request Body:**

```json
{
  "name": "Guest 10Mbps",
  "downloadLimitKbps": 10000,
  "uploadLimitKbps": 5000
}
```

| Field               | Type   | Required | Description                                     |
| ------------------- | ------ | -------- | ----------------------------------------------- |
| `name`              | string | Yes      | Name of the speed profile                       |
| `downloadLimitKbps` | number | No       | Download limit in Kbps (-1 or omit = unlimited) |
| `uploadLimitKbps`   | number | No       | Upload limit in Kbps (-1 or omit = unlimited)   |

**Response (201):**

```json
{
  "status": 201,
  "message": "UniFi Speed Profile Created Successfully!",
  "data": {
    "id": "abc123...",
    "name": "Guest 10Mbps",
    "siteId": "61ae856a8eae567c3905c550",
    "noDelete": false,
    "downloadLimitKbps": 10000,
    "uploadLimitKbps": 5000
  },
  "successful": true
}
```

---

### Get Private PSKs

**GET** `/unifi/site/:id/wifi/:wlanId/ppsk`

Fetch private pre-shared keys (PPSKs) for a specific WiFi network. PPSKs allow different devices to connect to the same SSID with unique passwords.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.wifi`, `unifi.site.wifi.ppsk`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)
- `wlanId` - UniFi WLAN configuration ID

**Response:**

```json
{
  "status": 200,
  "message": "UniFi Private PSKs Fetched Successfully!",
  "data": [
    {
      "key": "mySecurePassword123",
      "name": "John's Laptop",
      "mac": null,
      "vlanId": null
    },
    {
      "key": "anotherPassword456",
      "name": "IoT Device",
      "mac": "AA:BB:CC:DD:EE:FF",
      "vlanId": 100
    }
  ],
  "successful": true
}
```

---

### Create Private PSK

**POST** `/unifi/site/:id/wifi/:wlanId/ppsk`

Create a private pre-shared key on a specific WiFi network. This adds a new PPSK to the WLAN's list and enables PPSK mode if not already enabled.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.site.wifi`, `unifi.site.wifi.ppsk`, `unifi.site.wifi.ppsk.create`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)
- `wlanId` - UniFi WLAN configuration ID

**Request Body:**

```json
{
  "key": "mySecurePassword123",
  "name": "John's Laptop",
  "mac": "AA:BB:CC:DD:EE:FF",
  "vlanId": 100
}
```

| Field    | Type   | Required | Description                                       |
| -------- | ------ | -------- | ------------------------------------------------- |
| `key`    | string | Yes      | The pre-shared key (min 8 characters)             |
| `name`   | string | Yes      | Descriptive name for this PSK                     |
| `mac`    | string | No       | MAC address to lock this PSK to a specific device |
| `vlanId` | number | No       | VLAN ID to assign to clients using this PSK       |

**Response (201):**

```json
{
  "status": 201,
  "message": "UniFi Private PSK Created Successfully!",
  "data": [
    {
      "key": "mySecurePassword123",
      "name": "John's Laptop",
      "mac": "AA:BB:CC:DD:EE:FF",
      "vlanId": 100
    }
  ],
  "successful": true
}
```

> **Note:** The response returns the full updated list of all PPSKs on the WLAN after creation.

---

### Link Site to Company

**POST** `/unifi/site/:id/link`

Link a UniFi site to a company.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.sites.link`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Request Body:**

```json
{
  "companyId": "ckx..."
}
```

**Response:**

```json
{
  "status": 200,
  "message": "UniFi Site Linked to Company Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Total Tech - Murray Office",
    "siteId": "km9b1v8i",
    "companyId": "ckx...",
    "createdAt": "2025-01-01T00:00:00.000Z",
    "updatedAt": "2025-01-01T00:00:00.000Z"
  },
  "successful": true
}
```

---

### Unlink Site from Company

**POST** `/unifi/site/:id/unlink`

Unlink a UniFi site from its associated company.

**Authentication Required:** Yes

**Required Permissions:** `unifi.access`, `unifi.sites.link`

**URL Parameters:**

- `id` - Internal UniFi site ID (database ID)

**Response:**

```json
{
  "status": 200,
  "message": "UniFi Site Unlinked from Company Successfully!",
  "data": {
    "id": "ckx...",
    "name": "Total Tech - Murray Office",
    "siteId": "km9b1v8i",
    "companyId": null,
    "createdAt": "2025-01-01T00:00:00.000Z",
    "updatedAt": "2025-01-01T00:00:00.000Z"
  },
  "successful": true
}
```

---

## Error Responses

All endpoints may return error responses in the following format:

### 400 Bad Request

```json
{
  "status": 400,
  "message": "Validation error",
  "errors": [
    {
      "path": ["field"],
      "message": "Field is required"
    }
  ],
  "successful": false
}
```

### 401 Unauthorized

```json
{
  "status": 401,
  "message": "Unauthorized",
  "successful": false
}
```

### 403 Forbidden

```json
{
  "status": 403,
  "message": "Insufficient permissions",
  "successful": false
}
```

### 404 Not Found

```json
{
  "status": 404,
  "message": "Resource not found",
  "successful": false
}
```

### 500 Internal Server Error

```json
{
  "status": 500,
  "message": "Internal server error",
  "successful": false
}
```

---

## Authentication

Most endpoints require authentication via an access token in the request headers:

```
Authorization: Bearer <access_token>
```

Tokens are obtained through the Microsoft OAuth flow:

1. Call `GET /auth/uri` to get the authentication URL
2. Redirect user to the Microsoft login page
3. User authenticates and is redirected to `/auth/redirect`
4. Access and refresh tokens are provided via WebSocket or response
5. Use the access token in subsequent API requests

When the access token expires, use `POST /auth/refresh` with the refresh token to obtain a new access token.

---

## Permission System

The API uses a granular permission system. Each endpoint requires specific permissions that are checked via the `authMiddleware`. Permissions are granted through roles assigned to users.

Common permission patterns:

- `resource.fetch` - Read a single resource
- `resource.fetch.many` - Read multiple resources
- `resource.create` - Create a new resource
- `resource.update` - Update a resource
- `resource.delete` - Delete a resource
- `resource.field.action` - Perform specific field operations

Users can have multiple roles, and permissions are accumulated from all assigned roles.
