Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1326725995 | |||
| 508fa39835 | |||
| b1f6462ac3 | |||
| 51eb36f4a6 |
@@ -5,8 +5,28 @@ on:
|
|||||||
types: [created]
|
types: [created]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate Prisma client
|
||||||
|
run: DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" bunx prisma generate
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: bun test --preload ./tests/setup.ts
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
|
needs: [test]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate Prisma client
|
||||||
|
run: DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" bunx prisma generate
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: bun test --preload ./tests/setup.ts
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"chat.tools.terminal.autoApprove": {
|
||||||
|
"bun": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+673
@@ -2204,6 +2204,679 @@ A fun Easter egg endpoint that returns HTTP 418 (I'm a teapot).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**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,
|
||||||
|
"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`
|
||||||
|
|
||||||
|
**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`
|
||||||
|
|
||||||
|
**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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sales Routes
|
||||||
|
|
||||||
|
Sales routes serve opportunity data stored locally and synced from ConnectWise. List, search, and count operations read from the local database. Sub-resource routes (forecasts, notes, contacts) fetch live data from ConnectWise using the opportunity's CW ID.
|
||||||
|
|
||||||
|
### Get All Opportunities
|
||||||
|
|
||||||
|
**GET** `/sales/opportunities`
|
||||||
|
|
||||||
|
Fetch a paginated list of opportunities. Supports search.
|
||||||
|
|
||||||
|
**Authentication Required:** Yes
|
||||||
|
|
||||||
|
**Required Permissions:** `sales.opportunity.fetch.many`
|
||||||
|
|
||||||
|
**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": 100, "name": "Acme Corp" },
|
||||||
|
"contact": { "id": 200, "name": "Jane Smith" },
|
||||||
|
"site": { "id": 50, "name": "Main Office" },
|
||||||
|
"customerPO": null,
|
||||||
|
"totalSalesTax": 0,
|
||||||
|
"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",
|
||||||
|
"createdAt": "2026-02-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-02-26T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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 Opportunity
|
||||||
|
|
||||||
|
**GET** `/sales/opportunities/:identifier`
|
||||||
|
|
||||||
|
Fetch a single opportunity by its internal ID or ConnectWise opportunity ID.
|
||||||
|
|
||||||
|
**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 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": 100, "name": "Acme Corp" },
|
||||||
|
"contact": { "id": 200, "name": "Jane Smith" },
|
||||||
|
"site": { "id": 50, "name": "Main Office" },
|
||||||
|
"customerPO": null,
|
||||||
|
"totalSalesTax": 0,
|
||||||
|
"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",
|
||||||
|
"createdAt": "2026-02-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-02-26T10:00:00.000Z"
|
||||||
|
},
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Refresh Opportunity
|
||||||
|
|
||||||
|
**POST** `/sales/opportunities/:identifier/refresh`
|
||||||
|
|
||||||
|
Refresh an opportunity's local data by fetching the latest from ConnectWise.
|
||||||
|
|
||||||
|
**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": 100, "name": "Acme Corp" },
|
||||||
|
"contact": { "id": 200, "name": "Jane Smith" },
|
||||||
|
"site": { "id": 50, "name": "Main Office" },
|
||||||
|
"customerPO": null,
|
||||||
|
"totalSalesTax": 0,
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Opportunity Forecasts
|
||||||
|
|
||||||
|
**GET** `/sales/opportunities/:identifier/forecasts`
|
||||||
|
|
||||||
|
Fetch forecast/revenue items for an opportunity. Data is fetched live from ConnectWise using the opportunity's CW ID.
|
||||||
|
|
||||||
|
**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 forecasts fetched successfully!",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"forecastType": "Revenue",
|
||||||
|
"forecastMonth": "2026-03-01T00:00:00Z",
|
||||||
|
"revenue": 50000.0,
|
||||||
|
"cost": 30000.0,
|
||||||
|
"forecastPercentage": 75,
|
||||||
|
"status": { "id": 1, "name": "Open" },
|
||||||
|
"includedFlag": true,
|
||||||
|
"linkedFlag": false,
|
||||||
|
"recurringFlag": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Opportunity Notes
|
||||||
|
|
||||||
|
**GET** `/sales/opportunities/:identifier/notes`
|
||||||
|
|
||||||
|
Fetch notes for an opportunity. Data is fetched live from ConnectWise using the opportunity's CW ID.
|
||||||
|
|
||||||
|
**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": "JDoe"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Opportunity Contacts
|
||||||
|
|
||||||
|
**GET** `/sales/opportunities/:identifier/contacts`
|
||||||
|
|
||||||
|
Fetch contacts associated with an opportunity. Data is fetched live from ConnectWise using the opportunity's CW ID.
|
||||||
|
|
||||||
|
**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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## UniFi Routes
|
## 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.
|
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.
|
||||||
|
|||||||
+4
-1
@@ -67,4 +67,7 @@ RUN bun install --frozen-lockfile
|
|||||||
COPY prisma/ prisma/
|
COPY prisma/ prisma/
|
||||||
COPY prisma.config.ts ./
|
COPY prisma.config.ts ./
|
||||||
|
|
||||||
CMD ["bunx", "prisma", "migrate", "deploy"]
|
COPY prisma/migrate-entrypoint.sh ./prisma/migrate-entrypoint.sh
|
||||||
|
RUN chmod +x prisma/migrate-entrypoint.sh
|
||||||
|
|
||||||
|
CMD ["sh", "prisma/migrate-entrypoint.sh"]
|
||||||
@@ -115,6 +115,25 @@ Admin-specific UI permissions that control visibility and data loading for admin
|
|||||||
- **Combine with API permissions**: A user with an admin UI permission should also have the corresponding API permission (e.g., `role.list`) to actually load data.
|
- **Combine with API permissions**: A user with an admin UI permission should also have the corresponding API permission (e.g., `role.list`) to actually load data.
|
||||||
- **Use wildcards for flexibility**: Grant `ui.navigation.*.view` to allow all navigation sections.
|
- **Use wildcards for flexibility**: Grant `ui.navigation.*.view` to allow all navigation sections.
|
||||||
|
|
||||||
|
### Procurement Permissions
|
||||||
|
|
||||||
|
| Permission Node | Description | Used In | Dependencies |
|
||||||
|
| --------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
|
||||||
|
| `procurement.catalog.fetch` | Fetch a single catalog item | [src/api/procurement/[id]/fetch.ts](src/api/procurement/[id]/fetch.ts) | |
|
||||||
|
| `procurement.catalog.fetch.many` | Fetch multiple catalog items or count | [src/api/procurement/fetchAll.ts](src/api/procurement/fetchAll.ts), [src/api/procurement/count.ts](src/api/procurement/count.ts) | |
|
||||||
|
| `procurement.catalog.inventory.refresh` | Refresh on-hand inventory for a catalog item from ConnectWise | [src/api/procurement/[id]/refreshInventory.ts](src/api/procurement/[id]/refreshInventory.ts) | `procurement.catalog.fetch` |
|
||||||
|
| `procurement.catalog.link` | Link or unlink catalog items to each other | [src/api/procurement/[id]/link.ts](src/api/procurement/[id]/link.ts), [src/api/procurement/[id]/unlink.ts](src/api/procurement/[id]/unlink.ts) | `procurement.catalog.fetch` |
|
||||||
|
|
||||||
|
### Sales Permissions
|
||||||
|
|
||||||
|
Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (forecasts, notes, contacts) are fetched live from CW.
|
||||||
|
|
||||||
|
| Permission Node | Description | Used In | Dependencies |
|
||||||
|
| ------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
|
||||||
|
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (forecasts, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/forecasts.ts](src/api/sales/[id]/forecasts.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | |
|
||||||
|
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable) or get opportunity count | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts) | |
|
||||||
|
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` |
|
||||||
|
|
||||||
### UniFi Permissions
|
### UniFi Permissions
|
||||||
|
|
||||||
Permissions for accessing and managing UniFi network infrastructure. The `unifi.access` permission is a gate permission required for **all** UniFi routes.
|
Permissions for accessing and managing UniFi network infrastructure. The `unifi.access` permission is a gate permission required for **all** UniFi routes.
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export type Company = Prisma.CompanyModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type CatalogItem = Prisma.CatalogItemModel
|
export type CatalogItem = Prisma.CatalogItemModel
|
||||||
|
/**
|
||||||
|
* Model Opportunity
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Opportunity = Prisma.OpportunityModel
|
||||||
/**
|
/**
|
||||||
* Model CredentialType
|
* Model CredentialType
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ export type Company = Prisma.CompanyModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type CatalogItem = Prisma.CatalogItemModel
|
export type CatalogItem = Prisma.CatalogItemModel
|
||||||
|
/**
|
||||||
|
* Model Opportunity
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Opportunity = Prisma.OpportunityModel
|
||||||
/**
|
/**
|
||||||
* Model CredentialType
|
* Model CredentialType
|
||||||
*
|
*
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -390,6 +390,7 @@ export const ModelName = {
|
|||||||
UnifiSite: 'UnifiSite',
|
UnifiSite: 'UnifiSite',
|
||||||
Company: 'Company',
|
Company: 'Company',
|
||||||
CatalogItem: 'CatalogItem',
|
CatalogItem: 'CatalogItem',
|
||||||
|
Opportunity: 'Opportunity',
|
||||||
CredentialType: 'CredentialType',
|
CredentialType: 'CredentialType',
|
||||||
SecureValue: 'SecureValue',
|
SecureValue: 'SecureValue',
|
||||||
Credential: 'Credential'
|
Credential: 'Credential'
|
||||||
@@ -408,7 +409,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
omit: GlobalOmitOptions
|
omit: GlobalOmitOptions
|
||||||
}
|
}
|
||||||
meta: {
|
meta: {
|
||||||
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "credentialType" | "secureValue" | "credential"
|
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential"
|
||||||
txIsolationLevel: TransactionIsolationLevel
|
txIsolationLevel: TransactionIsolationLevel
|
||||||
}
|
}
|
||||||
model: {
|
model: {
|
||||||
@@ -856,6 +857,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Opportunity: {
|
||||||
|
payload: Prisma.$OpportunityPayload<ExtArgs>
|
||||||
|
fields: Prisma.OpportunityFieldRefs
|
||||||
|
operations: {
|
||||||
|
findUnique: {
|
||||||
|
args: Prisma.OpportunityFindUniqueArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload> | null
|
||||||
|
}
|
||||||
|
findUniqueOrThrow: {
|
||||||
|
args: Prisma.OpportunityFindUniqueOrThrowArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>
|
||||||
|
}
|
||||||
|
findFirst: {
|
||||||
|
args: Prisma.OpportunityFindFirstArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload> | null
|
||||||
|
}
|
||||||
|
findFirstOrThrow: {
|
||||||
|
args: Prisma.OpportunityFindFirstOrThrowArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>
|
||||||
|
}
|
||||||
|
findMany: {
|
||||||
|
args: Prisma.OpportunityFindManyArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>[]
|
||||||
|
}
|
||||||
|
create: {
|
||||||
|
args: Prisma.OpportunityCreateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>
|
||||||
|
}
|
||||||
|
createMany: {
|
||||||
|
args: Prisma.OpportunityCreateManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
createManyAndReturn: {
|
||||||
|
args: Prisma.OpportunityCreateManyAndReturnArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>[]
|
||||||
|
}
|
||||||
|
delete: {
|
||||||
|
args: Prisma.OpportunityDeleteArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>
|
||||||
|
}
|
||||||
|
update: {
|
||||||
|
args: Prisma.OpportunityUpdateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>
|
||||||
|
}
|
||||||
|
deleteMany: {
|
||||||
|
args: Prisma.OpportunityDeleteManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
updateMany: {
|
||||||
|
args: Prisma.OpportunityUpdateManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
updateManyAndReturn: {
|
||||||
|
args: Prisma.OpportunityUpdateManyAndReturnArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>[]
|
||||||
|
}
|
||||||
|
upsert: {
|
||||||
|
args: Prisma.OpportunityUpsertArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$OpportunityPayload>
|
||||||
|
}
|
||||||
|
aggregate: {
|
||||||
|
args: Prisma.OpportunityAggregateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.AggregateOpportunity>
|
||||||
|
}
|
||||||
|
groupBy: {
|
||||||
|
args: Prisma.OpportunityGroupByArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.OpportunityGroupByOutputType>[]
|
||||||
|
}
|
||||||
|
count: {
|
||||||
|
args: Prisma.OpportunityCountArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.OpportunityCountAggregateOutputType> | number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
CredentialType: {
|
CredentialType: {
|
||||||
payload: Prisma.$CredentialTypePayload<ExtArgs>
|
payload: Prisma.$CredentialTypePayload<ExtArgs>
|
||||||
fields: Prisma.CredentialTypeFieldRefs
|
fields: Prisma.CredentialTypeFieldRefs
|
||||||
@@ -1186,6 +1261,7 @@ export type CompanyScalarFieldEnum = (typeof CompanyScalarFieldEnum)[keyof typeo
|
|||||||
export const CatalogItemScalarFieldEnum = {
|
export const CatalogItemScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
cwCatalogId: 'cwCatalogId',
|
cwCatalogId: 'cwCatalogId',
|
||||||
|
identifier: 'identifier',
|
||||||
name: 'name',
|
name: 'name',
|
||||||
description: 'description',
|
description: 'description',
|
||||||
customerDescription: 'customerDescription',
|
customerDescription: 'customerDescription',
|
||||||
@@ -1209,6 +1285,58 @@ export const CatalogItemScalarFieldEnum = {
|
|||||||
export type CatalogItemScalarFieldEnum = (typeof CatalogItemScalarFieldEnum)[keyof typeof CatalogItemScalarFieldEnum]
|
export type CatalogItemScalarFieldEnum = (typeof CatalogItemScalarFieldEnum)[keyof typeof CatalogItemScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const OpportunityScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
cwOpportunityId: 'cwOpportunityId',
|
||||||
|
name: 'name',
|
||||||
|
notes: 'notes',
|
||||||
|
typeName: 'typeName',
|
||||||
|
typeCwId: 'typeCwId',
|
||||||
|
stageName: 'stageName',
|
||||||
|
stageCwId: 'stageCwId',
|
||||||
|
statusName: 'statusName',
|
||||||
|
statusCwId: 'statusCwId',
|
||||||
|
priorityName: 'priorityName',
|
||||||
|
priorityCwId: 'priorityCwId',
|
||||||
|
ratingName: 'ratingName',
|
||||||
|
ratingCwId: 'ratingCwId',
|
||||||
|
source: 'source',
|
||||||
|
campaignName: 'campaignName',
|
||||||
|
campaignCwId: 'campaignCwId',
|
||||||
|
primarySalesRepName: 'primarySalesRepName',
|
||||||
|
primarySalesRepIdentifier: 'primarySalesRepIdentifier',
|
||||||
|
primarySalesRepCwId: 'primarySalesRepCwId',
|
||||||
|
secondarySalesRepName: 'secondarySalesRepName',
|
||||||
|
secondarySalesRepIdentifier: 'secondarySalesRepIdentifier',
|
||||||
|
secondarySalesRepCwId: 'secondarySalesRepCwId',
|
||||||
|
companyCwId: 'companyCwId',
|
||||||
|
companyName: 'companyName',
|
||||||
|
contactCwId: 'contactCwId',
|
||||||
|
contactName: 'contactName',
|
||||||
|
siteCwId: 'siteCwId',
|
||||||
|
siteName: 'siteName',
|
||||||
|
customerPO: 'customerPO',
|
||||||
|
totalSalesTax: 'totalSalesTax',
|
||||||
|
locationName: 'locationName',
|
||||||
|
locationCwId: 'locationCwId',
|
||||||
|
departmentName: 'departmentName',
|
||||||
|
departmentCwId: 'departmentCwId',
|
||||||
|
expectedCloseDate: 'expectedCloseDate',
|
||||||
|
pipelineChangeDate: 'pipelineChangeDate',
|
||||||
|
dateBecameLead: 'dateBecameLead',
|
||||||
|
closedDate: 'closedDate',
|
||||||
|
closedFlag: 'closedFlag',
|
||||||
|
closedByName: 'closedByName',
|
||||||
|
closedByCwId: 'closedByCwId',
|
||||||
|
companyId: 'companyId',
|
||||||
|
cwLastUpdated: 'cwLastUpdated',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type OpportunityScalarFieldEnum = (typeof OpportunityScalarFieldEnum)[keyof typeof OpportunityScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const CredentialTypeScalarFieldEnum = {
|
export const CredentialTypeScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
name: 'name',
|
name: 'name',
|
||||||
@@ -1473,6 +1601,7 @@ export type GlobalOmitConfig = {
|
|||||||
unifiSite?: Prisma.UnifiSiteOmit
|
unifiSite?: Prisma.UnifiSiteOmit
|
||||||
company?: Prisma.CompanyOmit
|
company?: Prisma.CompanyOmit
|
||||||
catalogItem?: Prisma.CatalogItemOmit
|
catalogItem?: Prisma.CatalogItemOmit
|
||||||
|
opportunity?: Prisma.OpportunityOmit
|
||||||
credentialType?: Prisma.CredentialTypeOmit
|
credentialType?: Prisma.CredentialTypeOmit
|
||||||
secureValue?: Prisma.SecureValueOmit
|
secureValue?: Prisma.SecureValueOmit
|
||||||
credential?: Prisma.CredentialOmit
|
credential?: Prisma.CredentialOmit
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const ModelName = {
|
|||||||
UnifiSite: 'UnifiSite',
|
UnifiSite: 'UnifiSite',
|
||||||
Company: 'Company',
|
Company: 'Company',
|
||||||
CatalogItem: 'CatalogItem',
|
CatalogItem: 'CatalogItem',
|
||||||
|
Opportunity: 'Opportunity',
|
||||||
CredentialType: 'CredentialType',
|
CredentialType: 'CredentialType',
|
||||||
SecureValue: 'SecureValue',
|
SecureValue: 'SecureValue',
|
||||||
Credential: 'Credential'
|
Credential: 'Credential'
|
||||||
@@ -147,6 +148,7 @@ export type CompanyScalarFieldEnum = (typeof CompanyScalarFieldEnum)[keyof typeo
|
|||||||
export const CatalogItemScalarFieldEnum = {
|
export const CatalogItemScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
cwCatalogId: 'cwCatalogId',
|
cwCatalogId: 'cwCatalogId',
|
||||||
|
identifier: 'identifier',
|
||||||
name: 'name',
|
name: 'name',
|
||||||
description: 'description',
|
description: 'description',
|
||||||
customerDescription: 'customerDescription',
|
customerDescription: 'customerDescription',
|
||||||
@@ -170,6 +172,58 @@ export const CatalogItemScalarFieldEnum = {
|
|||||||
export type CatalogItemScalarFieldEnum = (typeof CatalogItemScalarFieldEnum)[keyof typeof CatalogItemScalarFieldEnum]
|
export type CatalogItemScalarFieldEnum = (typeof CatalogItemScalarFieldEnum)[keyof typeof CatalogItemScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const OpportunityScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
cwOpportunityId: 'cwOpportunityId',
|
||||||
|
name: 'name',
|
||||||
|
notes: 'notes',
|
||||||
|
typeName: 'typeName',
|
||||||
|
typeCwId: 'typeCwId',
|
||||||
|
stageName: 'stageName',
|
||||||
|
stageCwId: 'stageCwId',
|
||||||
|
statusName: 'statusName',
|
||||||
|
statusCwId: 'statusCwId',
|
||||||
|
priorityName: 'priorityName',
|
||||||
|
priorityCwId: 'priorityCwId',
|
||||||
|
ratingName: 'ratingName',
|
||||||
|
ratingCwId: 'ratingCwId',
|
||||||
|
source: 'source',
|
||||||
|
campaignName: 'campaignName',
|
||||||
|
campaignCwId: 'campaignCwId',
|
||||||
|
primarySalesRepName: 'primarySalesRepName',
|
||||||
|
primarySalesRepIdentifier: 'primarySalesRepIdentifier',
|
||||||
|
primarySalesRepCwId: 'primarySalesRepCwId',
|
||||||
|
secondarySalesRepName: 'secondarySalesRepName',
|
||||||
|
secondarySalesRepIdentifier: 'secondarySalesRepIdentifier',
|
||||||
|
secondarySalesRepCwId: 'secondarySalesRepCwId',
|
||||||
|
companyCwId: 'companyCwId',
|
||||||
|
companyName: 'companyName',
|
||||||
|
contactCwId: 'contactCwId',
|
||||||
|
contactName: 'contactName',
|
||||||
|
siteCwId: 'siteCwId',
|
||||||
|
siteName: 'siteName',
|
||||||
|
customerPO: 'customerPO',
|
||||||
|
totalSalesTax: 'totalSalesTax',
|
||||||
|
locationName: 'locationName',
|
||||||
|
locationCwId: 'locationCwId',
|
||||||
|
departmentName: 'departmentName',
|
||||||
|
departmentCwId: 'departmentCwId',
|
||||||
|
expectedCloseDate: 'expectedCloseDate',
|
||||||
|
pipelineChangeDate: 'pipelineChangeDate',
|
||||||
|
dateBecameLead: 'dateBecameLead',
|
||||||
|
closedDate: 'closedDate',
|
||||||
|
closedFlag: 'closedFlag',
|
||||||
|
closedByName: 'closedByName',
|
||||||
|
closedByCwId: 'closedByCwId',
|
||||||
|
companyId: 'companyId',
|
||||||
|
cwLastUpdated: 'cwLastUpdated',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type OpportunityScalarFieldEnum = (typeof OpportunityScalarFieldEnum)[keyof typeof OpportunityScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const CredentialTypeScalarFieldEnum = {
|
export const CredentialTypeScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
name: 'name',
|
name: 'name',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type * from './models/Role.ts'
|
|||||||
export type * from './models/UnifiSite.ts'
|
export type * from './models/UnifiSite.ts'
|
||||||
export type * from './models/Company.ts'
|
export type * from './models/Company.ts'
|
||||||
export type * from './models/CatalogItem.ts'
|
export type * from './models/CatalogItem.ts'
|
||||||
|
export type * from './models/Opportunity.ts'
|
||||||
export type * from './models/CredentialType.ts'
|
export type * from './models/CredentialType.ts'
|
||||||
export type * from './models/SecureValue.ts'
|
export type * from './models/SecureValue.ts'
|
||||||
export type * from './models/Credential.ts'
|
export type * from './models/Credential.ts'
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export type CatalogItemSumAggregateOutputType = {
|
|||||||
export type CatalogItemMinAggregateOutputType = {
|
export type CatalogItemMinAggregateOutputType = {
|
||||||
id: string | null
|
id: string | null
|
||||||
cwCatalogId: number | null
|
cwCatalogId: number | null
|
||||||
|
identifier: string | null
|
||||||
name: string | null
|
name: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
customerDescription: string | null
|
customerDescription: string | null
|
||||||
@@ -70,6 +71,7 @@ export type CatalogItemMinAggregateOutputType = {
|
|||||||
export type CatalogItemMaxAggregateOutputType = {
|
export type CatalogItemMaxAggregateOutputType = {
|
||||||
id: string | null
|
id: string | null
|
||||||
cwCatalogId: number | null
|
cwCatalogId: number | null
|
||||||
|
identifier: string | null
|
||||||
name: string | null
|
name: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
customerDescription: string | null
|
customerDescription: string | null
|
||||||
@@ -93,6 +95,7 @@ export type CatalogItemMaxAggregateOutputType = {
|
|||||||
export type CatalogItemCountAggregateOutputType = {
|
export type CatalogItemCountAggregateOutputType = {
|
||||||
id: number
|
id: number
|
||||||
cwCatalogId: number
|
cwCatalogId: number
|
||||||
|
identifier: number
|
||||||
name: number
|
name: number
|
||||||
description: number
|
description: number
|
||||||
customerDescription: number
|
customerDescription: number
|
||||||
@@ -136,6 +139,7 @@ export type CatalogItemSumAggregateInputType = {
|
|||||||
export type CatalogItemMinAggregateInputType = {
|
export type CatalogItemMinAggregateInputType = {
|
||||||
id?: true
|
id?: true
|
||||||
cwCatalogId?: true
|
cwCatalogId?: true
|
||||||
|
identifier?: true
|
||||||
name?: true
|
name?: true
|
||||||
description?: true
|
description?: true
|
||||||
customerDescription?: true
|
customerDescription?: true
|
||||||
@@ -159,6 +163,7 @@ export type CatalogItemMinAggregateInputType = {
|
|||||||
export type CatalogItemMaxAggregateInputType = {
|
export type CatalogItemMaxAggregateInputType = {
|
||||||
id?: true
|
id?: true
|
||||||
cwCatalogId?: true
|
cwCatalogId?: true
|
||||||
|
identifier?: true
|
||||||
name?: true
|
name?: true
|
||||||
description?: true
|
description?: true
|
||||||
customerDescription?: true
|
customerDescription?: true
|
||||||
@@ -182,6 +187,7 @@ export type CatalogItemMaxAggregateInputType = {
|
|||||||
export type CatalogItemCountAggregateInputType = {
|
export type CatalogItemCountAggregateInputType = {
|
||||||
id?: true
|
id?: true
|
||||||
cwCatalogId?: true
|
cwCatalogId?: true
|
||||||
|
identifier?: true
|
||||||
name?: true
|
name?: true
|
||||||
description?: true
|
description?: true
|
||||||
customerDescription?: true
|
customerDescription?: true
|
||||||
@@ -292,6 +298,7 @@ export type CatalogItemGroupByArgs<ExtArgs extends runtime.Types.Extensions.Inte
|
|||||||
export type CatalogItemGroupByOutputType = {
|
export type CatalogItemGroupByOutputType = {
|
||||||
id: string
|
id: string
|
||||||
cwCatalogId: number
|
cwCatalogId: number
|
||||||
|
identifier: string | null
|
||||||
name: string
|
name: string
|
||||||
description: string | null
|
description: string | null
|
||||||
customerDescription: string | null
|
customerDescription: string | null
|
||||||
@@ -338,6 +345,7 @@ export type CatalogItemWhereInput = {
|
|||||||
NOT?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[]
|
NOT?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[]
|
||||||
id?: Prisma.StringFilter<"CatalogItem"> | string
|
id?: Prisma.StringFilter<"CatalogItem"> | string
|
||||||
cwCatalogId?: Prisma.IntFilter<"CatalogItem"> | number
|
cwCatalogId?: Prisma.IntFilter<"CatalogItem"> | number
|
||||||
|
identifier?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
name?: Prisma.StringFilter<"CatalogItem"> | string
|
name?: Prisma.StringFilter<"CatalogItem"> | string
|
||||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
@@ -363,6 +371,7 @@ export type CatalogItemWhereInput = {
|
|||||||
export type CatalogItemOrderByWithRelationInput = {
|
export type CatalogItemOrderByWithRelationInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
cwCatalogId?: Prisma.SortOrder
|
cwCatalogId?: Prisma.SortOrder
|
||||||
|
identifier?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
name?: Prisma.SortOrder
|
name?: Prisma.SortOrder
|
||||||
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
@@ -388,6 +397,7 @@ export type CatalogItemOrderByWithRelationInput = {
|
|||||||
export type CatalogItemWhereUniqueInput = Prisma.AtLeast<{
|
export type CatalogItemWhereUniqueInput = Prisma.AtLeast<{
|
||||||
id?: string
|
id?: string
|
||||||
cwCatalogId?: number
|
cwCatalogId?: number
|
||||||
|
identifier?: string
|
||||||
AND?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[]
|
AND?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[]
|
||||||
OR?: Prisma.CatalogItemWhereInput[]
|
OR?: Prisma.CatalogItemWhereInput[]
|
||||||
NOT?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[]
|
NOT?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[]
|
||||||
@@ -411,11 +421,12 @@ export type CatalogItemWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
updatedAt?: Prisma.DateTimeFilter<"CatalogItem"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"CatalogItem"> | Date | string
|
||||||
linkedItems?: Prisma.CatalogItemListRelationFilter
|
linkedItems?: Prisma.CatalogItemListRelationFilter
|
||||||
linkedTo?: Prisma.CatalogItemListRelationFilter
|
linkedTo?: Prisma.CatalogItemListRelationFilter
|
||||||
}, "id" | "cwCatalogId">
|
}, "id" | "cwCatalogId" | "identifier">
|
||||||
|
|
||||||
export type CatalogItemOrderByWithAggregationInput = {
|
export type CatalogItemOrderByWithAggregationInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
cwCatalogId?: Prisma.SortOrder
|
cwCatalogId?: Prisma.SortOrder
|
||||||
|
identifier?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
name?: Prisma.SortOrder
|
name?: Prisma.SortOrder
|
||||||
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
@@ -447,6 +458,7 @@ export type CatalogItemScalarWhereWithAggregatesInput = {
|
|||||||
NOT?: Prisma.CatalogItemScalarWhereWithAggregatesInput | Prisma.CatalogItemScalarWhereWithAggregatesInput[]
|
NOT?: Prisma.CatalogItemScalarWhereWithAggregatesInput | Prisma.CatalogItemScalarWhereWithAggregatesInput[]
|
||||||
id?: Prisma.StringWithAggregatesFilter<"CatalogItem"> | string
|
id?: Prisma.StringWithAggregatesFilter<"CatalogItem"> | string
|
||||||
cwCatalogId?: Prisma.IntWithAggregatesFilter<"CatalogItem"> | number
|
cwCatalogId?: Prisma.IntWithAggregatesFilter<"CatalogItem"> | number
|
||||||
|
identifier?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||||
name?: Prisma.StringWithAggregatesFilter<"CatalogItem"> | string
|
name?: Prisma.StringWithAggregatesFilter<"CatalogItem"> | string
|
||||||
description?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
description?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||||
customerDescription?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
customerDescription?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||||
@@ -470,6 +482,7 @@ export type CatalogItemScalarWhereWithAggregatesInput = {
|
|||||||
export type CatalogItemCreateInput = {
|
export type CatalogItemCreateInput = {
|
||||||
id?: string
|
id?: string
|
||||||
cwCatalogId: number
|
cwCatalogId: number
|
||||||
|
identifier?: string | null
|
||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
@@ -495,6 +508,7 @@ export type CatalogItemCreateInput = {
|
|||||||
export type CatalogItemUncheckedCreateInput = {
|
export type CatalogItemUncheckedCreateInput = {
|
||||||
id?: string
|
id?: string
|
||||||
cwCatalogId: number
|
cwCatalogId: number
|
||||||
|
identifier?: string | null
|
||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
@@ -520,6 +534,7 @@ export type CatalogItemUncheckedCreateInput = {
|
|||||||
export type CatalogItemUpdateInput = {
|
export type CatalogItemUpdateInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -545,6 +560,7 @@ export type CatalogItemUpdateInput = {
|
|||||||
export type CatalogItemUncheckedUpdateInput = {
|
export type CatalogItemUncheckedUpdateInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -570,6 +586,7 @@ export type CatalogItemUncheckedUpdateInput = {
|
|||||||
export type CatalogItemCreateManyInput = {
|
export type CatalogItemCreateManyInput = {
|
||||||
id?: string
|
id?: string
|
||||||
cwCatalogId: number
|
cwCatalogId: number
|
||||||
|
identifier?: string | null
|
||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
@@ -593,6 +610,7 @@ export type CatalogItemCreateManyInput = {
|
|||||||
export type CatalogItemUpdateManyMutationInput = {
|
export type CatalogItemUpdateManyMutationInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -616,6 +634,7 @@ export type CatalogItemUpdateManyMutationInput = {
|
|||||||
export type CatalogItemUncheckedUpdateManyInput = {
|
export type CatalogItemUncheckedUpdateManyInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -649,6 +668,7 @@ export type CatalogItemOrderByRelationAggregateInput = {
|
|||||||
export type CatalogItemCountOrderByAggregateInput = {
|
export type CatalogItemCountOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
cwCatalogId?: Prisma.SortOrder
|
cwCatalogId?: Prisma.SortOrder
|
||||||
|
identifier?: Prisma.SortOrder
|
||||||
name?: Prisma.SortOrder
|
name?: Prisma.SortOrder
|
||||||
description?: Prisma.SortOrder
|
description?: Prisma.SortOrder
|
||||||
customerDescription?: Prisma.SortOrder
|
customerDescription?: Prisma.SortOrder
|
||||||
@@ -681,6 +701,7 @@ export type CatalogItemAvgOrderByAggregateInput = {
|
|||||||
export type CatalogItemMaxOrderByAggregateInput = {
|
export type CatalogItemMaxOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
cwCatalogId?: Prisma.SortOrder
|
cwCatalogId?: Prisma.SortOrder
|
||||||
|
identifier?: Prisma.SortOrder
|
||||||
name?: Prisma.SortOrder
|
name?: Prisma.SortOrder
|
||||||
description?: Prisma.SortOrder
|
description?: Prisma.SortOrder
|
||||||
customerDescription?: Prisma.SortOrder
|
customerDescription?: Prisma.SortOrder
|
||||||
@@ -704,6 +725,7 @@ export type CatalogItemMaxOrderByAggregateInput = {
|
|||||||
export type CatalogItemMinOrderByAggregateInput = {
|
export type CatalogItemMinOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
cwCatalogId?: Prisma.SortOrder
|
cwCatalogId?: Prisma.SortOrder
|
||||||
|
identifier?: Prisma.SortOrder
|
||||||
name?: Prisma.SortOrder
|
name?: Prisma.SortOrder
|
||||||
description?: Prisma.SortOrder
|
description?: Prisma.SortOrder
|
||||||
customerDescription?: Prisma.SortOrder
|
customerDescription?: Prisma.SortOrder
|
||||||
@@ -828,6 +850,7 @@ export type CatalogItemUncheckedUpdateManyWithoutLinkedItemsNestedInput = {
|
|||||||
export type CatalogItemCreateWithoutLinkedToInput = {
|
export type CatalogItemCreateWithoutLinkedToInput = {
|
||||||
id?: string
|
id?: string
|
||||||
cwCatalogId: number
|
cwCatalogId: number
|
||||||
|
identifier?: string | null
|
||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
@@ -852,6 +875,7 @@ export type CatalogItemCreateWithoutLinkedToInput = {
|
|||||||
export type CatalogItemUncheckedCreateWithoutLinkedToInput = {
|
export type CatalogItemUncheckedCreateWithoutLinkedToInput = {
|
||||||
id?: string
|
id?: string
|
||||||
cwCatalogId: number
|
cwCatalogId: number
|
||||||
|
identifier?: string | null
|
||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
@@ -881,6 +905,7 @@ export type CatalogItemCreateOrConnectWithoutLinkedToInput = {
|
|||||||
export type CatalogItemCreateWithoutLinkedItemsInput = {
|
export type CatalogItemCreateWithoutLinkedItemsInput = {
|
||||||
id?: string
|
id?: string
|
||||||
cwCatalogId: number
|
cwCatalogId: number
|
||||||
|
identifier?: string | null
|
||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
@@ -905,6 +930,7 @@ export type CatalogItemCreateWithoutLinkedItemsInput = {
|
|||||||
export type CatalogItemUncheckedCreateWithoutLinkedItemsInput = {
|
export type CatalogItemUncheckedCreateWithoutLinkedItemsInput = {
|
||||||
id?: string
|
id?: string
|
||||||
cwCatalogId: number
|
cwCatalogId: number
|
||||||
|
identifier?: string | null
|
||||||
name: string
|
name: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
customerDescription?: string | null
|
customerDescription?: string | null
|
||||||
@@ -953,6 +979,7 @@ export type CatalogItemScalarWhereInput = {
|
|||||||
NOT?: Prisma.CatalogItemScalarWhereInput | Prisma.CatalogItemScalarWhereInput[]
|
NOT?: Prisma.CatalogItemScalarWhereInput | Prisma.CatalogItemScalarWhereInput[]
|
||||||
id?: Prisma.StringFilter<"CatalogItem"> | string
|
id?: Prisma.StringFilter<"CatalogItem"> | string
|
||||||
cwCatalogId?: Prisma.IntFilter<"CatalogItem"> | number
|
cwCatalogId?: Prisma.IntFilter<"CatalogItem"> | number
|
||||||
|
identifier?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
name?: Prisma.StringFilter<"CatalogItem"> | string
|
name?: Prisma.StringFilter<"CatalogItem"> | string
|
||||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||||
@@ -992,6 +1019,7 @@ export type CatalogItemUpdateManyWithWhereWithoutLinkedItemsInput = {
|
|||||||
export type CatalogItemUpdateWithoutLinkedToInput = {
|
export type CatalogItemUpdateWithoutLinkedToInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -1016,6 +1044,7 @@ export type CatalogItemUpdateWithoutLinkedToInput = {
|
|||||||
export type CatalogItemUncheckedUpdateWithoutLinkedToInput = {
|
export type CatalogItemUncheckedUpdateWithoutLinkedToInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -1040,6 +1069,7 @@ export type CatalogItemUncheckedUpdateWithoutLinkedToInput = {
|
|||||||
export type CatalogItemUncheckedUpdateManyWithoutLinkedToInput = {
|
export type CatalogItemUncheckedUpdateManyWithoutLinkedToInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -1063,6 +1093,7 @@ export type CatalogItemUncheckedUpdateManyWithoutLinkedToInput = {
|
|||||||
export type CatalogItemUpdateWithoutLinkedItemsInput = {
|
export type CatalogItemUpdateWithoutLinkedItemsInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -1087,6 +1118,7 @@ export type CatalogItemUpdateWithoutLinkedItemsInput = {
|
|||||||
export type CatalogItemUncheckedUpdateWithoutLinkedItemsInput = {
|
export type CatalogItemUncheckedUpdateWithoutLinkedItemsInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -1111,6 +1143,7 @@ export type CatalogItemUncheckedUpdateWithoutLinkedItemsInput = {
|
|||||||
export type CatalogItemUncheckedUpdateManyWithoutLinkedItemsInput = {
|
export type CatalogItemUncheckedUpdateManyWithoutLinkedItemsInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
@@ -1174,6 +1207,7 @@ export type CatalogItemCountOutputTypeCountLinkedToArgs<ExtArgs extends runtime.
|
|||||||
export type CatalogItemSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type CatalogItemSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
cwCatalogId?: boolean
|
cwCatalogId?: boolean
|
||||||
|
identifier?: boolean
|
||||||
name?: boolean
|
name?: boolean
|
||||||
description?: boolean
|
description?: boolean
|
||||||
customerDescription?: boolean
|
customerDescription?: boolean
|
||||||
@@ -1200,6 +1234,7 @@ export type CatalogItemSelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
|||||||
export type CatalogItemSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type CatalogItemSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
cwCatalogId?: boolean
|
cwCatalogId?: boolean
|
||||||
|
identifier?: boolean
|
||||||
name?: boolean
|
name?: boolean
|
||||||
description?: boolean
|
description?: boolean
|
||||||
customerDescription?: boolean
|
customerDescription?: boolean
|
||||||
@@ -1223,6 +1258,7 @@ export type CatalogItemSelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
|||||||
export type CatalogItemSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type CatalogItemSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
cwCatalogId?: boolean
|
cwCatalogId?: boolean
|
||||||
|
identifier?: boolean
|
||||||
name?: boolean
|
name?: boolean
|
||||||
description?: boolean
|
description?: boolean
|
||||||
customerDescription?: boolean
|
customerDescription?: boolean
|
||||||
@@ -1246,6 +1282,7 @@ export type CatalogItemSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
|||||||
export type CatalogItemSelectScalar = {
|
export type CatalogItemSelectScalar = {
|
||||||
id?: boolean
|
id?: boolean
|
||||||
cwCatalogId?: boolean
|
cwCatalogId?: boolean
|
||||||
|
identifier?: boolean
|
||||||
name?: boolean
|
name?: boolean
|
||||||
description?: boolean
|
description?: boolean
|
||||||
customerDescription?: boolean
|
customerDescription?: boolean
|
||||||
@@ -1266,7 +1303,7 @@ export type CatalogItemSelectScalar = {
|
|||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CatalogItemOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwCatalogId" | "name" | "description" | "customerDescription" | "internalNotes" | "manufacturer" | "manufactureCwId" | "partNumber" | "vendorName" | "vendorSku" | "vendorCwId" | "price" | "cost" | "inactive" | "salesTaxable" | "onHand" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["catalogItem"]>
|
export type CatalogItemOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwCatalogId" | "identifier" | "name" | "description" | "customerDescription" | "internalNotes" | "manufacturer" | "manufactureCwId" | "partNumber" | "vendorName" | "vendorSku" | "vendorCwId" | "price" | "cost" | "inactive" | "salesTaxable" | "onHand" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["catalogItem"]>
|
||||||
export type CatalogItemInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type CatalogItemInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
linkedItems?: boolean | Prisma.CatalogItem$linkedItemsArgs<ExtArgs>
|
linkedItems?: boolean | Prisma.CatalogItem$linkedItemsArgs<ExtArgs>
|
||||||
linkedTo?: boolean | Prisma.CatalogItem$linkedToArgs<ExtArgs>
|
linkedTo?: boolean | Prisma.CatalogItem$linkedToArgs<ExtArgs>
|
||||||
@@ -1284,6 +1321,7 @@ export type $CatalogItemPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
|||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: string
|
id: string
|
||||||
cwCatalogId: number
|
cwCatalogId: number
|
||||||
|
identifier: string | null
|
||||||
name: string
|
name: string
|
||||||
description: string | null
|
description: string | null
|
||||||
customerDescription: string | null
|
customerDescription: string | null
|
||||||
@@ -1729,6 +1767,7 @@ export interface Prisma__CatalogItemClient<T, Null = never, ExtArgs extends runt
|
|||||||
export interface CatalogItemFieldRefs {
|
export interface CatalogItemFieldRefs {
|
||||||
readonly id: Prisma.FieldRef<"CatalogItem", 'String'>
|
readonly id: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||||
readonly cwCatalogId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
readonly cwCatalogId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
||||||
|
readonly identifier: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||||
readonly name: Prisma.FieldRef<"CatalogItem", 'String'>
|
readonly name: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||||
readonly description: Prisma.FieldRef<"CatalogItem", 'String'>
|
readonly description: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||||
readonly customerDescription: Prisma.FieldRef<"CatalogItem", 'String'>
|
readonly customerDescription: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ export type CompanyWhereInput = {
|
|||||||
updatedAt?: Prisma.DateTimeFilter<"Company"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"Company"> | Date | string
|
||||||
credentials?: Prisma.CredentialListRelationFilter
|
credentials?: Prisma.CredentialListRelationFilter
|
||||||
unifiSites?: Prisma.UnifiSiteListRelationFilter
|
unifiSites?: Prisma.UnifiSiteListRelationFilter
|
||||||
|
opportunities?: Prisma.OpportunityListRelationFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyOrderByWithRelationInput = {
|
export type CompanyOrderByWithRelationInput = {
|
||||||
@@ -237,6 +238,7 @@ export type CompanyOrderByWithRelationInput = {
|
|||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
credentials?: Prisma.CredentialOrderByRelationAggregateInput
|
credentials?: Prisma.CredentialOrderByRelationAggregateInput
|
||||||
unifiSites?: Prisma.UnifiSiteOrderByRelationAggregateInput
|
unifiSites?: Prisma.UnifiSiteOrderByRelationAggregateInput
|
||||||
|
opportunities?: Prisma.OpportunityOrderByRelationAggregateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyWhereUniqueInput = Prisma.AtLeast<{
|
export type CompanyWhereUniqueInput = Prisma.AtLeast<{
|
||||||
@@ -251,6 +253,7 @@ export type CompanyWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
updatedAt?: Prisma.DateTimeFilter<"Company"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"Company"> | Date | string
|
||||||
credentials?: Prisma.CredentialListRelationFilter
|
credentials?: Prisma.CredentialListRelationFilter
|
||||||
unifiSites?: Prisma.UnifiSiteListRelationFilter
|
unifiSites?: Prisma.UnifiSiteListRelationFilter
|
||||||
|
opportunities?: Prisma.OpportunityListRelationFilter
|
||||||
}, "id" | "cw_CompanyId" | "cw_Identifier">
|
}, "id" | "cw_CompanyId" | "cw_Identifier">
|
||||||
|
|
||||||
export type CompanyOrderByWithAggregationInput = {
|
export type CompanyOrderByWithAggregationInput = {
|
||||||
@@ -288,6 +291,7 @@ export type CompanyCreateInput = {
|
|||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
credentials?: Prisma.CredentialCreateNestedManyWithoutCompanyInput
|
credentials?: Prisma.CredentialCreateNestedManyWithoutCompanyInput
|
||||||
unifiSites?: Prisma.UnifiSiteCreateNestedManyWithoutCompanyInput
|
unifiSites?: Prisma.UnifiSiteCreateNestedManyWithoutCompanyInput
|
||||||
|
opportunities?: Prisma.OpportunityCreateNestedManyWithoutCompanyInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyUncheckedCreateInput = {
|
export type CompanyUncheckedCreateInput = {
|
||||||
@@ -299,6 +303,7 @@ export type CompanyUncheckedCreateInput = {
|
|||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
credentials?: Prisma.CredentialUncheckedCreateNestedManyWithoutCompanyInput
|
credentials?: Prisma.CredentialUncheckedCreateNestedManyWithoutCompanyInput
|
||||||
unifiSites?: Prisma.UnifiSiteUncheckedCreateNestedManyWithoutCompanyInput
|
unifiSites?: Prisma.UnifiSiteUncheckedCreateNestedManyWithoutCompanyInput
|
||||||
|
opportunities?: Prisma.OpportunityUncheckedCreateNestedManyWithoutCompanyInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyUpdateInput = {
|
export type CompanyUpdateInput = {
|
||||||
@@ -310,6 +315,7 @@ export type CompanyUpdateInput = {
|
|||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
credentials?: Prisma.CredentialUpdateManyWithoutCompanyNestedInput
|
credentials?: Prisma.CredentialUpdateManyWithoutCompanyNestedInput
|
||||||
unifiSites?: Prisma.UnifiSiteUpdateManyWithoutCompanyNestedInput
|
unifiSites?: Prisma.UnifiSiteUpdateManyWithoutCompanyNestedInput
|
||||||
|
opportunities?: Prisma.OpportunityUpdateManyWithoutCompanyNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyUncheckedUpdateInput = {
|
export type CompanyUncheckedUpdateInput = {
|
||||||
@@ -321,6 +327,7 @@ export type CompanyUncheckedUpdateInput = {
|
|||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
credentials?: Prisma.CredentialUncheckedUpdateManyWithoutCompanyNestedInput
|
credentials?: Prisma.CredentialUncheckedUpdateManyWithoutCompanyNestedInput
|
||||||
unifiSites?: Prisma.UnifiSiteUncheckedUpdateManyWithoutCompanyNestedInput
|
unifiSites?: Prisma.UnifiSiteUncheckedUpdateManyWithoutCompanyNestedInput
|
||||||
|
opportunities?: Prisma.OpportunityUncheckedUpdateManyWithoutCompanyNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyCreateManyInput = {
|
export type CompanyCreateManyInput = {
|
||||||
@@ -419,6 +426,22 @@ export type IntFieldUpdateOperationsInput = {
|
|||||||
divide?: number
|
divide?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CompanyCreateNestedOneWithoutOpportunitiesInput = {
|
||||||
|
create?: Prisma.XOR<Prisma.CompanyCreateWithoutOpportunitiesInput, Prisma.CompanyUncheckedCreateWithoutOpportunitiesInput>
|
||||||
|
connectOrCreate?: Prisma.CompanyCreateOrConnectWithoutOpportunitiesInput
|
||||||
|
connect?: Prisma.CompanyWhereUniqueInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompanyUpdateOneWithoutOpportunitiesNestedInput = {
|
||||||
|
create?: Prisma.XOR<Prisma.CompanyCreateWithoutOpportunitiesInput, Prisma.CompanyUncheckedCreateWithoutOpportunitiesInput>
|
||||||
|
connectOrCreate?: Prisma.CompanyCreateOrConnectWithoutOpportunitiesInput
|
||||||
|
upsert?: Prisma.CompanyUpsertWithoutOpportunitiesInput
|
||||||
|
disconnect?: Prisma.CompanyWhereInput | boolean
|
||||||
|
delete?: Prisma.CompanyWhereInput | boolean
|
||||||
|
connect?: Prisma.CompanyWhereUniqueInput
|
||||||
|
update?: Prisma.XOR<Prisma.XOR<Prisma.CompanyUpdateToOneWithWhereWithoutOpportunitiesInput, Prisma.CompanyUpdateWithoutOpportunitiesInput>, Prisma.CompanyUncheckedUpdateWithoutOpportunitiesInput>
|
||||||
|
}
|
||||||
|
|
||||||
export type CompanyCreateNestedOneWithoutCredentialsInput = {
|
export type CompanyCreateNestedOneWithoutCredentialsInput = {
|
||||||
create?: Prisma.XOR<Prisma.CompanyCreateWithoutCredentialsInput, Prisma.CompanyUncheckedCreateWithoutCredentialsInput>
|
create?: Prisma.XOR<Prisma.CompanyCreateWithoutCredentialsInput, Prisma.CompanyUncheckedCreateWithoutCredentialsInput>
|
||||||
connectOrCreate?: Prisma.CompanyCreateOrConnectWithoutCredentialsInput
|
connectOrCreate?: Prisma.CompanyCreateOrConnectWithoutCredentialsInput
|
||||||
@@ -441,6 +464,7 @@ export type CompanyCreateWithoutUnifiSitesInput = {
|
|||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
credentials?: Prisma.CredentialCreateNestedManyWithoutCompanyInput
|
credentials?: Prisma.CredentialCreateNestedManyWithoutCompanyInput
|
||||||
|
opportunities?: Prisma.OpportunityCreateNestedManyWithoutCompanyInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyUncheckedCreateWithoutUnifiSitesInput = {
|
export type CompanyUncheckedCreateWithoutUnifiSitesInput = {
|
||||||
@@ -451,6 +475,7 @@ export type CompanyUncheckedCreateWithoutUnifiSitesInput = {
|
|||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
credentials?: Prisma.CredentialUncheckedCreateNestedManyWithoutCompanyInput
|
credentials?: Prisma.CredentialUncheckedCreateNestedManyWithoutCompanyInput
|
||||||
|
opportunities?: Prisma.OpportunityUncheckedCreateNestedManyWithoutCompanyInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyCreateOrConnectWithoutUnifiSitesInput = {
|
export type CompanyCreateOrConnectWithoutUnifiSitesInput = {
|
||||||
@@ -477,6 +502,7 @@ export type CompanyUpdateWithoutUnifiSitesInput = {
|
|||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
credentials?: Prisma.CredentialUpdateManyWithoutCompanyNestedInput
|
credentials?: Prisma.CredentialUpdateManyWithoutCompanyNestedInput
|
||||||
|
opportunities?: Prisma.OpportunityUpdateManyWithoutCompanyNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyUncheckedUpdateWithoutUnifiSitesInput = {
|
export type CompanyUncheckedUpdateWithoutUnifiSitesInput = {
|
||||||
@@ -487,6 +513,67 @@ export type CompanyUncheckedUpdateWithoutUnifiSitesInput = {
|
|||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
credentials?: Prisma.CredentialUncheckedUpdateManyWithoutCompanyNestedInput
|
credentials?: Prisma.CredentialUncheckedUpdateManyWithoutCompanyNestedInput
|
||||||
|
opportunities?: Prisma.OpportunityUncheckedUpdateManyWithoutCompanyNestedInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompanyCreateWithoutOpportunitiesInput = {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
cw_CompanyId: number
|
||||||
|
cw_Identifier: string
|
||||||
|
createdAt?: Date | string
|
||||||
|
updatedAt?: Date | string
|
||||||
|
credentials?: Prisma.CredentialCreateNestedManyWithoutCompanyInput
|
||||||
|
unifiSites?: Prisma.UnifiSiteCreateNestedManyWithoutCompanyInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompanyUncheckedCreateWithoutOpportunitiesInput = {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
cw_CompanyId: number
|
||||||
|
cw_Identifier: string
|
||||||
|
createdAt?: Date | string
|
||||||
|
updatedAt?: Date | string
|
||||||
|
credentials?: Prisma.CredentialUncheckedCreateNestedManyWithoutCompanyInput
|
||||||
|
unifiSites?: Prisma.UnifiSiteUncheckedCreateNestedManyWithoutCompanyInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompanyCreateOrConnectWithoutOpportunitiesInput = {
|
||||||
|
where: Prisma.CompanyWhereUniqueInput
|
||||||
|
create: Prisma.XOR<Prisma.CompanyCreateWithoutOpportunitiesInput, Prisma.CompanyUncheckedCreateWithoutOpportunitiesInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompanyUpsertWithoutOpportunitiesInput = {
|
||||||
|
update: Prisma.XOR<Prisma.CompanyUpdateWithoutOpportunitiesInput, Prisma.CompanyUncheckedUpdateWithoutOpportunitiesInput>
|
||||||
|
create: Prisma.XOR<Prisma.CompanyCreateWithoutOpportunitiesInput, Prisma.CompanyUncheckedCreateWithoutOpportunitiesInput>
|
||||||
|
where?: Prisma.CompanyWhereInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompanyUpdateToOneWithWhereWithoutOpportunitiesInput = {
|
||||||
|
where?: Prisma.CompanyWhereInput
|
||||||
|
data: Prisma.XOR<Prisma.CompanyUpdateWithoutOpportunitiesInput, Prisma.CompanyUncheckedUpdateWithoutOpportunitiesInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompanyUpdateWithoutOpportunitiesInput = {
|
||||||
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
cw_CompanyId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
cw_Identifier?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
credentials?: Prisma.CredentialUpdateManyWithoutCompanyNestedInput
|
||||||
|
unifiSites?: Prisma.UnifiSiteUpdateManyWithoutCompanyNestedInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompanyUncheckedUpdateWithoutOpportunitiesInput = {
|
||||||
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
cw_CompanyId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
cw_Identifier?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
credentials?: Prisma.CredentialUncheckedUpdateManyWithoutCompanyNestedInput
|
||||||
|
unifiSites?: Prisma.UnifiSiteUncheckedUpdateManyWithoutCompanyNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyCreateWithoutCredentialsInput = {
|
export type CompanyCreateWithoutCredentialsInput = {
|
||||||
@@ -497,6 +584,7 @@ export type CompanyCreateWithoutCredentialsInput = {
|
|||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
unifiSites?: Prisma.UnifiSiteCreateNestedManyWithoutCompanyInput
|
unifiSites?: Prisma.UnifiSiteCreateNestedManyWithoutCompanyInput
|
||||||
|
opportunities?: Prisma.OpportunityCreateNestedManyWithoutCompanyInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyUncheckedCreateWithoutCredentialsInput = {
|
export type CompanyUncheckedCreateWithoutCredentialsInput = {
|
||||||
@@ -507,6 +595,7 @@ export type CompanyUncheckedCreateWithoutCredentialsInput = {
|
|||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
unifiSites?: Prisma.UnifiSiteUncheckedCreateNestedManyWithoutCompanyInput
|
unifiSites?: Prisma.UnifiSiteUncheckedCreateNestedManyWithoutCompanyInput
|
||||||
|
opportunities?: Prisma.OpportunityUncheckedCreateNestedManyWithoutCompanyInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyCreateOrConnectWithoutCredentialsInput = {
|
export type CompanyCreateOrConnectWithoutCredentialsInput = {
|
||||||
@@ -533,6 +622,7 @@ export type CompanyUpdateWithoutCredentialsInput = {
|
|||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
unifiSites?: Prisma.UnifiSiteUpdateManyWithoutCompanyNestedInput
|
unifiSites?: Prisma.UnifiSiteUpdateManyWithoutCompanyNestedInput
|
||||||
|
opportunities?: Prisma.OpportunityUpdateManyWithoutCompanyNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyUncheckedUpdateWithoutCredentialsInput = {
|
export type CompanyUncheckedUpdateWithoutCredentialsInput = {
|
||||||
@@ -543,6 +633,7 @@ export type CompanyUncheckedUpdateWithoutCredentialsInput = {
|
|||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
unifiSites?: Prisma.UnifiSiteUncheckedUpdateManyWithoutCompanyNestedInput
|
unifiSites?: Prisma.UnifiSiteUncheckedUpdateManyWithoutCompanyNestedInput
|
||||||
|
opportunities?: Prisma.OpportunityUncheckedUpdateManyWithoutCompanyNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -553,11 +644,13 @@ export type CompanyUncheckedUpdateWithoutCredentialsInput = {
|
|||||||
export type CompanyCountOutputType = {
|
export type CompanyCountOutputType = {
|
||||||
credentials: number
|
credentials: number
|
||||||
unifiSites: number
|
unifiSites: number
|
||||||
|
opportunities: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanyCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type CompanyCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
credentials?: boolean | CompanyCountOutputTypeCountCredentialsArgs
|
credentials?: boolean | CompanyCountOutputTypeCountCredentialsArgs
|
||||||
unifiSites?: boolean | CompanyCountOutputTypeCountUnifiSitesArgs
|
unifiSites?: boolean | CompanyCountOutputTypeCountUnifiSitesArgs
|
||||||
|
opportunities?: boolean | CompanyCountOutputTypeCountOpportunitiesArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -584,6 +677,13 @@ export type CompanyCountOutputTypeCountUnifiSitesArgs<ExtArgs extends runtime.Ty
|
|||||||
where?: Prisma.UnifiSiteWhereInput
|
where?: Prisma.UnifiSiteWhereInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CompanyCountOutputType without action
|
||||||
|
*/
|
||||||
|
export type CompanyCountOutputTypeCountOpportunitiesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
|
where?: Prisma.OpportunityWhereInput
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export type CompanySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type CompanySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
@@ -594,6 +694,7 @@ export type CompanySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
|
|||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
credentials?: boolean | Prisma.Company$credentialsArgs<ExtArgs>
|
credentials?: boolean | Prisma.Company$credentialsArgs<ExtArgs>
|
||||||
unifiSites?: boolean | Prisma.Company$unifiSitesArgs<ExtArgs>
|
unifiSites?: boolean | Prisma.Company$unifiSitesArgs<ExtArgs>
|
||||||
|
opportunities?: boolean | Prisma.Company$opportunitiesArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.CompanyCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.CompanyCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["company"]>
|
}, ExtArgs["result"]["company"]>
|
||||||
|
|
||||||
@@ -628,6 +729,7 @@ export type CompanyOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||||||
export type CompanyInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type CompanyInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
credentials?: boolean | Prisma.Company$credentialsArgs<ExtArgs>
|
credentials?: boolean | Prisma.Company$credentialsArgs<ExtArgs>
|
||||||
unifiSites?: boolean | Prisma.Company$unifiSitesArgs<ExtArgs>
|
unifiSites?: boolean | Prisma.Company$unifiSitesArgs<ExtArgs>
|
||||||
|
opportunities?: boolean | Prisma.Company$opportunitiesArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.CompanyCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.CompanyCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
export type CompanyIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
|
export type CompanyIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
|
||||||
@@ -638,6 +740,7 @@ export type $CompanyPayload<ExtArgs extends runtime.Types.Extensions.InternalArg
|
|||||||
objects: {
|
objects: {
|
||||||
credentials: Prisma.$CredentialPayload<ExtArgs>[]
|
credentials: Prisma.$CredentialPayload<ExtArgs>[]
|
||||||
unifiSites: Prisma.$UnifiSitePayload<ExtArgs>[]
|
unifiSites: Prisma.$UnifiSitePayload<ExtArgs>[]
|
||||||
|
opportunities: Prisma.$OpportunityPayload<ExtArgs>[]
|
||||||
}
|
}
|
||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: string
|
id: string
|
||||||
@@ -1042,6 +1145,7 @@ export interface Prisma__CompanyClient<T, Null = never, ExtArgs extends runtime.
|
|||||||
readonly [Symbol.toStringTag]: "PrismaPromise"
|
readonly [Symbol.toStringTag]: "PrismaPromise"
|
||||||
credentials<T extends Prisma.Company$credentialsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Company$credentialsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CredentialPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
credentials<T extends Prisma.Company$credentialsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Company$credentialsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CredentialPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||||
unifiSites<T extends Prisma.Company$unifiSitesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Company$unifiSitesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UnifiSitePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
unifiSites<T extends Prisma.Company$unifiSitesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Company$unifiSitesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UnifiSitePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||||
|
opportunities<T extends Prisma.Company$opportunitiesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Company$opportunitiesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$OpportunityPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||||
/**
|
/**
|
||||||
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
* @param onfulfilled The callback to execute when the Promise is resolved.
|
||||||
@@ -1512,6 +1616,30 @@ export type Company$unifiSitesArgs<ExtArgs extends runtime.Types.Extensions.Inte
|
|||||||
distinct?: Prisma.UnifiSiteScalarFieldEnum | Prisma.UnifiSiteScalarFieldEnum[]
|
distinct?: Prisma.UnifiSiteScalarFieldEnum | Prisma.UnifiSiteScalarFieldEnum[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Company.opportunities
|
||||||
|
*/
|
||||||
|
export type Company$opportunitiesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
|
/**
|
||||||
|
* Select specific fields to fetch from the Opportunity
|
||||||
|
*/
|
||||||
|
select?: Prisma.OpportunitySelect<ExtArgs> | null
|
||||||
|
/**
|
||||||
|
* Omit specific fields from the Opportunity
|
||||||
|
*/
|
||||||
|
omit?: Prisma.OpportunityOmit<ExtArgs> | null
|
||||||
|
/**
|
||||||
|
* Choose, which related nodes to fetch as well
|
||||||
|
*/
|
||||||
|
include?: Prisma.OpportunityInclude<ExtArgs> | null
|
||||||
|
where?: Prisma.OpportunityWhereInput
|
||||||
|
orderBy?: Prisma.OpportunityOrderByWithRelationInput | Prisma.OpportunityOrderByWithRelationInput[]
|
||||||
|
cursor?: Prisma.OpportunityWhereUniqueInput
|
||||||
|
take?: number
|
||||||
|
skip?: number
|
||||||
|
distinct?: Prisma.OpportunityScalarFieldEnum | Prisma.OpportunityScalarFieldEnum[]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Company without action
|
* Company without action
|
||||||
*/
|
*/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development bun --watch src/index.ts",
|
"dev": "NODE_ENV=development bun --watch src/index.ts",
|
||||||
|
"test": "bun test --preload ./tests/setup.ts",
|
||||||
"db:gen": "prisma generate",
|
"db:gen": "prisma generate",
|
||||||
"db:push": "prisma migrate dev --skip-generate",
|
"db:push": "prisma migrate dev --skip-generate",
|
||||||
"db:deploy": "prisma migrate deploy",
|
"db:deploy": "prisma migrate deploy",
|
||||||
|
|||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Resolve any previously failed migrations so deploy can proceed.
|
||||||
|
# Prisma marks failed migrations in _prisma_migrations; we roll them back
|
||||||
|
# so the current run can re-apply them cleanly.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo "[migrate] Checking for failed migrations..."
|
||||||
|
FAILED=$(bunx prisma migrate status 2>&1 || true)
|
||||||
|
|
||||||
|
# Extract failed migration names and mark them as rolled back
|
||||||
|
echo "$FAILED" | grep -oE '[0-9]{14}_[a-z_]+' | while read -r MIGRATION; do
|
||||||
|
# Only resolve if the status output says it failed
|
||||||
|
if echo "$FAILED" | grep -q "failed"; then
|
||||||
|
echo "[migrate] Resolving failed migration: $MIGRATION"
|
||||||
|
bunx prisma migrate resolve --rolled-back "$MIGRATION" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Generate diff SQL between current migrations and the Prisma schema.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DIFF_SQL=$(bunx prisma migrate diff \
|
||||||
|
--from-migrations prisma/migrations \
|
||||||
|
--to-schema-datamodel prisma/schema.prisma \
|
||||||
|
--script 2>/dev/null || true)
|
||||||
|
|
||||||
|
# If there's a meaningful diff (not just empty/comments), create a migration
|
||||||
|
if [ -n "$DIFF_SQL" ] && echo "$DIFF_SQL" | grep -qvE '^\s*$|^--'; then
|
||||||
|
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
|
||||||
|
MIGRATION_DIR="prisma/migrations/${TIMESTAMP}_auto_generated"
|
||||||
|
mkdir -p "$MIGRATION_DIR"
|
||||||
|
echo "$DIFF_SQL" > "$MIGRATION_DIR/migration.sql"
|
||||||
|
echo "[migrate] Created migration: $MIGRATION_DIR"
|
||||||
|
else
|
||||||
|
echo "[migrate] Schema and migrations are in sync — no migration needed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Deploy all pending migrations.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo "[migrate] Running prisma migrate deploy..."
|
||||||
|
bunx prisma migrate deploy
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Opportunity" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"cwOpportunityId" INTEGER NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"typeName" TEXT,
|
||||||
|
"typeCwId" INTEGER,
|
||||||
|
"stageName" TEXT,
|
||||||
|
"stageCwId" INTEGER,
|
||||||
|
"statusName" TEXT,
|
||||||
|
"statusCwId" INTEGER,
|
||||||
|
"priorityName" TEXT,
|
||||||
|
"priorityCwId" INTEGER,
|
||||||
|
"ratingName" TEXT,
|
||||||
|
"ratingCwId" INTEGER,
|
||||||
|
"source" TEXT,
|
||||||
|
"campaignName" TEXT,
|
||||||
|
"campaignCwId" INTEGER,
|
||||||
|
"primarySalesRepName" TEXT,
|
||||||
|
"primarySalesRepIdentifier" TEXT,
|
||||||
|
"primarySalesRepCwId" INTEGER,
|
||||||
|
"secondarySalesRepName" TEXT,
|
||||||
|
"secondarySalesRepIdentifier" TEXT,
|
||||||
|
"secondarySalesRepCwId" INTEGER,
|
||||||
|
"companyCwId" INTEGER,
|
||||||
|
"companyName" TEXT,
|
||||||
|
"contactCwId" INTEGER,
|
||||||
|
"contactName" TEXT,
|
||||||
|
"siteCwId" INTEGER,
|
||||||
|
"siteName" TEXT,
|
||||||
|
"customerPO" TEXT,
|
||||||
|
"totalSalesTax" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
"locationName" TEXT,
|
||||||
|
"locationCwId" INTEGER,
|
||||||
|
"departmentName" TEXT,
|
||||||
|
"departmentCwId" INTEGER,
|
||||||
|
"expectedCloseDate" TIMESTAMP(3),
|
||||||
|
"pipelineChangeDate" TIMESTAMP(3),
|
||||||
|
"dateBecameLead" TIMESTAMP(3),
|
||||||
|
"closedDate" TIMESTAMP(3),
|
||||||
|
"closedFlag" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"closedByName" TEXT,
|
||||||
|
"closedByCwId" INTEGER,
|
||||||
|
"companyId" TEXT,
|
||||||
|
"cwLastUpdated" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Opportunity_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Opportunity_cwOpportunityId_key" ON "Opportunity"("cwOpportunityId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Opportunity" ADD CONSTRAINT "Opportunity_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
+71
-2
@@ -75,8 +75,9 @@ model Company {
|
|||||||
cw_CompanyId Int @unique
|
cw_CompanyId Int @unique
|
||||||
cw_Identifier String @unique
|
cw_Identifier String @unique
|
||||||
|
|
||||||
credentials Credential[]
|
credentials Credential[]
|
||||||
unifiSites UnifiSite[]
|
unifiSites UnifiSite[]
|
||||||
|
opportunities Opportunity[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -85,6 +86,7 @@ model Company {
|
|||||||
model CatalogItem {
|
model CatalogItem {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
cwCatalogId Int @unique
|
cwCatalogId Int @unique
|
||||||
|
identifier String? @unique
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
customerDescription String?
|
customerDescription String?
|
||||||
@@ -115,6 +117,73 @@ model CatalogItem {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Opportunity {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
cwOpportunityId Int @unique
|
||||||
|
name String
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
// Stage / status / priority / type / rating stored as JSON references
|
||||||
|
// so we don't need separate lookup tables for CW enums
|
||||||
|
typeName String?
|
||||||
|
typeCwId Int?
|
||||||
|
stageName String?
|
||||||
|
stageCwId Int?
|
||||||
|
statusName String?
|
||||||
|
statusCwId Int?
|
||||||
|
priorityName String?
|
||||||
|
priorityCwId Int?
|
||||||
|
ratingName String?
|
||||||
|
ratingCwId Int?
|
||||||
|
source String?
|
||||||
|
campaignName String?
|
||||||
|
campaignCwId Int?
|
||||||
|
|
||||||
|
// Sales rep references
|
||||||
|
primarySalesRepName String?
|
||||||
|
primarySalesRepIdentifier String?
|
||||||
|
primarySalesRepCwId Int?
|
||||||
|
secondarySalesRepName String?
|
||||||
|
secondarySalesRepIdentifier String?
|
||||||
|
secondarySalesRepCwId Int?
|
||||||
|
|
||||||
|
// Company / contact / site
|
||||||
|
companyCwId Int?
|
||||||
|
companyName String?
|
||||||
|
contactCwId Int?
|
||||||
|
contactName String?
|
||||||
|
siteCwId Int?
|
||||||
|
siteName String?
|
||||||
|
customerPO String?
|
||||||
|
|
||||||
|
// Financials
|
||||||
|
totalSalesTax Float @default(0)
|
||||||
|
|
||||||
|
// Location / department
|
||||||
|
locationName String?
|
||||||
|
locationCwId Int?
|
||||||
|
departmentName String?
|
||||||
|
departmentCwId Int?
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
expectedCloseDate DateTime?
|
||||||
|
pipelineChangeDate DateTime?
|
||||||
|
dateBecameLead DateTime?
|
||||||
|
closedDate DateTime?
|
||||||
|
closedFlag Boolean @default(false)
|
||||||
|
closedByName String?
|
||||||
|
closedByCwId Int?
|
||||||
|
|
||||||
|
// Internal relation to Company (optional, linked by cwCompanyId)
|
||||||
|
companyId String?
|
||||||
|
company Company? @relation(fields: [companyId], references: [id])
|
||||||
|
|
||||||
|
cwLastUpdated DateTime?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model CredentialType {
|
model CredentialType {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { procurement } from "../../../managers/procurement";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
|
||||||
|
/* /v1/procurement/items/:identifier */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/items/:identifier"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const includeLinkedItems = c.req.query("includeLinkedItems") === "true";
|
||||||
|
|
||||||
|
const item = await procurement.fetchItem(identifier);
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Catalog item fetched successfully!",
|
||||||
|
item.toJson({ includeLinkedItems }),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["procurement.catalog.fetch"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { procurement } from "../../../managers/procurement";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
|
||||||
|
/* GET /v1/procurement/items/:identifier/linked */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/items/:identifier/linked"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const item = await procurement.fetchItem(identifier);
|
||||||
|
|
||||||
|
const linkedItems = item.getLinkedItems().map((linked) => linked.toJson());
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Linked catalog items fetched successfully!",
|
||||||
|
linkedItems,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["procurement.catalog.fetch"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { procurement } from "../../../managers/procurement";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/* POST /v1/procurement/items/:identifier/link */
|
||||||
|
export default createRoute(
|
||||||
|
"post",
|
||||||
|
["/items/:identifier/link"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const body = await c.req.json();
|
||||||
|
const schema = z.object({ targetId: z.string() }).strict();
|
||||||
|
const { targetId } = schema.parse(body);
|
||||||
|
|
||||||
|
const item = await procurement.linkItems(identifier, targetId);
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Catalog item linked successfully!",
|
||||||
|
item.toJson({ includeLinkedItems: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["procurement.catalog.link"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { procurement } from "../../../managers/procurement";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
|
||||||
|
/* /v1/procurement/items/:identifier/refresh-inventory */
|
||||||
|
export default createRoute(
|
||||||
|
"post",
|
||||||
|
["/items/:identifier/refresh-inventory"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const item = await procurement.fetchItem(identifier);
|
||||||
|
|
||||||
|
await item.refreshInventory();
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Inventory refreshed successfully!",
|
||||||
|
item.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["procurement.catalog.inventory.refresh"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { procurement } from "../../../managers/procurement";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/* POST /v1/procurement/items/:identifier/unlink */
|
||||||
|
export default createRoute(
|
||||||
|
"post",
|
||||||
|
["/items/:identifier/unlink"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const body = await c.req.json();
|
||||||
|
const schema = z.object({ targetId: z.string() }).strict();
|
||||||
|
const { targetId } = schema.parse(body);
|
||||||
|
|
||||||
|
const item = await procurement.unlinkItems(identifier, targetId);
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Catalog item unlinked successfully!",
|
||||||
|
item.toJson({ includeLinkedItems: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["procurement.catalog.link"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||||
|
import { procurement } from "../../managers/procurement";
|
||||||
|
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
|
||||||
|
/* /v1/procurement/count */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/count"],
|
||||||
|
async (c) => {
|
||||||
|
const activeOnly = c.req.query("activeOnly") === "true";
|
||||||
|
|
||||||
|
const count = await procurement.count({ activeOnly });
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Catalog item count fetched successfully!",
|
||||||
|
{ count },
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||||
|
import { procurement } from "../../managers/procurement";
|
||||||
|
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
|
||||||
|
/* /v1/procurement/items */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/items"],
|
||||||
|
async (c) => {
|
||||||
|
const page = Number(c.req.query("page") ?? 1);
|
||||||
|
const rpp = Number(c.req.query("rpp") ?? 30);
|
||||||
|
const search = c.req.query("search") as string;
|
||||||
|
const includeInactive = c.req.query("includeInactive") === "true";
|
||||||
|
|
||||||
|
const data = search
|
||||||
|
? await procurement.search(search, page, rpp, { includeInactive })
|
||||||
|
: await procurement.fetchPages(page, rpp, { includeInactive });
|
||||||
|
|
||||||
|
const totalRecords = await procurement.count({
|
||||||
|
activeOnly: !includeInactive,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Catalog items fetched successfully!",
|
||||||
|
data.map((item) => item.toJson()),
|
||||||
|
{
|
||||||
|
pagination: {
|
||||||
|
previousPage: page <= 1 ? null : page - 1,
|
||||||
|
currentPage: page,
|
||||||
|
nextPage: page >= totalRecords / rpp ? null : page + 1,
|
||||||
|
totalPages: Math.ceil(totalRecords / rpp),
|
||||||
|
totalRecords,
|
||||||
|
listedRecords: rpp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { default as fetchAll } from "./fetchAll";
|
||||||
|
import { default as fetch } from "./[id]/fetch";
|
||||||
|
import { default as refreshInventory } from "./[id]/refreshInventory";
|
||||||
|
import { default as link } from "./[id]/link";
|
||||||
|
import { default as unlink } from "./[id]/unlink";
|
||||||
|
import { default as fetchLinked } from "./[id]/fetchLinked";
|
||||||
|
import { default as count } from "./count";
|
||||||
|
|
||||||
|
export { count, fetch, fetchAll, fetchLinked, link, refreshInventory, unlink };
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import * as procurementRoutes from "../procurement";
|
||||||
|
|
||||||
|
const procurementRouter = new Hono();
|
||||||
|
Object.values(procurementRoutes).map((r) => procurementRouter.route("/", r));
|
||||||
|
|
||||||
|
export default procurementRouter;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import * as salesRoutes from "../sales";
|
||||||
|
|
||||||
|
const salesRouter = new Hono();
|
||||||
|
Object.values(salesRoutes).map((r) => salesRouter.route("/", r));
|
||||||
|
|
||||||
|
export default salesRouter;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
import { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/:identifier/contacts */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/:identifier/contacts"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const item = await opportunities.fetchItem(identifier);
|
||||||
|
|
||||||
|
const contacts = await opportunityCw.fetchContacts(item.cwOpportunityId);
|
||||||
|
|
||||||
|
const data = contacts.map((ct) => ({
|
||||||
|
id: ct.id,
|
||||||
|
contact: ct.contact ? { id: ct.contact.id, name: ct.contact.name } : null,
|
||||||
|
company: ct.company
|
||||||
|
? {
|
||||||
|
id: ct.company.id,
|
||||||
|
identifier: ct.company.identifier,
|
||||||
|
name: ct.company.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
role: ct.role ? { id: ct.role.id, name: ct.role.name } : null,
|
||||||
|
notes: ct.notes,
|
||||||
|
referralFlag: ct.referralFlag,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Opportunity contacts fetched successfully!",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/:identifier */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/:identifier"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
|
||||||
|
const item = await opportunities.fetchItem(identifier);
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Opportunity fetched successfully!",
|
||||||
|
item.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
import { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/:identifier/forecasts */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/:identifier/forecasts"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const item = await opportunities.fetchItem(identifier);
|
||||||
|
|
||||||
|
const forecasts = await opportunityCw.fetchForecasts(item.cwOpportunityId);
|
||||||
|
|
||||||
|
const data = forecasts.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
forecastType: f.forecastType,
|
||||||
|
forecastMonth: f.forecastMonth,
|
||||||
|
revenue: f.revenue,
|
||||||
|
cost: f.cost,
|
||||||
|
forecastPercentage: f.forecastPercentage,
|
||||||
|
status: f.status ? { id: f.status.id, name: f.status.name } : null,
|
||||||
|
includedFlag: f.includedFlag,
|
||||||
|
linkedFlag: f.linkedFlag,
|
||||||
|
recurringFlag: f.recurringFlag,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Opportunity forecasts fetched successfully!",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
import { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/:identifier/notes */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/:identifier/notes"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const item = await opportunities.fetchItem(identifier);
|
||||||
|
|
||||||
|
const notes = await opportunityCw.fetchNotes(item.cwOpportunityId);
|
||||||
|
|
||||||
|
const data = notes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
text: n.text,
|
||||||
|
type: n.type ? { id: n.type.id, name: n.type.name } : null,
|
||||||
|
flagged: n.flagged,
|
||||||
|
enteredBy: n.enteredBy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Opportunity notes fetched successfully!",
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
|
||||||
|
/* POST /v1/sales/opportunities/:identifier/refresh */
|
||||||
|
export default createRoute(
|
||||||
|
"post",
|
||||||
|
["/opportunities/:identifier/refresh"],
|
||||||
|
async (c) => {
|
||||||
|
const identifier = c.req.param("identifier");
|
||||||
|
const item = await opportunities.fetchItem(identifier);
|
||||||
|
|
||||||
|
const refreshed = await item.refreshFromCW();
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Opportunity refreshed from ConnectWise successfully!",
|
||||||
|
refreshed.toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.refresh"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/count */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/count"],
|
||||||
|
async (c) => {
|
||||||
|
const openOnly = c.req.query("openOnly") === "true";
|
||||||
|
|
||||||
|
const count = await opportunities.count({ openOnly });
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Opportunity count fetched successfully!",
|
||||||
|
{ count },
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../middleware/authorization";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities"],
|
||||||
|
async (c) => {
|
||||||
|
const page = Number(c.req.query("page") ?? 1);
|
||||||
|
const rpp = Number(c.req.query("rpp") ?? 30);
|
||||||
|
const search = c.req.query("search") as string;
|
||||||
|
const includeClosed = c.req.query("includeClosed") === "true";
|
||||||
|
|
||||||
|
const data = search
|
||||||
|
? await opportunities.search(search, page, rpp, { includeClosed })
|
||||||
|
: await opportunities.fetchPages(page, rpp, { includeClosed });
|
||||||
|
|
||||||
|
const totalRecords = await opportunities.count({
|
||||||
|
openOnly: !includeClosed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Opportunities fetched successfully!",
|
||||||
|
data.map((item) => item.toJson()),
|
||||||
|
{
|
||||||
|
pagination: {
|
||||||
|
previousPage: page <= 1 ? null : page - 1,
|
||||||
|
currentPage: page,
|
||||||
|
nextPage: page >= totalRecords / rpp ? null : page + 1,
|
||||||
|
totalPages: Math.ceil(totalRecords / rpp),
|
||||||
|
totalRecords,
|
||||||
|
listedRecords: rpp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { default as fetchAll } from "./fetchAll";
|
||||||
|
import { default as count } from "./count";
|
||||||
|
import { default as fetch } from "./[id]/fetch";
|
||||||
|
import { default as refresh } from "./[id]/refresh";
|
||||||
|
import { default as forecasts } from "./[id]/forecasts";
|
||||||
|
import { default as notes } from "./[id]/notes";
|
||||||
|
import { default as contacts } from "./[id]/contacts";
|
||||||
|
|
||||||
|
export { count, fetch, fetchAll, forecasts, notes, contacts, refresh };
|
||||||
@@ -55,6 +55,8 @@ v1.route("/credential-type", require("./routers/credentialTypeRouter").default);
|
|||||||
v1.route("/role", require("./routers/roleRouter").default);
|
v1.route("/role", require("./routers/roleRouter").default);
|
||||||
v1.route("/permissions", require("./routers/permissionRouter").default);
|
v1.route("/permissions", require("./routers/permissionRouter").default);
|
||||||
v1.route("/unifi", require("./routers/unifiRouter").default);
|
v1.route("/unifi", require("./routers/unifiRouter").default);
|
||||||
|
v1.route("/procurement", require("./routers/procurementRouter").default);
|
||||||
|
v1.route("/sales", require("./routers/salesRouter").default);
|
||||||
app.route("/v1", v1);
|
app.route("/v1", v1);
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { CatalogItem } from "../../generated/prisma/client";
|
||||||
|
import { prisma } from "../constants";
|
||||||
|
import { catalogCw } from "../modules/cw-utils/procurement/catalog";
|
||||||
|
import { CatalogItem as CWCatalogItem } from "../modules/cw-utils/procurement/catalog.types";
|
||||||
|
import GenericError from "../Errors/GenericError";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog Item Controller
|
||||||
|
*
|
||||||
|
* This class encapsulates a catalog item entity and provides domain methods
|
||||||
|
* for accessing, refreshing, and serializing catalog item data. It bridges
|
||||||
|
* the internal database representation with ConnectWise catalog data.
|
||||||
|
*/
|
||||||
|
export class CatalogItemController {
|
||||||
|
public readonly id: string;
|
||||||
|
public name: string;
|
||||||
|
public description: string | null;
|
||||||
|
public customerDescription: string | null;
|
||||||
|
public internalNotes: string | null;
|
||||||
|
|
||||||
|
public readonly cwCatalogId: number;
|
||||||
|
public readonly identifier: string | null;
|
||||||
|
|
||||||
|
public manufacturer: string | null;
|
||||||
|
public manufactureCwId: number | null;
|
||||||
|
public partNumber: string | null;
|
||||||
|
|
||||||
|
public vendorName: string | null;
|
||||||
|
public vendorSku: string | null;
|
||||||
|
public vendorCwId: number | null;
|
||||||
|
|
||||||
|
public price: number;
|
||||||
|
public cost: number;
|
||||||
|
|
||||||
|
public inactive: boolean;
|
||||||
|
public salesTaxable: boolean;
|
||||||
|
|
||||||
|
public onHand: number;
|
||||||
|
public cwLastUpdated: Date | null;
|
||||||
|
|
||||||
|
private _linkedItems: CatalogItemController[];
|
||||||
|
|
||||||
|
public readonly createdAt: Date;
|
||||||
|
public updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
itemData: CatalogItem & {
|
||||||
|
linkedItems?: CatalogItem[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.id = itemData.id;
|
||||||
|
this.name = itemData.name;
|
||||||
|
this.description = itemData.description;
|
||||||
|
this.customerDescription = itemData.customerDescription;
|
||||||
|
this.internalNotes = itemData.internalNotes;
|
||||||
|
this.cwCatalogId = itemData.cwCatalogId;
|
||||||
|
this.identifier = itemData.identifier;
|
||||||
|
this.manufacturer = itemData.manufacturer;
|
||||||
|
this.manufactureCwId = itemData.manufactureCwId;
|
||||||
|
this.partNumber = itemData.partNumber;
|
||||||
|
this.vendorName = itemData.vendorName;
|
||||||
|
this.vendorSku = itemData.vendorSku;
|
||||||
|
this.vendorCwId = itemData.vendorCwId;
|
||||||
|
this.price = itemData.price;
|
||||||
|
this.cost = itemData.cost;
|
||||||
|
this.inactive = itemData.inactive;
|
||||||
|
this.salesTaxable = itemData.salesTaxable;
|
||||||
|
this.onHand = itemData.onHand;
|
||||||
|
this.cwLastUpdated = itemData.cwLastUpdated;
|
||||||
|
this.createdAt = itemData.createdAt;
|
||||||
|
this.updatedAt = itemData.updatedAt;
|
||||||
|
|
||||||
|
this._linkedItems = (itemData.linkedItems ?? []).map(
|
||||||
|
(linked) => new CatalogItemController(linked),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh Inventory
|
||||||
|
*
|
||||||
|
* Fetches the latest on-hand inventory count from ConnectWise
|
||||||
|
* and updates both the controller state and the database.
|
||||||
|
*
|
||||||
|
* @returns {Promise<CatalogItemController>} - The updated controller
|
||||||
|
*/
|
||||||
|
public async refreshInventory(): Promise<CatalogItemController> {
|
||||||
|
const onHand = await catalogCw.fetchInventoryOnHand(this.cwCatalogId);
|
||||||
|
|
||||||
|
if (onHand !== this.onHand) {
|
||||||
|
await prisma.catalogItem.update({
|
||||||
|
where: { id: this.id },
|
||||||
|
data: { onHand },
|
||||||
|
});
|
||||||
|
this.onHand = onHand;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Linked Items
|
||||||
|
*
|
||||||
|
* Returns the linked catalog items as an array of controllers.
|
||||||
|
*
|
||||||
|
* @returns {CatalogItemController[]} - Array of linked item controllers
|
||||||
|
*/
|
||||||
|
public getLinkedItems(): CatalogItemController[] {
|
||||||
|
return this._linkedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link Item
|
||||||
|
*
|
||||||
|
* Links another catalog item to this item. The relationship is bidirectional
|
||||||
|
* via the Prisma implicit many-to-many.
|
||||||
|
*
|
||||||
|
* @param targetId - The internal ID of the catalog item to link
|
||||||
|
* @returns {Promise<CatalogItemController>} - The updated controller
|
||||||
|
*/
|
||||||
|
public async linkItem(targetId: string): Promise<CatalogItemController> {
|
||||||
|
if (targetId === this.id) {
|
||||||
|
throw new GenericError({
|
||||||
|
message: "Cannot link a catalog item to itself",
|
||||||
|
name: "InvalidLinkTarget",
|
||||||
|
cause: `Item '${this.id}' cannot be linked to itself`,
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await prisma.catalogItem.findFirst({
|
||||||
|
where: { id: targetId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new GenericError({
|
||||||
|
message: "Target catalog item not found",
|
||||||
|
name: "CatalogItemNotFound",
|
||||||
|
cause: `No catalog item exists with ID '${targetId}'`,
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.catalogItem.update({
|
||||||
|
where: { id: this.id },
|
||||||
|
data: {
|
||||||
|
linkedItems: { connect: { id: targetId } },
|
||||||
|
},
|
||||||
|
include: { linkedItems: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
this._linkedItems = (updated.linkedItems ?? []).map(
|
||||||
|
(linked) => new CatalogItemController(linked),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink Item
|
||||||
|
*
|
||||||
|
* Removes the link between this catalog item and another.
|
||||||
|
*
|
||||||
|
* @param targetId - The internal ID of the catalog item to unlink
|
||||||
|
* @returns {Promise<CatalogItemController>} - The updated controller
|
||||||
|
*/
|
||||||
|
public async unlinkItem(targetId: string): Promise<CatalogItemController> {
|
||||||
|
const updated = await prisma.catalogItem.update({
|
||||||
|
where: { id: this.id },
|
||||||
|
data: {
|
||||||
|
linkedItems: { disconnect: { id: targetId } },
|
||||||
|
},
|
||||||
|
include: { linkedItems: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
this._linkedItems = (updated.linkedItems ?? []).map(
|
||||||
|
(linked) => new CatalogItemController(linked),
|
||||||
|
);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To JSON
|
||||||
|
*
|
||||||
|
* Serializes the catalog item into a safe, API-friendly object.
|
||||||
|
*
|
||||||
|
* @param opts - Options to control output
|
||||||
|
* @returns - A JSON-safe representation of the catalog item
|
||||||
|
*/
|
||||||
|
public toJson(opts?: { includeLinkedItems?: boolean }): Record<string, any> {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
cwCatalogId: this.cwCatalogId,
|
||||||
|
identifier: this.identifier,
|
||||||
|
name: this.name,
|
||||||
|
description: this.description,
|
||||||
|
customerDescription: this.customerDescription,
|
||||||
|
internalNotes: this.internalNotes,
|
||||||
|
manufacturer: this.manufacturer,
|
||||||
|
manufactureCwId: this.manufactureCwId,
|
||||||
|
partNumber: this.partNumber,
|
||||||
|
vendorName: this.vendorName,
|
||||||
|
vendorSku: this.vendorSku,
|
||||||
|
vendorCwId: this.vendorCwId,
|
||||||
|
price: this.price,
|
||||||
|
cost: this.cost,
|
||||||
|
inactive: this.inactive,
|
||||||
|
salesTaxable: this.salesTaxable,
|
||||||
|
onHand: this.onHand,
|
||||||
|
cwLastUpdated: this.cwLastUpdated,
|
||||||
|
linkedItems: opts?.includeLinkedItems
|
||||||
|
? this._linkedItems.map((item) => item.toJson())
|
||||||
|
: undefined,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
updatedAt: this.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ export class CompanyController {
|
|||||||
public readonly cw_CompanyId: number;
|
public readonly cw_CompanyId: number;
|
||||||
public readonly cw_Data?: {
|
public readonly cw_Data?: {
|
||||||
company: CWCompany;
|
company: CWCompany;
|
||||||
defaultContact: Contact;
|
defaultContact: Contact | null;
|
||||||
allContacts: Contact[];
|
allContacts: Contact[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,23 +96,25 @@ export class CompanyController {
|
|||||||
},
|
},
|
||||||
primaryContact: !opts?.includePrimaryContact
|
primaryContact: !opts?.includePrimaryContact
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: this.cw_Data?.defaultContact
|
||||||
firstName: this.cw_Data?.defaultContact.firstName,
|
? {
|
||||||
lastName: this.cw_Data?.defaultContact.lastName,
|
firstName: this.cw_Data.defaultContact.firstName,
|
||||||
cwId: this.cw_Data?.defaultContact.id,
|
lastName: this.cw_Data.defaultContact.lastName,
|
||||||
inactive: this.cw_Data?.defaultContact.inactiveFlag,
|
cwId: this.cw_Data.defaultContact.id,
|
||||||
title: this.cw_Data?.defaultContact.title,
|
inactive: this.cw_Data.defaultContact.inactiveFlag,
|
||||||
phone: this.cw_Data?.defaultContact.defaultPhoneNbr,
|
title: this.cw_Data.defaultContact.title,
|
||||||
email: (() => {
|
phone: this.cw_Data.defaultContact.defaultPhoneNbr,
|
||||||
if (!this.cw_Data?.defaultContact.communicationItems)
|
email: (() => {
|
||||||
return null;
|
if (!this.cw_Data?.defaultContact?.communicationItems)
|
||||||
return (
|
return null;
|
||||||
this.cw_Data?.defaultContact.communicationItems.find(
|
return (
|
||||||
(v) => v.type.name === "Email",
|
this.cw_Data.defaultContact.communicationItems.find(
|
||||||
)?.value ?? null
|
(v) => v.type.name === "Email",
|
||||||
);
|
)?.value ?? null
|
||||||
})(),
|
);
|
||||||
},
|
})(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
allContacts: !opts?.includeAllContacts
|
allContacts: !opts?.includeAllContacts
|
||||||
? undefined
|
? undefined
|
||||||
: this.cw_Data?.allContacts.map((contact) => ({
|
: this.cw_Data?.allContacts.map((contact) => ({
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { Opportunity } from "../../generated/prisma/client";
|
||||||
|
import { prisma } from "../constants";
|
||||||
|
import { fetchOpportunity } from "../modules/cw-utils/opportunities/fetchOpportunity";
|
||||||
|
import { CWOpportunity } from "../modules/cw-utils/opportunities/opportunity.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opportunity Controller
|
||||||
|
*
|
||||||
|
* Domain model class that encapsulates an Opportunity entity and provides
|
||||||
|
* methods for accessing, refreshing from ConnectWise, and serializing
|
||||||
|
* opportunity data.
|
||||||
|
*/
|
||||||
|
export class OpportunityController {
|
||||||
|
public readonly id: string;
|
||||||
|
public readonly cwOpportunityId: number;
|
||||||
|
public name: string;
|
||||||
|
public notes: string | null;
|
||||||
|
|
||||||
|
public typeName: string | null;
|
||||||
|
public typeCwId: number | null;
|
||||||
|
public stageName: string | null;
|
||||||
|
public stageCwId: number | null;
|
||||||
|
public statusName: string | null;
|
||||||
|
public statusCwId: number | null;
|
||||||
|
public priorityName: string | null;
|
||||||
|
public priorityCwId: number | null;
|
||||||
|
public ratingName: string | null;
|
||||||
|
public ratingCwId: number | null;
|
||||||
|
public source: string | null;
|
||||||
|
public campaignName: string | null;
|
||||||
|
public campaignCwId: number | null;
|
||||||
|
|
||||||
|
public primarySalesRepName: string | null;
|
||||||
|
public primarySalesRepIdentifier: string | null;
|
||||||
|
public primarySalesRepCwId: number | null;
|
||||||
|
public secondarySalesRepName: string | null;
|
||||||
|
public secondarySalesRepIdentifier: string | null;
|
||||||
|
public secondarySalesRepCwId: number | null;
|
||||||
|
|
||||||
|
public companyCwId: number | null;
|
||||||
|
public companyName: string | null;
|
||||||
|
public contactCwId: number | null;
|
||||||
|
public contactName: string | null;
|
||||||
|
public siteCwId: number | null;
|
||||||
|
public siteName: string | null;
|
||||||
|
public customerPO: string | null;
|
||||||
|
|
||||||
|
public totalSalesTax: number;
|
||||||
|
|
||||||
|
public locationName: string | null;
|
||||||
|
public locationCwId: number | null;
|
||||||
|
public departmentName: string | null;
|
||||||
|
public departmentCwId: number | null;
|
||||||
|
|
||||||
|
public expectedCloseDate: Date | null;
|
||||||
|
public pipelineChangeDate: Date | null;
|
||||||
|
public dateBecameLead: Date | null;
|
||||||
|
public closedDate: Date | null;
|
||||||
|
public closedFlag: boolean;
|
||||||
|
public closedByName: string | null;
|
||||||
|
public closedByCwId: number | null;
|
||||||
|
|
||||||
|
public companyId: string | null;
|
||||||
|
public cwLastUpdated: Date | null;
|
||||||
|
|
||||||
|
public readonly createdAt: Date;
|
||||||
|
public updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(data: Opportunity) {
|
||||||
|
this.id = data.id;
|
||||||
|
this.cwOpportunityId = data.cwOpportunityId;
|
||||||
|
this.name = data.name;
|
||||||
|
this.notes = data.notes;
|
||||||
|
|
||||||
|
this.typeName = data.typeName;
|
||||||
|
this.typeCwId = data.typeCwId;
|
||||||
|
this.stageName = data.stageName;
|
||||||
|
this.stageCwId = data.stageCwId;
|
||||||
|
this.statusName = data.statusName;
|
||||||
|
this.statusCwId = data.statusCwId;
|
||||||
|
this.priorityName = data.priorityName;
|
||||||
|
this.priorityCwId = data.priorityCwId;
|
||||||
|
this.ratingName = data.ratingName;
|
||||||
|
this.ratingCwId = data.ratingCwId;
|
||||||
|
this.source = data.source;
|
||||||
|
this.campaignName = data.campaignName;
|
||||||
|
this.campaignCwId = data.campaignCwId;
|
||||||
|
|
||||||
|
this.primarySalesRepName = data.primarySalesRepName;
|
||||||
|
this.primarySalesRepIdentifier = data.primarySalesRepIdentifier;
|
||||||
|
this.primarySalesRepCwId = data.primarySalesRepCwId;
|
||||||
|
this.secondarySalesRepName = data.secondarySalesRepName;
|
||||||
|
this.secondarySalesRepIdentifier = data.secondarySalesRepIdentifier;
|
||||||
|
this.secondarySalesRepCwId = data.secondarySalesRepCwId;
|
||||||
|
|
||||||
|
this.companyCwId = data.companyCwId;
|
||||||
|
this.companyName = data.companyName;
|
||||||
|
this.contactCwId = data.contactCwId;
|
||||||
|
this.contactName = data.contactName;
|
||||||
|
this.siteCwId = data.siteCwId;
|
||||||
|
this.siteName = data.siteName;
|
||||||
|
this.customerPO = data.customerPO;
|
||||||
|
|
||||||
|
this.totalSalesTax = data.totalSalesTax;
|
||||||
|
|
||||||
|
this.locationName = data.locationName;
|
||||||
|
this.locationCwId = data.locationCwId;
|
||||||
|
this.departmentName = data.departmentName;
|
||||||
|
this.departmentCwId = data.departmentCwId;
|
||||||
|
|
||||||
|
this.expectedCloseDate = data.expectedCloseDate;
|
||||||
|
this.pipelineChangeDate = data.pipelineChangeDate;
|
||||||
|
this.dateBecameLead = data.dateBecameLead;
|
||||||
|
this.closedDate = data.closedDate;
|
||||||
|
this.closedFlag = data.closedFlag;
|
||||||
|
this.closedByName = data.closedByName;
|
||||||
|
this.closedByCwId = data.closedByCwId;
|
||||||
|
|
||||||
|
this.companyId = data.companyId;
|
||||||
|
this.cwLastUpdated = data.cwLastUpdated;
|
||||||
|
|
||||||
|
this.createdAt = data.createdAt;
|
||||||
|
this.updatedAt = data.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh from ConnectWise
|
||||||
|
*
|
||||||
|
* Fetches the latest opportunity data from CW and updates
|
||||||
|
* the local database record and controller state.
|
||||||
|
*/
|
||||||
|
public async refreshFromCW(): Promise<OpportunityController> {
|
||||||
|
const cwData = await fetchOpportunity(this.cwOpportunityId);
|
||||||
|
const mapped = OpportunityController.mapCwToDb(cwData);
|
||||||
|
|
||||||
|
const updated = await prisma.opportunity.update({
|
||||||
|
where: { id: this.id },
|
||||||
|
data: mapped,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new OpportunityController(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch raw CW data
|
||||||
|
*
|
||||||
|
* Returns the raw ConnectWise opportunity object without updating the DB.
|
||||||
|
*/
|
||||||
|
public async fetchCwData(): Promise<CWOpportunity> {
|
||||||
|
return fetchOpportunity(this.cwOpportunityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map CW Opportunity → Prisma create/update payload
|
||||||
|
*
|
||||||
|
* Static helper used by both the controller and the refresh sync.
|
||||||
|
*/
|
||||||
|
public static mapCwToDb(item: CWOpportunity) {
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
notes: item.notes ?? null,
|
||||||
|
|
||||||
|
typeName: item.type?.name ?? null,
|
||||||
|
typeCwId: item.type?.id ?? null,
|
||||||
|
stageName: item.stage?.name ?? null,
|
||||||
|
stageCwId: item.stage?.id ?? null,
|
||||||
|
statusName: item.status?.name ?? null,
|
||||||
|
statusCwId: item.status?.id ?? null,
|
||||||
|
priorityName: item.priority?.name ?? null,
|
||||||
|
priorityCwId: item.priority?.id ?? null,
|
||||||
|
ratingName: item.rating?.name ?? null,
|
||||||
|
ratingCwId: item.rating?.id ?? null,
|
||||||
|
source: item.source ?? null,
|
||||||
|
campaignName: item.campaign?.name ?? null,
|
||||||
|
campaignCwId: item.campaign?.id ?? null,
|
||||||
|
|
||||||
|
primarySalesRepName: item.primarySalesRep?.name ?? null,
|
||||||
|
primarySalesRepIdentifier: item.primarySalesRep?.identifier ?? null,
|
||||||
|
primarySalesRepCwId: item.primarySalesRep?.id ?? null,
|
||||||
|
secondarySalesRepName: item.secondarySalesRep?.name ?? null,
|
||||||
|
secondarySalesRepIdentifier: item.secondarySalesRep?.identifier ?? null,
|
||||||
|
secondarySalesRepCwId: item.secondarySalesRep?.id ?? null,
|
||||||
|
|
||||||
|
companyCwId: item.company?.id ?? null,
|
||||||
|
companyName: item.company?.name ?? null,
|
||||||
|
contactCwId: item.contact?.id ?? null,
|
||||||
|
contactName: item.contact?.name ?? null,
|
||||||
|
siteCwId: item.site?.id ?? null,
|
||||||
|
siteName: item.site?.name ?? null,
|
||||||
|
customerPO: item.customerPO ?? null,
|
||||||
|
|
||||||
|
totalSalesTax: item.totalSalesTax ?? 0,
|
||||||
|
|
||||||
|
locationName: item.location?.name ?? null,
|
||||||
|
locationCwId: item.location?.id ?? null,
|
||||||
|
departmentName: item.department?.name ?? null,
|
||||||
|
departmentCwId: item.department?.id ?? null,
|
||||||
|
|
||||||
|
expectedCloseDate: item.expectedCloseDate
|
||||||
|
? new Date(item.expectedCloseDate)
|
||||||
|
: null,
|
||||||
|
pipelineChangeDate: item.pipelineChangeDate
|
||||||
|
? new Date(item.pipelineChangeDate)
|
||||||
|
: null,
|
||||||
|
dateBecameLead: item.dateBecameLead
|
||||||
|
? new Date(item.dateBecameLead)
|
||||||
|
: null,
|
||||||
|
closedDate: item.closedDate ? new Date(item.closedDate) : null,
|
||||||
|
closedFlag: item.closedFlag ?? false,
|
||||||
|
closedByName: item.closedBy?.name ?? null,
|
||||||
|
closedByCwId: item.closedBy?.id ?? null,
|
||||||
|
|
||||||
|
cwLastUpdated: item._info?.lastUpdated
|
||||||
|
? new Date(item._info.lastUpdated)
|
||||||
|
: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To JSON
|
||||||
|
*
|
||||||
|
* Serializes the opportunity into a safe, API-friendly object.
|
||||||
|
*/
|
||||||
|
public toJson(): Record<string, any> {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
cwOpportunityId: this.cwOpportunityId,
|
||||||
|
name: this.name,
|
||||||
|
notes: this.notes,
|
||||||
|
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
|
||||||
|
stage: this.stageCwId
|
||||||
|
? { id: this.stageCwId, name: this.stageName }
|
||||||
|
: null,
|
||||||
|
status: this.statusCwId
|
||||||
|
? { id: this.statusCwId, name: this.statusName }
|
||||||
|
: null,
|
||||||
|
priority: this.priorityCwId
|
||||||
|
? { id: this.priorityCwId, name: this.priorityName }
|
||||||
|
: null,
|
||||||
|
rating: this.ratingCwId
|
||||||
|
? { id: this.ratingCwId, name: this.ratingName }
|
||||||
|
: null,
|
||||||
|
source: this.source,
|
||||||
|
campaign: this.campaignCwId
|
||||||
|
? { id: this.campaignCwId, name: this.campaignName }
|
||||||
|
: null,
|
||||||
|
primarySalesRep: this.primarySalesRepCwId
|
||||||
|
? {
|
||||||
|
id: this.primarySalesRepCwId,
|
||||||
|
identifier: this.primarySalesRepIdentifier,
|
||||||
|
name: this.primarySalesRepName,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
secondarySalesRep: this.secondarySalesRepCwId
|
||||||
|
? {
|
||||||
|
id: this.secondarySalesRepCwId,
|
||||||
|
identifier: this.secondarySalesRepIdentifier,
|
||||||
|
name: this.secondarySalesRepName,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
company: this.companyCwId
|
||||||
|
? { id: this.companyCwId, name: this.companyName }
|
||||||
|
: null,
|
||||||
|
contact: this.contactCwId
|
||||||
|
? { id: this.contactCwId, name: this.contactName }
|
||||||
|
: null,
|
||||||
|
site: this.siteCwId ? { id: this.siteCwId, name: this.siteName } : null,
|
||||||
|
customerPO: this.customerPO,
|
||||||
|
totalSalesTax: this.totalSalesTax,
|
||||||
|
location: this.locationCwId
|
||||||
|
? { id: this.locationCwId, name: this.locationName }
|
||||||
|
: null,
|
||||||
|
department: this.departmentCwId
|
||||||
|
? { id: this.departmentCwId, name: this.departmentName }
|
||||||
|
: null,
|
||||||
|
expectedCloseDate: this.expectedCloseDate,
|
||||||
|
pipelineChangeDate: this.pipelineChangeDate,
|
||||||
|
dateBecameLead: this.dateBecameLead,
|
||||||
|
closedDate: this.closedDate,
|
||||||
|
closedFlag: this.closedFlag,
|
||||||
|
closedBy: this.closedByCwId
|
||||||
|
? { id: this.closedByCwId, name: this.closedByName }
|
||||||
|
: null,
|
||||||
|
companyId: this.companyId,
|
||||||
|
cwLastUpdated: this.cwLastUpdated,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
updatedAt: this.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,6 +178,46 @@ export default class UserController {
|
|||||||
return decoded.permissions;
|
return decoded.permissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read Role Permissions
|
||||||
|
*
|
||||||
|
* Verifies and decodes a role permissions JWT and returns the permission nodes.
|
||||||
|
* Returns an empty array if verification fails.
|
||||||
|
*
|
||||||
|
* @param role - Role record containing the signed permissions token
|
||||||
|
* @returns {string[]} The role permission nodes
|
||||||
|
*/
|
||||||
|
private _readRolePermissions(role: Role): string[] {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(role.permissions, permissionsPrivateKey, {
|
||||||
|
algorithms: ["RS256"],
|
||||||
|
issuer: "roles",
|
||||||
|
subject: role.id,
|
||||||
|
}) as DecodedPermissionsBlock;
|
||||||
|
|
||||||
|
return decoded.permissions;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read All Permissions
|
||||||
|
*
|
||||||
|
* Aggregates the user's direct permissions and all permissions from their assigned roles
|
||||||
|
* into a single deduplicated array.
|
||||||
|
*
|
||||||
|
* @returns {Promise<string[]>} Combined array of all permission nodes
|
||||||
|
*/
|
||||||
|
public async readAllPermissions(): Promise<string[]> {
|
||||||
|
const directPermissions = this.readPermissions();
|
||||||
|
const rolePermissions = this._roles
|
||||||
|
.map((role) => this._readRolePermissions(role))
|
||||||
|
.flatMap((permissions) => permissions);
|
||||||
|
|
||||||
|
return [...new Set([...directPermissions, ...rolePermissions])];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch Roles
|
* Fetch Roles
|
||||||
*
|
*
|
||||||
@@ -262,7 +302,16 @@ export default class UserController {
|
|||||||
: this._roles.size > 0
|
: this._roles.size > 0
|
||||||
? this._roles.map((v) => v.moniker)
|
? this._roles.map((v) => v.moniker)
|
||||||
: undefined,
|
: undefined,
|
||||||
permissions: opts?.safeReturn ? undefined : this.readPermissions(),
|
permissions: opts?.safeReturn
|
||||||
|
? undefined
|
||||||
|
: (() => {
|
||||||
|
const directPermissions = this.readPermissions();
|
||||||
|
const rolePermissions = this._roles
|
||||||
|
.map((role) => this._readRolePermissions(role))
|
||||||
|
.flatMap((permissions) => permissions);
|
||||||
|
|
||||||
|
return [...new Set([...directPermissions, ...rolePermissions])];
|
||||||
|
})(),
|
||||||
login: opts?.safeReturn ? undefined : this.login,
|
login: opts?.safeReturn ? undefined : this.login,
|
||||||
email: opts?.safeReturn ? undefined : this.email,
|
email: opts?.safeReturn ? undefined : this.email,
|
||||||
image: this.image,
|
image: this.image,
|
||||||
|
|||||||
+37
-8
@@ -12,6 +12,7 @@ import { unifiSites } from "./managers/unifiSites";
|
|||||||
import { refreshCompanies } from "./modules/cw-utils/refreshCompanies";
|
import { refreshCompanies } from "./modules/cw-utils/refreshCompanies";
|
||||||
import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog";
|
import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog";
|
||||||
import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory";
|
import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory";
|
||||||
|
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
|
||||||
import { events, setupEventDebugger } from "./modules/globalEvents";
|
import { events, setupEventDebugger } from "./modules/globalEvents";
|
||||||
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
||||||
import { RoleController } from "./controllers/RoleController";
|
import { RoleController } from "./controllers/RoleController";
|
||||||
@@ -44,30 +45,58 @@ if (!existingAdmin) {
|
|||||||
events.emit("role:created", new RoleController(created));
|
events.emit("role:created", new RoleController(created));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to run a startup sync safely — failures are logged but never crash the process.
|
||||||
|
const safeStartup = async (label: string, fn: () => Promise<void>) => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[startup] ${label} failed — will retry on next interval`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Refresh the internal list of companies every minute
|
// Refresh the internal list of companies every minute
|
||||||
await refreshCompanies();
|
await safeStartup("refreshCompanies", refreshCompanies);
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
return refreshCompanies();
|
return refreshCompanies().catch((err) =>
|
||||||
|
console.error("[interval] refreshCompanies failed", err),
|
||||||
|
);
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
// Refresh the internal catalog every minute
|
// Refresh the internal catalog every minute
|
||||||
await refreshCatalog();
|
await safeStartup("refreshCatalog", refreshCatalog);
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
return refreshCatalog();
|
return refreshCatalog().catch((err) =>
|
||||||
|
console.error("[interval] refreshCatalog failed", err),
|
||||||
|
);
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
// Refresh inventory on hand every 2 minutes
|
// Refresh inventory on hand every 2 minutes
|
||||||
await refreshInventory();
|
await safeStartup("refreshInventory", refreshInventory);
|
||||||
setInterval(
|
setInterval(
|
||||||
() => {
|
() => {
|
||||||
return refreshInventory();
|
return refreshInventory().catch((err) =>
|
||||||
|
console.error("[interval] refreshInventory failed", err),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
2 * 60 * 1000,
|
2 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
await unifiSites.syncSites();
|
// Refresh opportunities every minute
|
||||||
|
await safeStartup("refreshOpportunities", refreshOpportunities);
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
return unifiSites.syncSites();
|
return refreshOpportunities().catch((err) =>
|
||||||
|
console.error("[interval] refreshOpportunities failed", err),
|
||||||
|
);
|
||||||
|
}, 60 * 1000);
|
||||||
|
|
||||||
|
await safeStartup("syncSites", () => unifiSites.syncSites());
|
||||||
|
setInterval(() => {
|
||||||
|
return unifiSites
|
||||||
|
.syncSites()
|
||||||
|
.catch((err) => console.error("[interval] syncSites failed", err));
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
|
|||||||
@@ -15,16 +15,19 @@ export const companies = {
|
|||||||
const freshCwData: { data: Company } = await connectWiseApi.get(
|
const freshCwData: { data: Company } = await connectWiseApi.get(
|
||||||
`/company/companies/${search.cw_CompanyId}`,
|
`/company/companies/${search.cw_CompanyId}`,
|
||||||
);
|
);
|
||||||
const defaultContactData = await connectWiseApi.get(
|
|
||||||
(freshCwData.data as Company).defaultContact._info.contact_href,
|
const contactHref = freshCwData.data.defaultContact?._info?.contact_href;
|
||||||
);
|
const defaultContactData = contactHref
|
||||||
|
? await connectWiseApi.get(contactHref)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const allContactsData = await connectWiseApi.get(
|
const allContactsData = await connectWiseApi.get(
|
||||||
`${freshCwData.data._info.contacts_href}&pageSize=1000`,
|
`${freshCwData.data._info.contacts_href}&pageSize=1000`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new CompanyController(search, {
|
return new CompanyController(search, {
|
||||||
company: freshCwData.data,
|
company: freshCwData.data,
|
||||||
defaultContact: defaultContactData.data,
|
defaultContact: defaultContactData?.data ?? null,
|
||||||
allContacts: allContactsData.data,
|
allContacts: allContactsData.data,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { prisma } from "../constants";
|
||||||
|
import { OpportunityController } from "../controllers/OpportunityController";
|
||||||
|
import GenericError from "../Errors/GenericError";
|
||||||
|
|
||||||
|
export const opportunities = {
|
||||||
|
/**
|
||||||
|
* Fetch Opportunity
|
||||||
|
*
|
||||||
|
* Fetch an opportunity by its internal ID or ConnectWise opportunity ID
|
||||||
|
* and return an OpportunityController instance.
|
||||||
|
*
|
||||||
|
* @param identifier - The internal ID (string) or CW opportunity ID (number)
|
||||||
|
* @returns {Promise<OpportunityController>}
|
||||||
|
*/
|
||||||
|
async fetchItem(identifier: string | number): Promise<OpportunityController> {
|
||||||
|
const isNumeric =
|
||||||
|
typeof identifier === "number" || /^\d+$/.test(String(identifier));
|
||||||
|
|
||||||
|
const item = await prisma.opportunity.findFirst({
|
||||||
|
where: isNumeric
|
||||||
|
? { cwOpportunityId: Number(identifier) }
|
||||||
|
: { id: identifier as string },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new GenericError({
|
||||||
|
message: "Opportunity not found",
|
||||||
|
name: "OpportunityNotFound",
|
||||||
|
cause: `No opportunity exists with identifier '${identifier}'`,
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OpportunityController(item);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch All Opportunities (Paginated)
|
||||||
|
*
|
||||||
|
* @param page - Page number (1-based)
|
||||||
|
* @param rpp - Records per page
|
||||||
|
* @param opts - Optional filters
|
||||||
|
* @returns {Promise<OpportunityController[]>}
|
||||||
|
*/
|
||||||
|
async fetchPages(
|
||||||
|
page: number,
|
||||||
|
rpp: number,
|
||||||
|
opts?: { includeClosed?: boolean },
|
||||||
|
): Promise<OpportunityController[]> {
|
||||||
|
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||||
|
|
||||||
|
const items = await prisma.opportunity.findMany({
|
||||||
|
where: opts?.includeClosed ? undefined : { closedFlag: false },
|
||||||
|
skip,
|
||||||
|
take: rpp,
|
||||||
|
orderBy: { expectedCloseDate: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return items.map((item) => new OpportunityController(item));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search Opportunities
|
||||||
|
*
|
||||||
|
* Search opportunities by name, company name, contact name, notes,
|
||||||
|
* sales rep, or status with pagination support.
|
||||||
|
*
|
||||||
|
* @param query - Search query string
|
||||||
|
* @param page - Page number (1-based)
|
||||||
|
* @param rpp - Records per page
|
||||||
|
* @param opts - Optional filters
|
||||||
|
* @returns {Promise<OpportunityController[]>}
|
||||||
|
*/
|
||||||
|
async search(
|
||||||
|
query: string,
|
||||||
|
page: number,
|
||||||
|
rpp: number,
|
||||||
|
opts?: { includeClosed?: boolean },
|
||||||
|
): Promise<OpportunityController[]> {
|
||||||
|
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||||
|
|
||||||
|
const items = await prisma.opportunity.findMany({
|
||||||
|
where: {
|
||||||
|
...(opts?.includeClosed ? {} : { closedFlag: false }),
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: query, mode: "insensitive" } },
|
||||||
|
{ companyName: { contains: query, mode: "insensitive" } },
|
||||||
|
{ contactName: { contains: query, mode: "insensitive" } },
|
||||||
|
{ notes: { contains: query, mode: "insensitive" } },
|
||||||
|
{ primarySalesRepName: { contains: query, mode: "insensitive" } },
|
||||||
|
{ statusName: { contains: query, mode: "insensitive" } },
|
||||||
|
{ stageName: { contains: query, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take: rpp,
|
||||||
|
orderBy: { expectedCloseDate: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return items.map((item) => new OpportunityController(item));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count Opportunities
|
||||||
|
*
|
||||||
|
* @param opts - Optional filters
|
||||||
|
* @returns {Promise<number>}
|
||||||
|
*/
|
||||||
|
async count(opts?: { openOnly?: boolean }): Promise<number> {
|
||||||
|
return prisma.opportunity.count({
|
||||||
|
where: opts?.openOnly ? { closedFlag: false } : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Opportunities by Company
|
||||||
|
*
|
||||||
|
* Fetch all opportunities for a company by its internal company ID.
|
||||||
|
*
|
||||||
|
* @param companyId - The internal company ID
|
||||||
|
* @param opts - Optional filters
|
||||||
|
* @returns {Promise<OpportunityController[]>}
|
||||||
|
*/
|
||||||
|
async fetchByCompany(
|
||||||
|
companyId: string,
|
||||||
|
opts?: { includeClosed?: boolean },
|
||||||
|
): Promise<OpportunityController[]> {
|
||||||
|
const items = await prisma.opportunity.findMany({
|
||||||
|
where: {
|
||||||
|
companyId,
|
||||||
|
...(opts?.includeClosed ? {} : { closedFlag: false }),
|
||||||
|
},
|
||||||
|
orderBy: { expectedCloseDate: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return items.map((item) => new OpportunityController(item));
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { prisma } from "../constants";
|
||||||
|
import { CatalogItemController } from "../controllers/CatalogItemController";
|
||||||
|
import GenericError from "../Errors/GenericError";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard include clause used by catalog item queries.
|
||||||
|
* Includes one level of linked items.
|
||||||
|
*/
|
||||||
|
const catalogItemInclude = {
|
||||||
|
linkedItems: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const procurement = {
|
||||||
|
/**
|
||||||
|
* Fetch Catalog Item
|
||||||
|
*
|
||||||
|
* Fetch a catalog item by its internal ID or ConnectWise catalog ID
|
||||||
|
* and return a CatalogItemController instance.
|
||||||
|
*
|
||||||
|
* @param identifier - The internal ID (string) or CW catalog ID (number)
|
||||||
|
* @returns {Promise<CatalogItemController>} - The catalog item controller
|
||||||
|
*/
|
||||||
|
async fetchItem(identifier: string | number): Promise<CatalogItemController> {
|
||||||
|
const isNumeric =
|
||||||
|
typeof identifier === "number" || /^\d+$/.test(String(identifier));
|
||||||
|
|
||||||
|
const item = await prisma.catalogItem.findFirst({
|
||||||
|
where: isNumeric
|
||||||
|
? { cwCatalogId: Number(identifier) }
|
||||||
|
: {
|
||||||
|
OR: [
|
||||||
|
{ id: identifier as string },
|
||||||
|
{ identifier: identifier as string },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: catalogItemInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new GenericError({
|
||||||
|
message: "Catalog item not found",
|
||||||
|
name: "CatalogItemNotFound",
|
||||||
|
cause: `No catalog item exists with identifier '${identifier}'`,
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CatalogItemController(item);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch All Catalog Items (Paginated)
|
||||||
|
*
|
||||||
|
* Fetch pages of catalog items for pagination.
|
||||||
|
*
|
||||||
|
* @param page - Page number (1-based)
|
||||||
|
* @param rpp - Records per page
|
||||||
|
* @returns {Promise<CatalogItemController[]>} - Array of catalog item controllers
|
||||||
|
*/
|
||||||
|
async fetchPages(
|
||||||
|
page: number,
|
||||||
|
rpp: number,
|
||||||
|
opts?: { includeInactive?: boolean },
|
||||||
|
): Promise<CatalogItemController[]> {
|
||||||
|
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||||
|
const take = rpp;
|
||||||
|
|
||||||
|
const items = await prisma.catalogItem.findMany({
|
||||||
|
where: opts?.includeInactive ? undefined : { inactive: false },
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
include: catalogItemInclude,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return items.map((item) => new CatalogItemController(item));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search Catalog Items
|
||||||
|
*
|
||||||
|
* Search catalog items by name, description, part number, or vendor SKU
|
||||||
|
* with pagination support.
|
||||||
|
*
|
||||||
|
* @param query - Search query string
|
||||||
|
* @param page - Page number (1-based)
|
||||||
|
* @param rpp - Records per page
|
||||||
|
* @returns {Promise<CatalogItemController[]>} - Array of matching catalog item controllers
|
||||||
|
*/
|
||||||
|
async search(
|
||||||
|
query: string,
|
||||||
|
page: number,
|
||||||
|
rpp: number,
|
||||||
|
opts?: { includeInactive?: boolean },
|
||||||
|
): Promise<CatalogItemController[]> {
|
||||||
|
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||||
|
const take = rpp;
|
||||||
|
|
||||||
|
const items = await prisma.catalogItem.findMany({
|
||||||
|
where: {
|
||||||
|
...(opts?.includeInactive ? {} : { inactive: false }),
|
||||||
|
OR: [
|
||||||
|
{ identifier: { contains: query, mode: "insensitive" } },
|
||||||
|
{ name: { contains: query, mode: "insensitive" } },
|
||||||
|
{ description: { contains: query, mode: "insensitive" } },
|
||||||
|
{ partNumber: { contains: query, mode: "insensitive" } },
|
||||||
|
{ vendorSku: { contains: query, mode: "insensitive" } },
|
||||||
|
{ manufacturer: { contains: query, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
include: catalogItemInclude,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return items.map((item) => new CatalogItemController(item));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count Catalog Items
|
||||||
|
*
|
||||||
|
* Returns the total number of catalog items in the database.
|
||||||
|
*
|
||||||
|
* @param opts - Optional filters
|
||||||
|
* @returns {Promise<number>} - Total count
|
||||||
|
*/
|
||||||
|
async count(opts?: { activeOnly?: boolean }): Promise<number> {
|
||||||
|
return prisma.catalogItem.count({
|
||||||
|
where: opts?.activeOnly ? { inactive: false } : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link Catalog Items
|
||||||
|
*
|
||||||
|
* Links a target catalog item to a source catalog item.
|
||||||
|
*
|
||||||
|
* @param sourceIdentifier - The source item's internal ID, identifier, or CW catalog ID
|
||||||
|
* @param targetIdentifier - The target item's internal ID, identifier, or CW catalog ID
|
||||||
|
* @returns {Promise<CatalogItemController>} - The updated source controller with linked items
|
||||||
|
*/
|
||||||
|
async linkItems(
|
||||||
|
sourceIdentifier: string | number,
|
||||||
|
targetIdentifier: string | number,
|
||||||
|
): Promise<CatalogItemController> {
|
||||||
|
const source = await procurement.fetchItem(sourceIdentifier);
|
||||||
|
const target = await procurement.fetchItem(targetIdentifier);
|
||||||
|
|
||||||
|
return source.linkItem(target.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink Catalog Items
|
||||||
|
*
|
||||||
|
* Removes the link between a source catalog item and a target catalog item.
|
||||||
|
*
|
||||||
|
* @param sourceIdentifier - The source item's internal ID, identifier, or CW catalog ID
|
||||||
|
* @param targetIdentifier - The target item's internal ID, identifier, or CW catalog ID
|
||||||
|
* @returns {Promise<CatalogItemController>} - The updated source controller
|
||||||
|
*/
|
||||||
|
async unlinkItems(
|
||||||
|
sourceIdentifier: string | number,
|
||||||
|
targetIdentifier: string | number,
|
||||||
|
): Promise<CatalogItemController> {
|
||||||
|
const source = await procurement.fetchItem(sourceIdentifier);
|
||||||
|
const target = await procurement.fetchItem(targetIdentifier);
|
||||||
|
|
||||||
|
return source.unlinkItem(target.id);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Collection } from "@discordjs/collection";
|
||||||
|
import GenericError from "../../../Errors/GenericError";
|
||||||
|
import { opportunityCw } from "./opportunities";
|
||||||
|
import { CWOpportunity } from "./opportunity.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all opportunities from ConnectWise with optional conditions.
|
||||||
|
*
|
||||||
|
* @param conditions - Optional CW conditions string for filtering
|
||||||
|
* @returns A Collection of CW opportunities keyed by their ID
|
||||||
|
* @throws GenericError if the fetch fails
|
||||||
|
*/
|
||||||
|
export const fetchAllOpportunities = async (
|
||||||
|
conditions?: string,
|
||||||
|
): Promise<Collection<number, CWOpportunity>> => {
|
||||||
|
try {
|
||||||
|
return await opportunityCw.fetchAll(conditions);
|
||||||
|
} catch (error) {
|
||||||
|
const errBody = (error as any).response?.data || error;
|
||||||
|
console.error("Error fetching all opportunities:", errBody);
|
||||||
|
throw new GenericError({
|
||||||
|
name: "FetchAllOpportunitiesError",
|
||||||
|
message: "Failed to fetch opportunities from ConnectWise",
|
||||||
|
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||||
|
status: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Collection } from "@discordjs/collection";
|
||||||
|
import GenericError from "../../../Errors/GenericError";
|
||||||
|
import { opportunityCw } from "./opportunities";
|
||||||
|
import { CWOpportunity } from "./opportunity.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all opportunities for a specific company from ConnectWise.
|
||||||
|
*
|
||||||
|
* @param cwCompanyId - The ConnectWise company ID
|
||||||
|
* @returns A Collection of CW opportunities for the company keyed by their ID
|
||||||
|
* @throws GenericError if the fetch fails
|
||||||
|
*/
|
||||||
|
export const fetchCompanyOpportunities = async (
|
||||||
|
cwCompanyId: number,
|
||||||
|
): Promise<Collection<number, CWOpportunity>> => {
|
||||||
|
try {
|
||||||
|
return await opportunityCw.fetchByCompany(cwCompanyId);
|
||||||
|
} catch (error) {
|
||||||
|
const errBody = (error as any).response?.data || error;
|
||||||
|
console.error(
|
||||||
|
`Error fetching opportunities for company ${cwCompanyId}:`,
|
||||||
|
errBody,
|
||||||
|
);
|
||||||
|
throw new GenericError({
|
||||||
|
name: "FetchCompanyOpportunitiesError",
|
||||||
|
message: `Failed to fetch opportunities for company ${cwCompanyId}`,
|
||||||
|
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||||
|
status: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import GenericError from "../../../Errors/GenericError";
|
||||||
|
import { opportunityCw } from "./opportunities";
|
||||||
|
import { CWOpportunity } from "./opportunity.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single opportunity by its ConnectWise ID.
|
||||||
|
*
|
||||||
|
* @param cwOpportunityId - The ConnectWise opportunity ID
|
||||||
|
* @returns The full CW opportunity object
|
||||||
|
* @throws GenericError if the fetch fails
|
||||||
|
*/
|
||||||
|
export const fetchOpportunity = async (
|
||||||
|
cwOpportunityId: number,
|
||||||
|
): Promise<CWOpportunity> => {
|
||||||
|
try {
|
||||||
|
return await opportunityCw.fetch(cwOpportunityId);
|
||||||
|
} catch (error) {
|
||||||
|
const errBody = (error as any).response?.data || error;
|
||||||
|
console.error(
|
||||||
|
`Error fetching opportunity with ID ${cwOpportunityId}:`,
|
||||||
|
errBody,
|
||||||
|
);
|
||||||
|
throw new GenericError({
|
||||||
|
name: "FetchOpportunityError",
|
||||||
|
message: `Failed to fetch opportunity ${cwOpportunityId}`,
|
||||||
|
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||||
|
status: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { Collection } from "@discordjs/collection";
|
||||||
|
import { connectWiseApi } from "../../../constants";
|
||||||
|
import {
|
||||||
|
CWOpportunity,
|
||||||
|
CWOpportunitySummary,
|
||||||
|
CWForecastItem,
|
||||||
|
CWOpportunityNote,
|
||||||
|
CWOpportunityContact,
|
||||||
|
} from "./opportunity.types";
|
||||||
|
|
||||||
|
export const opportunityCw = {
|
||||||
|
/**
|
||||||
|
* Count Opportunities
|
||||||
|
*
|
||||||
|
* Returns the total number of opportunities in ConnectWise.
|
||||||
|
* Optionally accepts CW conditions string for filtered counts.
|
||||||
|
*/
|
||||||
|
countItems: async (conditions?: string): Promise<number> => {
|
||||||
|
const query = conditions
|
||||||
|
? `/sales/opportunities/count?conditions=${encodeURIComponent(conditions)}`
|
||||||
|
: "/sales/opportunities/count";
|
||||||
|
const response = await connectWiseApi.get(query);
|
||||||
|
return response.data.count;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch All Opportunity Summaries
|
||||||
|
*
|
||||||
|
* Lightweight fetch returning only id and _info (for lastUpdated comparison).
|
||||||
|
* Paginates through all opportunities.
|
||||||
|
*/
|
||||||
|
fetchAllSummaries: async (): Promise<
|
||||||
|
Collection<number, CWOpportunitySummary>
|
||||||
|
> => {
|
||||||
|
const allItems = new Collection<number, CWOpportunitySummary>();
|
||||||
|
const pageSize = 1000;
|
||||||
|
|
||||||
|
const count = await opportunityCw.countItems();
|
||||||
|
const totalPages = Math.ceil(count / pageSize);
|
||||||
|
|
||||||
|
for (let page = 0; page < totalPages; page++) {
|
||||||
|
const response = await connectWiseApi.get(
|
||||||
|
`/sales/opportunities?page=${page + 1}&pageSize=${pageSize}&fields=id,_info`,
|
||||||
|
);
|
||||||
|
const items: CWOpportunitySummary[] = response.data;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
allItems.set(item.id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allItems;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch All Opportunities (Full)
|
||||||
|
*
|
||||||
|
* Fetches all opportunities with complete data. Paginates through
|
||||||
|
* the full list.
|
||||||
|
*/
|
||||||
|
fetchAll: async (
|
||||||
|
conditions?: string,
|
||||||
|
): Promise<Collection<number, CWOpportunity>> => {
|
||||||
|
const allItems = new Collection<number, CWOpportunity>();
|
||||||
|
const pageSize = 1000;
|
||||||
|
|
||||||
|
const count = await opportunityCw.countItems(conditions);
|
||||||
|
const totalPages = Math.ceil(count / pageSize);
|
||||||
|
|
||||||
|
for (let page = 0; page < totalPages; page++) {
|
||||||
|
const conditionsParam = conditions
|
||||||
|
? `&conditions=${encodeURIComponent(conditions)}`
|
||||||
|
: "";
|
||||||
|
const response = await connectWiseApi.get(
|
||||||
|
`/sales/opportunities?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
|
||||||
|
);
|
||||||
|
const items: CWOpportunity[] = response.data;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
allItems.set(item.id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allItems;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Single Opportunity
|
||||||
|
*
|
||||||
|
* Fetches a single opportunity by its ConnectWise ID.
|
||||||
|
*/
|
||||||
|
fetch: async (id: number): Promise<CWOpportunity> => {
|
||||||
|
const response = await connectWiseApi.get(`/sales/opportunities/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Opportunities by Company
|
||||||
|
*
|
||||||
|
* Fetches all opportunities associated with a specific ConnectWise company ID.
|
||||||
|
*/
|
||||||
|
fetchByCompany: async (
|
||||||
|
cwCompanyId: number,
|
||||||
|
): Promise<Collection<number, CWOpportunity>> => {
|
||||||
|
return opportunityCw.fetchAll(`company/id=${cwCompanyId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Opportunity Forecasts
|
||||||
|
*
|
||||||
|
* Fetches forecast/revenue items for a given opportunity.
|
||||||
|
*/
|
||||||
|
fetchForecasts: async (opportunityId: number): Promise<CWForecastItem[]> => {
|
||||||
|
const response = await connectWiseApi.get(
|
||||||
|
`/sales/opportunities/${opportunityId}/forecast`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Opportunity Notes
|
||||||
|
*
|
||||||
|
* Fetches notes associated with a given opportunity.
|
||||||
|
*/
|
||||||
|
fetchNotes: async (opportunityId: number): Promise<CWOpportunityNote[]> => {
|
||||||
|
const response = await connectWiseApi.get(
|
||||||
|
`/sales/opportunities/${opportunityId}/notes`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Opportunity Contacts
|
||||||
|
*
|
||||||
|
* Fetches contacts associated with a given opportunity.
|
||||||
|
*/
|
||||||
|
fetchContacts: async (
|
||||||
|
opportunityId: number,
|
||||||
|
): Promise<CWOpportunityContact[]> => {
|
||||||
|
const response = await connectWiseApi.get(
|
||||||
|
`/sales/opportunities/${opportunityId}/contacts`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
interface CWReference {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
_info?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CWMemberReference {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
name: string;
|
||||||
|
_info?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CWCompanyReference {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
name: string;
|
||||||
|
_info?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CWContactReference {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
_info?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CWSiteReference {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
_info?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CWCustomField {
|
||||||
|
id: number;
|
||||||
|
caption: string;
|
||||||
|
type: string;
|
||||||
|
entryMethod: string;
|
||||||
|
numberOfDecimals: number;
|
||||||
|
value: unknown;
|
||||||
|
connectWiseId: string;
|
||||||
|
rowNum: number;
|
||||||
|
userDefinedFieldRecId: number;
|
||||||
|
podId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CWOpportunity {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
expectedCloseDate: string;
|
||||||
|
type: CWReference;
|
||||||
|
stage: CWReference;
|
||||||
|
status: CWReference;
|
||||||
|
priority: CWReference;
|
||||||
|
notes: string;
|
||||||
|
source: string;
|
||||||
|
rating: CWReference;
|
||||||
|
campaign: CWReference;
|
||||||
|
primarySalesRep: CWMemberReference;
|
||||||
|
secondarySalesRep: CWMemberReference;
|
||||||
|
locationId: number;
|
||||||
|
businessUnitId: number;
|
||||||
|
company: CWCompanyReference;
|
||||||
|
contact: CWContactReference;
|
||||||
|
site: CWSiteReference;
|
||||||
|
customerPO: string;
|
||||||
|
pipelineChangeDate: string;
|
||||||
|
dateBecameLead: string;
|
||||||
|
closedDate: string;
|
||||||
|
closedBy: CWMemberReference;
|
||||||
|
totalSalesTax: number;
|
||||||
|
shipToCompany: CWCompanyReference;
|
||||||
|
shipToContact: CWContactReference;
|
||||||
|
shipToSite: CWSiteReference;
|
||||||
|
billToCompany: CWCompanyReference;
|
||||||
|
billToContact: CWContactReference;
|
||||||
|
billToSite: CWSiteReference;
|
||||||
|
billingTerms: CWReference;
|
||||||
|
taxCode: CWReference;
|
||||||
|
currency: CWReference;
|
||||||
|
companyLocationId: number;
|
||||||
|
location: CWReference;
|
||||||
|
department: CWReference;
|
||||||
|
closedFlag: boolean;
|
||||||
|
mobileGuid: string;
|
||||||
|
customFields: CWCustomField[];
|
||||||
|
_info: CWOpportunityInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CWOpportunityInfo {
|
||||||
|
lastUpdated: string;
|
||||||
|
updatedBy: string;
|
||||||
|
dateEntered: string;
|
||||||
|
enteredBy: string;
|
||||||
|
forecasts_href: string;
|
||||||
|
notes_href: string;
|
||||||
|
products_href: string;
|
||||||
|
contacts_href: string;
|
||||||
|
configurations_href: string;
|
||||||
|
team_href: string;
|
||||||
|
documents_href: string;
|
||||||
|
activities_href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CWForecastItem {
|
||||||
|
id: number;
|
||||||
|
opportunity: CWReference;
|
||||||
|
forecastType: string;
|
||||||
|
forecastMonth: string;
|
||||||
|
revenue: number;
|
||||||
|
cost: number;
|
||||||
|
forecastPercentage: number;
|
||||||
|
status: CWReference;
|
||||||
|
includedFlag: boolean;
|
||||||
|
linkedFlag: boolean;
|
||||||
|
recurringFlag: boolean;
|
||||||
|
_info?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CWOpportunityNote {
|
||||||
|
id: number;
|
||||||
|
opportunity: CWReference;
|
||||||
|
text: string;
|
||||||
|
type: CWReference;
|
||||||
|
flagged: boolean;
|
||||||
|
enteredBy: string;
|
||||||
|
mobileGuid: string;
|
||||||
|
_info?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CWOpportunityContact {
|
||||||
|
id: number;
|
||||||
|
opportunity: CWReference;
|
||||||
|
contact: CWContactReference;
|
||||||
|
company: CWCompanyReference;
|
||||||
|
role: CWReference;
|
||||||
|
notes: string;
|
||||||
|
referralFlag: boolean;
|
||||||
|
_info?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CWOpportunitySummary {
|
||||||
|
id: number;
|
||||||
|
_info?: Record<string, string>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { CWOpportunity } from "./opportunity.types";
|
||||||
|
|
||||||
|
export type ProcessedOpportunity = ReturnType<
|
||||||
|
typeof processOpportunityResponse
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes raw CW opportunity data into a cleaner, normalized shape
|
||||||
|
* suitable for API responses and internal consumption.
|
||||||
|
*/
|
||||||
|
export const processOpportunityResponse = (opportunity: CWOpportunity) => ({
|
||||||
|
id: opportunity.id,
|
||||||
|
name: opportunity.name,
|
||||||
|
expectedCloseDate: opportunity.expectedCloseDate,
|
||||||
|
closedDate: opportunity.closedDate,
|
||||||
|
closedFlag: opportunity.closedFlag,
|
||||||
|
type: opportunity.type
|
||||||
|
? { id: opportunity.type.id, name: opportunity.type.name }
|
||||||
|
: null,
|
||||||
|
stage: opportunity.stage
|
||||||
|
? { id: opportunity.stage.id, name: opportunity.stage.name }
|
||||||
|
: null,
|
||||||
|
status: opportunity.status
|
||||||
|
? { id: opportunity.status.id, name: opportunity.status.name }
|
||||||
|
: null,
|
||||||
|
priority: opportunity.priority
|
||||||
|
? { id: opportunity.priority.id, name: opportunity.priority.name }
|
||||||
|
: null,
|
||||||
|
rating: opportunity.rating
|
||||||
|
? { id: opportunity.rating.id, name: opportunity.rating.name }
|
||||||
|
: null,
|
||||||
|
source: opportunity.source,
|
||||||
|
notes: opportunity.notes,
|
||||||
|
customerPO: opportunity.customerPO,
|
||||||
|
company: opportunity.company
|
||||||
|
? {
|
||||||
|
id: opportunity.company.id,
|
||||||
|
identifier: opportunity.company.identifier,
|
||||||
|
name: opportunity.company.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
contact: opportunity.contact
|
||||||
|
? { id: opportunity.contact.id, name: opportunity.contact.name }
|
||||||
|
: null,
|
||||||
|
site: opportunity.site
|
||||||
|
? { id: opportunity.site.id, name: opportunity.site.name }
|
||||||
|
: null,
|
||||||
|
primarySalesRep: opportunity.primarySalesRep
|
||||||
|
? {
|
||||||
|
id: opportunity.primarySalesRep.id,
|
||||||
|
identifier: opportunity.primarySalesRep.identifier,
|
||||||
|
name: opportunity.primarySalesRep.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
secondarySalesRep: opportunity.secondarySalesRep
|
||||||
|
? {
|
||||||
|
id: opportunity.secondarySalesRep.id,
|
||||||
|
identifier: opportunity.secondarySalesRep.identifier,
|
||||||
|
name: opportunity.secondarySalesRep.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
closedBy: opportunity.closedBy
|
||||||
|
? {
|
||||||
|
id: opportunity.closedBy.id,
|
||||||
|
identifier: opportunity.closedBy.identifier,
|
||||||
|
name: opportunity.closedBy.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
campaign: opportunity.campaign
|
||||||
|
? { id: opportunity.campaign.id, name: opportunity.campaign.name }
|
||||||
|
: null,
|
||||||
|
totalSalesTax: opportunity.totalSalesTax,
|
||||||
|
location: opportunity.location
|
||||||
|
? { id: opportunity.location.id, name: opportunity.location.name }
|
||||||
|
: null,
|
||||||
|
department: opportunity.department
|
||||||
|
? { id: opportunity.department.id, name: opportunity.department.name }
|
||||||
|
: null,
|
||||||
|
pipelineChangeDate: opportunity.pipelineChangeDate,
|
||||||
|
dateBecameLead: opportunity.dateBecameLead,
|
||||||
|
info: opportunity._info,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes an array of raw CW opportunities.
|
||||||
|
*/
|
||||||
|
export const processOpportunitiesResponse = (opportunities: CWOpportunity[]) =>
|
||||||
|
opportunities.map(processOpportunityResponse);
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { prisma } from "../../../constants";
|
||||||
|
import { events } from "../../globalEvents";
|
||||||
|
import { opportunityCw } from "./opportunities";
|
||||||
|
import { OpportunityController } from "../../../controllers/OpportunityController";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh Opportunities
|
||||||
|
*
|
||||||
|
* Syncs local opportunity records with ConnectWise using the same
|
||||||
|
* stale-check pattern as refreshCatalog:
|
||||||
|
* 1. Fetch lightweight summaries (id + _info.lastUpdated)
|
||||||
|
* 2. Compare against local cwLastUpdated timestamps
|
||||||
|
* 3. Full-fetch only stale/new records
|
||||||
|
* 4. Upsert stale items, optionally linking to internal Company
|
||||||
|
*/
|
||||||
|
export const refreshOpportunities = async () => {
|
||||||
|
events.emit("cw:opportunities:refresh:check");
|
||||||
|
|
||||||
|
// 1. Fetch lightweight summaries from CW
|
||||||
|
const cwSummaries = await opportunityCw.fetchAllSummaries();
|
||||||
|
|
||||||
|
// 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated
|
||||||
|
const dbItems = await prisma.opportunity.findMany({
|
||||||
|
select: { cwOpportunityId: true, cwLastUpdated: true },
|
||||||
|
});
|
||||||
|
const dbMap = new Map(
|
||||||
|
dbItems.map((item) => [item.cwOpportunityId, item.cwLastUpdated]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Determine stale / new IDs
|
||||||
|
const staleIds: number[] = [];
|
||||||
|
|
||||||
|
for (const [cwId, summary] of cwSummaries) {
|
||||||
|
const cwLastUpdated = summary._info?.lastUpdated
|
||||||
|
? new Date(summary._info.lastUpdated)
|
||||||
|
: null;
|
||||||
|
const dbLastUpdated = dbMap.get(cwId) ?? null;
|
||||||
|
|
||||||
|
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
|
||||||
|
staleIds.push(cwId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staleIds.length === 0) {
|
||||||
|
events.emit("cw:opportunities:refresh:skipped", {
|
||||||
|
totalCw: cwSummaries.size,
|
||||||
|
totalDb: dbItems.length,
|
||||||
|
staleCount: 0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.emit("cw:opportunities:refresh:started", {
|
||||||
|
totalCw: cwSummaries.size,
|
||||||
|
totalDb: dbItems.length,
|
||||||
|
staleCount: staleIds.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Full-fetch all opportunities, filter to stale set
|
||||||
|
const staleIdSet = new Set(staleIds);
|
||||||
|
const allCwItems = await opportunityCw.fetchAll();
|
||||||
|
const staleItems = new Map<number, any>();
|
||||||
|
|
||||||
|
for (const [id, item] of allCwItems) {
|
||||||
|
if (staleIdSet.has(id)) {
|
||||||
|
staleItems.set(id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build a company CW ID → internal ID lookup for linking
|
||||||
|
const companies = await prisma.company.findMany({
|
||||||
|
select: { id: true, cw_CompanyId: true },
|
||||||
|
});
|
||||||
|
const companyMap = new Map(companies.map((c) => [c.cw_CompanyId, c.id]));
|
||||||
|
|
||||||
|
// 6. Upsert stale/new items
|
||||||
|
const updatedCount = (
|
||||||
|
await Promise.all(
|
||||||
|
staleIds.map(async (cwId) => {
|
||||||
|
const item = staleItems.get(cwId);
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
const mapped = OpportunityController.mapCwToDb(item);
|
||||||
|
const companyId = item.company?.id
|
||||||
|
? (companyMap.get(item.company.id) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return prisma.opportunity.upsert({
|
||||||
|
where: { cwOpportunityId: cwId },
|
||||||
|
create: {
|
||||||
|
cwOpportunityId: cwId,
|
||||||
|
...mapped,
|
||||||
|
companyId,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...mapped,
|
||||||
|
companyId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter(Boolean).length;
|
||||||
|
|
||||||
|
events.emit("cw:opportunities:refresh:completed", {
|
||||||
|
totalCw: cwSummaries.size,
|
||||||
|
totalDb: dbItems.length,
|
||||||
|
staleCount: staleIds.length,
|
||||||
|
itemsUpdated: updatedCount,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -91,6 +91,7 @@ export const refreshCatalog = async () => {
|
|||||||
where: { cwCatalogId: cwId },
|
where: { cwCatalogId: cwId },
|
||||||
create: {
|
create: {
|
||||||
cwCatalogId: cwId,
|
cwCatalogId: cwId,
|
||||||
|
identifier: item.identifier,
|
||||||
name: item.description,
|
name: item.description,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
customerDescription: item.customerDescription,
|
customerDescription: item.customerDescription,
|
||||||
@@ -110,6 +111,7 @@ export const refreshCatalog = async () => {
|
|||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
name: item.description,
|
name: item.description,
|
||||||
|
identifier: item.identifier,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
customerDescription: item.customerDescription,
|
customerDescription: item.customerDescription,
|
||||||
internalNotes: item.notes,
|
internalNotes: item.notes,
|
||||||
|
|||||||
@@ -158,6 +158,25 @@ interface EventTypes {
|
|||||||
totalItems: number;
|
totalItems: number;
|
||||||
updatedCount: number;
|
updatedCount: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
|
// ConnectWise Opportunities Events
|
||||||
|
"cw:opportunities:refresh:check": () => void;
|
||||||
|
"cw:opportunities:refresh:started": (data: {
|
||||||
|
totalCw: number;
|
||||||
|
totalDb: number;
|
||||||
|
staleCount: number;
|
||||||
|
}) => void;
|
||||||
|
"cw:opportunities:refresh:completed": (data: {
|
||||||
|
totalCw: number;
|
||||||
|
totalDb: number;
|
||||||
|
staleCount: number;
|
||||||
|
itemsUpdated: number;
|
||||||
|
}) => void;
|
||||||
|
"cw:opportunities:refresh:skipped": (data: {
|
||||||
|
totalCw: number;
|
||||||
|
totalDb: number;
|
||||||
|
staleCount: number;
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const events = new Eventra<EventTypes>();
|
export const events = new Eventra<EventTypes>();
|
||||||
|
|||||||
@@ -341,6 +341,72 @@ export const PERMISSION_NODES = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
procurement: {
|
||||||
|
name: "Procurement Permissions",
|
||||||
|
description:
|
||||||
|
"Permissions for accessing and managing procurement catalog items",
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
node: "procurement.catalog.fetch",
|
||||||
|
description: "Fetch a single catalog item",
|
||||||
|
usedIn: ["src/api/procurement/[id]/fetch.ts"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "procurement.catalog.fetch.many",
|
||||||
|
description: "Fetch multiple catalog items or count",
|
||||||
|
usedIn: [
|
||||||
|
"src/api/procurement/fetchAll.ts",
|
||||||
|
"src/api/procurement/count.ts",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "procurement.catalog.inventory.refresh",
|
||||||
|
description:
|
||||||
|
"Refresh on-hand inventory for a catalog item from ConnectWise",
|
||||||
|
usedIn: ["src/api/procurement/[id]/refreshInventory.ts"],
|
||||||
|
dependencies: ["procurement.catalog.fetch"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "procurement.catalog.link",
|
||||||
|
description: "Link or unlink catalog items to each other",
|
||||||
|
usedIn: [
|
||||||
|
"src/api/procurement/[id]/link.ts",
|
||||||
|
"src/api/procurement/[id]/unlink.ts",
|
||||||
|
],
|
||||||
|
dependencies: ["procurement.catalog.fetch"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
sales: {
|
||||||
|
name: "Sales Permissions",
|
||||||
|
description: "Permissions for accessing and managing sales opportunities",
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.fetch",
|
||||||
|
description:
|
||||||
|
"Fetch a single opportunity and its sub-resources (forecasts, notes, contacts)",
|
||||||
|
usedIn: [
|
||||||
|
"src/api/sales/[id]/fetch.ts",
|
||||||
|
"src/api/sales/[id]/forecasts.ts",
|
||||||
|
"src/api/sales/[id]/notes.ts",
|
||||||
|
"src/api/sales/[id]/contacts.ts",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.fetch.many",
|
||||||
|
description: "Fetch multiple opportunities or count",
|
||||||
|
usedIn: ["src/api/sales/fetchAll.ts", "src/api/sales/count.ts"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.refresh",
|
||||||
|
description: "Refresh a single opportunity from ConnectWise",
|
||||||
|
usedIn: ["src/api/sales/[id]/refresh.ts"],
|
||||||
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
unifi: {
|
unifi: {
|
||||||
name: "UniFi Permissions",
|
name: "UniFi Permissions",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { apiResponse } from "../../src/modules/api-utils/apiResponse";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
import GenericError from "../../src/Errors/GenericError";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the error-handling middleware registered in server.ts.
|
||||||
|
* We replicate the onError logic on a fresh Hono instance to test
|
||||||
|
* in isolation without importing all routes.
|
||||||
|
*/
|
||||||
|
function createAppWithErrorHandling() {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.onError((err, ctx) => {
|
||||||
|
const errClassName = err.constructor.name;
|
||||||
|
|
||||||
|
if (
|
||||||
|
errClassName.toLowerCase().includes("prisma") ||
|
||||||
|
err.message.toLowerCase().includes("prisma") ||
|
||||||
|
err.name.toLowerCase().includes("prisma")
|
||||||
|
) {
|
||||||
|
return ctx.json(apiResponse.internalError(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof ZodError || err.name === "ZodError") {
|
||||||
|
const zodResp = apiResponse.zodError(err as ZodError);
|
||||||
|
return ctx.json(zodResp, zodResp.status as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = apiResponse.error(err);
|
||||||
|
return ctx.json(response, response.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Server error handling", () => {
|
||||||
|
test("Prisma errors return 500 InternalServerError", async () => {
|
||||||
|
const app = createAppWithErrorHandling();
|
||||||
|
app.get("/test", () => {
|
||||||
|
const err = new Error("prisma query failed");
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.error).toBe("InternalServerError");
|
||||||
|
expect(body.successful).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Prisma errors detected by class name", async () => {
|
||||||
|
const app = createAppWithErrorHandling();
|
||||||
|
app.get("/test", () => {
|
||||||
|
class PrismaClientError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("something");
|
||||||
|
this.name = "PrismaClientError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new PrismaClientError();
|
||||||
|
});
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ZodError returns 400 with error array", async () => {
|
||||||
|
const app = createAppWithErrorHandling();
|
||||||
|
app.get("/test", (c) => {
|
||||||
|
// In Zod v4, we need to use z.parse to generate a proper ZodError
|
||||||
|
const { z } = require("zod");
|
||||||
|
const schema = z.object({ name: z.string() });
|
||||||
|
schema.parse({}); // throws ZodError
|
||||||
|
return c.text("unreachable");
|
||||||
|
});
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.message).toBe("TypeError");
|
||||||
|
expect(body.successful).toBe(false);
|
||||||
|
expect(Array.isArray(body.error)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GenericError returns custom status", async () => {
|
||||||
|
const app = createAppWithErrorHandling();
|
||||||
|
app.get("/test", () => {
|
||||||
|
throw new GenericError({
|
||||||
|
name: "NotFound",
|
||||||
|
message: "Resource not found",
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.error).toBe("NotFound");
|
||||||
|
expect(body.message).toBe("Resource not found");
|
||||||
|
expect(body.successful).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain Error defaults to 400", async () => {
|
||||||
|
const app = createAppWithErrorHandling();
|
||||||
|
app.get("/test", () => {
|
||||||
|
throw new Error("Unexpected error");
|
||||||
|
});
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.successful).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import app from "../../src/api/server";
|
||||||
|
|
||||||
|
describe("API Server — Integration", () => {
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Teapot route (no auth required)
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("GET /v1/teapot", () => {
|
||||||
|
test("returns 418 I'm not a teapot", async () => {
|
||||||
|
const res = await app.request("/v1/teapot");
|
||||||
|
expect(res.status).toBe(418);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.status).toBe(418);
|
||||||
|
expect(body.message).toBe("I'm not a teapot");
|
||||||
|
expect(body.successful).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns JSON content type", async () => {
|
||||||
|
const res = await app.request("/v1/teapot");
|
||||||
|
expect(res.headers.get("content-type")).toContain("application/json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Not Found
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("Not Found handling", () => {
|
||||||
|
test("returns 404 for unknown routes", async () => {
|
||||||
|
const res = await app.request("/v1/nonexistent");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.successful).toBe(false);
|
||||||
|
expect(body.error).toBe("NotFound");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes method and path in message", async () => {
|
||||||
|
const res = await app.request("/v1/some/random/path", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.message).toContain("POST");
|
||||||
|
expect(body.message).toContain("/v1/some/random/path");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 404 for root path", async () => {
|
||||||
|
const res = await app.request("/");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// CORS
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("CORS", () => {
|
||||||
|
test("includes CORS headers", async () => {
|
||||||
|
const res = await app.request("/v1/teapot", {
|
||||||
|
headers: { Origin: "http://localhost:3000" },
|
||||||
|
});
|
||||||
|
// Hono's cors middleware should add access-control headers
|
||||||
|
const acaoHeader = res.headers.get("access-control-allow-origin");
|
||||||
|
expect(acaoHeader).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles OPTIONS preflight", async () => {
|
||||||
|
const res = await app.request("/v1/teapot", {
|
||||||
|
method: "OPTIONS",
|
||||||
|
headers: {
|
||||||
|
Origin: "http://localhost:3000",
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Should not be 404
|
||||||
|
expect(res.status).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Auth-protected routes (should reject without auth)
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("Protected routes require authorization", () => {
|
||||||
|
const protectedRoutes = [
|
||||||
|
{ method: "GET", path: "/v1/company/companies" },
|
||||||
|
{ method: "GET", path: "/v1/company/count" },
|
||||||
|
{ method: "GET", path: "/v1/credential/credentials/some-id" },
|
||||||
|
{ method: "POST", path: "/v1/credential/credentials" },
|
||||||
|
{ method: "GET", path: "/v1/role" },
|
||||||
|
{ method: "POST", path: "/v1/role" },
|
||||||
|
{ method: "GET", path: "/v1/user/users" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(protectedRoutes)(
|
||||||
|
"$method $path returns 401 without auth header",
|
||||||
|
async ({ method, path }) => {
|
||||||
|
const res = await app.request(path, { method });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.successful).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(protectedRoutes)(
|
||||||
|
"$method $path returns error with invalid auth header",
|
||||||
|
async ({ method, path }) => {
|
||||||
|
const res = await app.request(path, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: "invalid-format" },
|
||||||
|
});
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.successful).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Error handling
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("Error handling", () => {
|
||||||
|
test("ZodError returns 400 with error details", async () => {
|
||||||
|
// POST to credentials without proper body should trigger a Zod error
|
||||||
|
const res = await app.request("/v1/credential/credentials", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: "Bearer invalid.token.here",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Will get auth error first, which is expected
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.successful).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* Global test setup — mock heavy external dependencies so unit tests
|
||||||
|
* never touch real databases, APIs, or file-system keys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mock } from "bun:test";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generate a real RSA key pair for modules that call crypto.createPrivateKey()
|
||||||
|
// at import time (e.g. readSecureValue.ts).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const { privateKey: _testPrivateKey, publicKey: _testPublicKey } =
|
||||||
|
crypto.generateKeyPairSync("rsa", {
|
||||||
|
modulusLength: 2048,
|
||||||
|
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||||
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock the constants module — almost every source file imports from here.
|
||||||
|
// We provide safe defaults so modules can be imported without side-effects.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
mock.module("../src/constants", () => ({
|
||||||
|
prisma: createMockPrisma(),
|
||||||
|
PORT: "3333",
|
||||||
|
API_BASE_URL: "http://localhost:3333",
|
||||||
|
sessionDuration: 30 * 24 * 60 * 60_000,
|
||||||
|
accessTokenDuration: "10min",
|
||||||
|
refreshTokenDuration: "30d",
|
||||||
|
accessTokenPrivateKey: _testPrivateKey,
|
||||||
|
refreshTokenPrivateKey: _testPrivateKey,
|
||||||
|
permissionsPrivateKey: _testPrivateKey,
|
||||||
|
secureValuesPrivateKey: _testPrivateKey,
|
||||||
|
secureValuesPublicKey: _testPublicKey,
|
||||||
|
msalClient: { acquireTokenByCode: mock(() => Promise.resolve({})) },
|
||||||
|
connectWiseApi: {
|
||||||
|
get: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
post: mock(() => Promise.resolve({ data: {} })),
|
||||||
|
},
|
||||||
|
unifi: createMockUnifi(),
|
||||||
|
unifiControllerBaseUrl: "https://unifi.test.local",
|
||||||
|
unifiSite: "default",
|
||||||
|
unifiUsername: "admin",
|
||||||
|
unifiPassword: "test-pass",
|
||||||
|
io: { of: mock(() => ({ on: mock() })) },
|
||||||
|
engine: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function createMockPrisma() {
|
||||||
|
const createModelProxy = () =>
|
||||||
|
new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, prop) {
|
||||||
|
return mock(() => Promise.resolve(null));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_target, prop) {
|
||||||
|
if (prop === "$connect" || prop === "$disconnect")
|
||||||
|
return mock(() => Promise.resolve());
|
||||||
|
return createModelProxy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockUnifi() {
|
||||||
|
return {
|
||||||
|
login: mock(() => Promise.resolve()),
|
||||||
|
getAllSites: mock(() => Promise.resolve([])),
|
||||||
|
getSiteOverview: mock(() => Promise.resolve({})),
|
||||||
|
getDevices: mock(() => Promise.resolve([])),
|
||||||
|
getWlanConf: mock(() => Promise.resolve([])),
|
||||||
|
updateWlanConf: mock(() => Promise.resolve({})),
|
||||||
|
getNetworks: mock(() => Promise.resolve([])),
|
||||||
|
createSite: mock(() =>
|
||||||
|
Promise.resolve({ name: "default", description: "Default" }),
|
||||||
|
),
|
||||||
|
getWlanGroups: mock(() => Promise.resolve([])),
|
||||||
|
createWlanGroup: mock(() => Promise.resolve({})),
|
||||||
|
getUserGroups: mock(() => Promise.resolve([])),
|
||||||
|
createUserGroup: mock(() => Promise.resolve({})),
|
||||||
|
getApGroups: mock(() => Promise.resolve([])),
|
||||||
|
createApGroup: mock(() => Promise.resolve({})),
|
||||||
|
updateApGroup: mock(() => Promise.resolve({})),
|
||||||
|
getAccessPoints: mock(() => Promise.resolve([])),
|
||||||
|
getWifiLimits: mock(() => Promise.resolve({})),
|
||||||
|
getPrivatePSKs: mock(() => Promise.resolve([])),
|
||||||
|
createPrivatePSK: mock(() => Promise.resolve({})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a minimal Prisma-shaped User row for controller tests. */
|
||||||
|
export function buildMockUser(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: "user-1",
|
||||||
|
userId: "ms-uid-1",
|
||||||
|
name: "Test User",
|
||||||
|
login: "test@example.com",
|
||||||
|
email: "test@example.com",
|
||||||
|
emailVerified: null,
|
||||||
|
image: null,
|
||||||
|
token: "ms-token",
|
||||||
|
permissions: null,
|
||||||
|
createdAt: new Date("2025-01-01"),
|
||||||
|
updatedAt: new Date("2025-01-01"),
|
||||||
|
roles: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a minimal Prisma-shaped Role row. */
|
||||||
|
export function buildMockRole(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: "role-1",
|
||||||
|
title: "Test Role",
|
||||||
|
moniker: "test-role",
|
||||||
|
permissions: "mock-permissions-token",
|
||||||
|
createdAt: new Date("2025-01-01"),
|
||||||
|
updatedAt: new Date("2025-01-01"),
|
||||||
|
users: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a minimal Prisma-shaped Company row. */
|
||||||
|
export function buildMockCompany(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: "company-1",
|
||||||
|
name: "Test Company",
|
||||||
|
cw_Identifier: "TestCo",
|
||||||
|
cw_CompanyId: 123,
|
||||||
|
createdAt: new Date("2025-01-01"),
|
||||||
|
updatedAt: new Date("2025-01-01"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a minimal Session row. */
|
||||||
|
export function buildMockSession(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: "session-1",
|
||||||
|
sessionKey: "sk-abc123",
|
||||||
|
userId: "user-1",
|
||||||
|
expires: new Date(Date.now() + 30 * 24 * 60 * 60_000),
|
||||||
|
refreshedAt: null,
|
||||||
|
invalidatedAt: null,
|
||||||
|
refreshTokenGenerated: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a minimal CredentialType row. */
|
||||||
|
export function buildMockCredentialType(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: "ctype-1",
|
||||||
|
name: "Login Credential",
|
||||||
|
permissionScope: "credential.login",
|
||||||
|
icon: null,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "username",
|
||||||
|
name: "Username",
|
||||||
|
required: true,
|
||||||
|
secure: false,
|
||||||
|
valueType: "plain_text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "password",
|
||||||
|
name: "Password",
|
||||||
|
required: true,
|
||||||
|
secure: true,
|
||||||
|
valueType: "password",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
credentials: [],
|
||||||
|
createdAt: new Date("2025-01-01"),
|
||||||
|
updatedAt: new Date("2025-01-01"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a minimal Credential row. */
|
||||||
|
export function buildMockCredential(overrides: Record<string, any> = {}) {
|
||||||
|
const ctype = buildMockCredentialType();
|
||||||
|
const company = buildMockCompany();
|
||||||
|
return {
|
||||||
|
id: "cred-1",
|
||||||
|
name: "Test Credential",
|
||||||
|
notes: null,
|
||||||
|
typeId: ctype.id,
|
||||||
|
companyId: company.id,
|
||||||
|
subCredentialOfId: null,
|
||||||
|
fields: { username: "admin" },
|
||||||
|
type: ctype,
|
||||||
|
company,
|
||||||
|
securevalues: [
|
||||||
|
{
|
||||||
|
id: "sv-1",
|
||||||
|
name: "password",
|
||||||
|
content: "encrypted-data",
|
||||||
|
hash: "BLAKE2s$abc$salt",
|
||||||
|
credentialId: "cred-1",
|
||||||
|
createdAt: new Date("2025-01-01"),
|
||||||
|
updatedAt: new Date("2025-01-01"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subCredentials: [],
|
||||||
|
createdAt: new Date("2025-01-01"),
|
||||||
|
updatedAt: new Date("2025-01-01"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a minimal UnifiSite row. */
|
||||||
|
export function buildMockUnifiSite(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: "usite-1",
|
||||||
|
name: "Main Office",
|
||||||
|
siteId: "default",
|
||||||
|
companyId: null,
|
||||||
|
createdAt: new Date("2025-01-01"),
|
||||||
|
updatedAt: new Date("2025-01-01"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, test, expect, beforeEach } from "bun:test";
|
||||||
|
import { apiResponse } from "../../src/modules/api-utils/apiResponse";
|
||||||
|
import { z } from "zod";
|
||||||
|
import GenericError from "../../src/Errors/GenericError";
|
||||||
|
|
||||||
|
describe("apiResponse", () => {
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// successful
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("successful()", () => {
|
||||||
|
test("returns status 200 and successful: true", () => {
|
||||||
|
const res = apiResponse.successful("OK");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.successful).toBe(true);
|
||||||
|
expect(res.message).toBe("OK");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes data when provided", () => {
|
||||||
|
const data = { id: 1, name: "Test" };
|
||||||
|
const res = apiResponse.successful("OK", data);
|
||||||
|
expect(res.data).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("data is undefined when not provided", () => {
|
||||||
|
const res = apiResponse.successful("OK");
|
||||||
|
expect(res.data).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes meta.timestamp", () => {
|
||||||
|
const before = Date.now();
|
||||||
|
const res = apiResponse.successful("OK");
|
||||||
|
const after = Date.now();
|
||||||
|
expect(res.meta.timestamp).toBeGreaterThanOrEqual(before);
|
||||||
|
expect(res.meta.timestamp).toBeLessThanOrEqual(after);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts optional meta parameter (overridden by timestamp)", () => {
|
||||||
|
const res = apiResponse.successful("OK", null, { custom: true } as any);
|
||||||
|
// The implementation replaces meta entirely with { timestamp }
|
||||||
|
expect(res.meta.timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// created
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("created()", () => {
|
||||||
|
test("returns status 201 and successful: true", () => {
|
||||||
|
const res = apiResponse.created("Created");
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.successful).toBe(true);
|
||||||
|
expect(res.message).toBe("Created");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes data when provided", () => {
|
||||||
|
const res = apiResponse.created("Created", { id: "abc" });
|
||||||
|
expect(res.data).toEqual({ id: "abc" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("data is undefined when not provided", () => {
|
||||||
|
const res = apiResponse.created("Created");
|
||||||
|
expect(res.data).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes meta.timestamp", () => {
|
||||||
|
const res = apiResponse.created("Created");
|
||||||
|
expect(res.meta.timestamp).toBeDefined();
|
||||||
|
expect(typeof res.meta.timestamp).toBe("number");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// error
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("error()", () => {
|
||||||
|
test("reads status from error object", () => {
|
||||||
|
const err = new GenericError({
|
||||||
|
name: "Oops",
|
||||||
|
message: "bad",
|
||||||
|
status: 422,
|
||||||
|
});
|
||||||
|
const res = apiResponse.error(err);
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
expect(res.message).toBe("bad");
|
||||||
|
expect(res.error).toBe("Oops");
|
||||||
|
expect(res.successful).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults status to 400 when error has no status", () => {
|
||||||
|
const err = new Error("plain error");
|
||||||
|
const res = apiResponse.error(err);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes meta.timestamp", () => {
|
||||||
|
const err = new Error("x");
|
||||||
|
const res = apiResponse.error(err);
|
||||||
|
expect(res.meta.timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// internalError
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("internalError()", () => {
|
||||||
|
test("returns status 500", () => {
|
||||||
|
const res = apiResponse.internalError();
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.successful).toBe(false);
|
||||||
|
expect(res.error).toBe("InternalServerError");
|
||||||
|
expect(res.message).toContain("Internal Server Error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes meta.timestamp", () => {
|
||||||
|
const res = apiResponse.internalError();
|
||||||
|
expect(res.meta.timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// zodError
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("zodError()", () => {
|
||||||
|
test("returns status 400 with parsed error data", () => {
|
||||||
|
const schema = z.object({ name: z.string() });
|
||||||
|
let zodErr: z.ZodError;
|
||||||
|
try {
|
||||||
|
schema.parse({ name: 123 });
|
||||||
|
throw new Error("should not reach");
|
||||||
|
} catch (e) {
|
||||||
|
zodErr = e as z.ZodError;
|
||||||
|
}
|
||||||
|
const res = apiResponse.zodError(zodErr!);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.successful).toBe(false);
|
||||||
|
expect(res.message).toBe("TypeError");
|
||||||
|
expect(Array.isArray(res.error)).toBe(true);
|
||||||
|
expect(res.error[0].code).toBe("invalid_type");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes meta.timestamp", () => {
|
||||||
|
const schema = z.object({ x: z.string() });
|
||||||
|
let zodErr: z.ZodError;
|
||||||
|
try {
|
||||||
|
schema.parse({});
|
||||||
|
throw new Error("should not reach");
|
||||||
|
} catch (e) {
|
||||||
|
zodErr = e as z.ZodError;
|
||||||
|
}
|
||||||
|
const res = apiResponse.zodError(zodErr!);
|
||||||
|
expect(res.meta.timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { CompanyController } from "../../../src/controllers/CompanyController";
|
||||||
|
import { buildMockCompany } from "../../setup";
|
||||||
|
|
||||||
|
const mockCwData = {
|
||||||
|
company: {
|
||||||
|
addressLine1: "123 Main St",
|
||||||
|
addressLine2: null,
|
||||||
|
city: "Springfield",
|
||||||
|
state: "IL",
|
||||||
|
zip: "62701",
|
||||||
|
country: { name: "United States" },
|
||||||
|
_info: { contacts_href: "" },
|
||||||
|
} as any,
|
||||||
|
defaultContact: {
|
||||||
|
id: 100,
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
inactiveFlag: false,
|
||||||
|
title: "CEO",
|
||||||
|
defaultPhoneNbr: "555-1234",
|
||||||
|
communicationItems: [
|
||||||
|
{ type: { name: "Email" }, value: "john@test.com" },
|
||||||
|
{ type: { name: "Phone" }, value: "555-1234" },
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
allContacts: [] as any[],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CompanyController", () => {
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Constructor
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("constructor", () => {
|
||||||
|
test("sets public properties from company data", () => {
|
||||||
|
const data = buildMockCompany();
|
||||||
|
const ctrl = new CompanyController(data);
|
||||||
|
expect(ctrl.id).toBe("company-1");
|
||||||
|
expect(ctrl.name).toBe("Test Company");
|
||||||
|
expect(ctrl.cw_Identifier).toBe("TestCo");
|
||||||
|
expect(ctrl.cw_CompanyId).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts optional CW data", () => {
|
||||||
|
const data = buildMockCompany();
|
||||||
|
const ctrl = new CompanyController(data, mockCwData);
|
||||||
|
expect(ctrl.cw_Data).toBeDefined();
|
||||||
|
expect(ctrl.cw_Data?.company.city).toBe("Springfield");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cw_Data is undefined when not provided", () => {
|
||||||
|
const data = buildMockCompany();
|
||||||
|
const ctrl = new CompanyController(data);
|
||||||
|
expect(ctrl.cw_Data).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// toJson
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("toJson()", () => {
|
||||||
|
test("returns base fields without options", () => {
|
||||||
|
const ctrl = new CompanyController(buildMockCompany(), mockCwData);
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json.id).toBe("company-1");
|
||||||
|
expect(json.name).toBe("Test Company");
|
||||||
|
expect(json.cw_Identifier).toBe("TestCo");
|
||||||
|
expect(json.cw_CompanyId).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("excludes address when includeAddress is false", () => {
|
||||||
|
const ctrl = new CompanyController(buildMockCompany(), mockCwData);
|
||||||
|
const json = ctrl.toJson({
|
||||||
|
includeAddress: false,
|
||||||
|
includePrimaryContact: false,
|
||||||
|
});
|
||||||
|
expect(json.cw_Data.address).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes address when includeAddress is true", () => {
|
||||||
|
const ctrl = new CompanyController(buildMockCompany(), mockCwData);
|
||||||
|
const json = ctrl.toJson({
|
||||||
|
includeAddress: true,
|
||||||
|
includePrimaryContact: false,
|
||||||
|
});
|
||||||
|
expect(json.cw_Data.address).toBeDefined();
|
||||||
|
expect(json.cw_Data.address!.city).toBe("Springfield");
|
||||||
|
expect(json.cw_Data.address!.state).toBe("IL");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes primary contact when includePrimaryContact is true", () => {
|
||||||
|
const ctrl = new CompanyController(buildMockCompany(), mockCwData);
|
||||||
|
const json = ctrl.toJson({
|
||||||
|
includeAddress: false,
|
||||||
|
includePrimaryContact: true,
|
||||||
|
});
|
||||||
|
expect(json.cw_Data.primaryContact).toBeDefined();
|
||||||
|
expect(json.cw_Data.primaryContact!.firstName).toBe("John");
|
||||||
|
expect(json.cw_Data.primaryContact!.lastName).toBe("Doe");
|
||||||
|
expect(json.cw_Data.primaryContact!.email).toBe("john@test.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("excludes primary contact when includePrimaryContact is false", () => {
|
||||||
|
const ctrl = new CompanyController(buildMockCompany(), mockCwData);
|
||||||
|
const json = ctrl.toJson({
|
||||||
|
includeAddress: false,
|
||||||
|
includePrimaryContact: false,
|
||||||
|
});
|
||||||
|
expect(json.cw_Data.primaryContact).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes allContacts when includeAllContacts is true", () => {
|
||||||
|
const cwDataWithContacts = {
|
||||||
|
...mockCwData,
|
||||||
|
allContacts: [
|
||||||
|
{
|
||||||
|
id: 200,
|
||||||
|
firstName: "Jane",
|
||||||
|
lastName: "Smith",
|
||||||
|
inactiveFlag: false,
|
||||||
|
title: "CTO",
|
||||||
|
defaultPhoneNbr: "555-5678",
|
||||||
|
communicationItems: [
|
||||||
|
{ type: { name: "Email" }, value: "jane@test.com" },
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const ctrl = new CompanyController(
|
||||||
|
buildMockCompany(),
|
||||||
|
cwDataWithContacts,
|
||||||
|
);
|
||||||
|
const json = ctrl.toJson({
|
||||||
|
includeAddress: false,
|
||||||
|
includePrimaryContact: false,
|
||||||
|
includeAllContacts: true,
|
||||||
|
});
|
||||||
|
expect(json.cw_Data.allContacts).toBeDefined();
|
||||||
|
expect(json.cw_Data.allContacts).toHaveLength(1);
|
||||||
|
expect(json.cw_Data.allContacts![0]!.firstName).toBe("Jane");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("email is null when no Email communication item", () => {
|
||||||
|
const noEmailCw = {
|
||||||
|
...mockCwData,
|
||||||
|
defaultContact: {
|
||||||
|
...mockCwData.defaultContact,
|
||||||
|
communicationItems: [{ type: { name: "Phone" }, value: "555" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctrl = new CompanyController(buildMockCompany(), noEmailCw);
|
||||||
|
const json = ctrl.toJson({
|
||||||
|
includeAddress: false,
|
||||||
|
includePrimaryContact: true,
|
||||||
|
});
|
||||||
|
expect(json.cw_Data.primaryContact!.email).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("email is null when communicationItems is missing", () => {
|
||||||
|
const noCIData = {
|
||||||
|
...mockCwData,
|
||||||
|
defaultContact: {
|
||||||
|
...mockCwData.defaultContact,
|
||||||
|
communicationItems: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctrl = new CompanyController(buildMockCompany(), noCIData);
|
||||||
|
const json = ctrl.toJson({
|
||||||
|
includeAddress: false,
|
||||||
|
includePrimaryContact: true,
|
||||||
|
});
|
||||||
|
expect(json.cw_Data.primaryContact!.email).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("country defaults to United States when null", () => {
|
||||||
|
const noCntry = {
|
||||||
|
...mockCwData,
|
||||||
|
company: { ...mockCwData.company, country: null },
|
||||||
|
};
|
||||||
|
const ctrl = new CompanyController(buildMockCompany(), noCntry);
|
||||||
|
const json = ctrl.toJson({
|
||||||
|
includeAddress: true,
|
||||||
|
includePrimaryContact: false,
|
||||||
|
});
|
||||||
|
expect(json.cw_Data.address!.country).toBe("United States");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { CredentialController } from "../../../src/controllers/CredentialController";
|
||||||
|
import { buildMockCredential } from "../../setup";
|
||||||
|
import { ValueType } from "../../../src/modules/credentials/credentialTypeDefs";
|
||||||
|
|
||||||
|
describe("CredentialController", () => {
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Constructor & _buildFields
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("constructor", () => {
|
||||||
|
test("sets public properties from credential data", () => {
|
||||||
|
const data = buildMockCredential();
|
||||||
|
const ctrl = new CredentialController(data);
|
||||||
|
expect(ctrl.id).toBe("cred-1");
|
||||||
|
expect(ctrl.name).toBe("Test Credential");
|
||||||
|
expect(ctrl.notes).toBeNull();
|
||||||
|
expect(ctrl.typeId).toBe("ctype-1");
|
||||||
|
expect(ctrl.companyId).toBe("company-1");
|
||||||
|
expect(ctrl.subCredentialOfId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds fields from type definition", () => {
|
||||||
|
const data = buildMockCredential();
|
||||||
|
const ctrl = new CredentialController(data);
|
||||||
|
expect(Array.isArray(ctrl.fields)).toBe(true);
|
||||||
|
expect(ctrl.fields).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain fields have value from raw data", () => {
|
||||||
|
const data = buildMockCredential();
|
||||||
|
const ctrl = new CredentialController(data);
|
||||||
|
const usernameField = ctrl.fields.find((f: any) => f.id === "username");
|
||||||
|
expect(usernameField).toBeDefined();
|
||||||
|
expect(usernameField.value).toBe("admin");
|
||||||
|
expect(usernameField.secure).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("secure fields reference secure value ID", () => {
|
||||||
|
const data = buildMockCredential();
|
||||||
|
const ctrl = new CredentialController(data);
|
||||||
|
const passwordField = ctrl.fields.find((f: any) => f.id === "password");
|
||||||
|
expect(passwordField).toBeDefined();
|
||||||
|
expect(passwordField.secure).toBe(true);
|
||||||
|
expect(passwordField.value).toBe("secure-sv-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles sub-credentials in constructor", () => {
|
||||||
|
const subCred = buildMockCredential({
|
||||||
|
id: "sub-cred-1",
|
||||||
|
name: "Sub Cred",
|
||||||
|
subCredentialOfId: "cred-1",
|
||||||
|
type: {
|
||||||
|
id: "ctype-1",
|
||||||
|
name: "Login Credential",
|
||||||
|
permissionScope: "credential.login",
|
||||||
|
icon: null,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "username",
|
||||||
|
name: "Username",
|
||||||
|
required: true,
|
||||||
|
secure: false,
|
||||||
|
valueType: "plain_text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
securevalues: [],
|
||||||
|
subCredentials: [],
|
||||||
|
});
|
||||||
|
const parent = buildMockCredential({
|
||||||
|
subCredentials: [subCred],
|
||||||
|
});
|
||||||
|
const ctrl = new CredentialController(parent);
|
||||||
|
// The parent should have the sub-credential processed
|
||||||
|
expect(ctrl.id).toBe("cred-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// getType / getCompany
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("getType() / getCompany()", () => {
|
||||||
|
test("getType returns the credential type", () => {
|
||||||
|
const ctrl = new CredentialController(buildMockCredential());
|
||||||
|
const type = ctrl.getType();
|
||||||
|
expect(type.id).toBe("ctype-1");
|
||||||
|
expect(type.name).toBe("Login Credential");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getCompany returns the company", () => {
|
||||||
|
const ctrl = new CredentialController(buildMockCredential());
|
||||||
|
const company = ctrl.getCompany();
|
||||||
|
expect(company.id).toBe("company-1");
|
||||||
|
expect(company.name).toBe("Test Company");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// toJson
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("toJson()", () => {
|
||||||
|
test("returns structured JSON without secure field IDs by default", () => {
|
||||||
|
const ctrl = new CredentialController(buildMockCredential());
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json.id).toBe("cred-1");
|
||||||
|
expect(json.name).toBe("Test Credential");
|
||||||
|
expect(json.typeId).toBe("ctype-1");
|
||||||
|
expect(json.companyId).toBe("company-1");
|
||||||
|
expect(json.type.id).toBe("ctype-1");
|
||||||
|
expect(json.company.id).toBe("company-1");
|
||||||
|
expect(json.secureFieldIds).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes secure field IDs when includeSecureValues is true", () => {
|
||||||
|
const ctrl = new CredentialController(buildMockCredential());
|
||||||
|
const json = ctrl.toJson({ includeSecureValues: true });
|
||||||
|
expect(json.secureFieldIds).toBeDefined();
|
||||||
|
expect(json.secureFieldIds).toContain("password");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes subCredentialOfId when present", () => {
|
||||||
|
const data = buildMockCredential({ subCredentialOfId: "parent-1" });
|
||||||
|
const ctrl = new CredentialController(data);
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json.subCredentialOfId).toBe("parent-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("excludes subCredentialOfId when null", () => {
|
||||||
|
const ctrl = new CredentialController(buildMockCredential());
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json.subCredentialOfId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes timestamp fields", () => {
|
||||||
|
const ctrl = new CredentialController(buildMockCredential());
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json.createdAt).toBeDefined();
|
||||||
|
expect(json.updatedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subCredentials is undefined when empty", () => {
|
||||||
|
const ctrl = new CredentialController(buildMockCredential());
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json.subCredentials).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Sub-credential field building
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("sub-credential field building", () => {
|
||||||
|
test("builds fields differently for sub-credentials", () => {
|
||||||
|
const subData = buildMockCredential({
|
||||||
|
id: "sub-1",
|
||||||
|
subCredentialOfId: "parent-1",
|
||||||
|
fields: { sub_user: "jdoe" },
|
||||||
|
type: {
|
||||||
|
id: "ctype-1",
|
||||||
|
name: "Login",
|
||||||
|
permissionScope: "credential.login",
|
||||||
|
icon: null,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "sub_user",
|
||||||
|
name: "Sub User",
|
||||||
|
required: true,
|
||||||
|
secure: false,
|
||||||
|
valueType: ValueType.PLAIN_TEXT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
securevalues: [
|
||||||
|
{
|
||||||
|
id: "sv-2",
|
||||||
|
name: "sub_pass",
|
||||||
|
content: "enc",
|
||||||
|
hash: "hash",
|
||||||
|
credentialId: "sub-1",
|
||||||
|
createdAt: new Date("2025-01-01"),
|
||||||
|
updatedAt: new Date("2025-01-01"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const ctrl = new CredentialController(subData);
|
||||||
|
// Sub-credential fields are built as array with id/value/secure
|
||||||
|
expect(Array.isArray(ctrl.fields)).toBe(true);
|
||||||
|
const plainField = ctrl.fields.find((f: any) => f.id === "sub_user");
|
||||||
|
expect(plainField).toBeDefined();
|
||||||
|
expect(plainField.secure).toBe(false);
|
||||||
|
const secureField = ctrl.fields.find((f: any) => f.id === "sub_pass");
|
||||||
|
expect(secureField).toBeDefined();
|
||||||
|
expect(secureField.secure).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { CredentialTypeController } from "../../../src/controllers/CredentialTypeController";
|
||||||
|
import { buildMockCredentialType } from "../../setup";
|
||||||
|
import { ValueType } from "../../../src/modules/credentials/credentialTypeDefs";
|
||||||
|
|
||||||
|
describe("CredentialTypeController", () => {
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Constructor
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("constructor", () => {
|
||||||
|
test("sets public properties", () => {
|
||||||
|
const ctrl = new CredentialTypeController(buildMockCredentialType());
|
||||||
|
expect(ctrl.id).toBe("ctype-1");
|
||||||
|
expect(ctrl.name).toBe("Login Credential");
|
||||||
|
expect(ctrl.permissionScope).toBe("credential.login");
|
||||||
|
expect(ctrl.icon).toBeNull();
|
||||||
|
expect(ctrl.fields).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses timestamps", () => {
|
||||||
|
const ctrl = new CredentialTypeController(buildMockCredentialType());
|
||||||
|
expect(ctrl.createdAt).toBeInstanceOf(Date);
|
||||||
|
expect(ctrl.updatedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// getFieldDefinition
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("getFieldDefinition()", () => {
|
||||||
|
test("returns matching field", () => {
|
||||||
|
const ctrl = new CredentialTypeController(buildMockCredentialType());
|
||||||
|
const field = ctrl.getFieldDefinition("username");
|
||||||
|
expect(field).toBeDefined();
|
||||||
|
expect(field!.name).toBe("Username");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns undefined for unknown field", () => {
|
||||||
|
const ctrl = new CredentialTypeController(buildMockCredentialType());
|
||||||
|
expect(ctrl.getFieldDefinition("nonexistent")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// getRequiredFields
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("getRequiredFields()", () => {
|
||||||
|
test("returns only required fields", () => {
|
||||||
|
const data = buildMockCredentialType({
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
name: "A",
|
||||||
|
required: true,
|
||||||
|
secure: false,
|
||||||
|
valueType: ValueType.PLAIN_TEXT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b",
|
||||||
|
name: "B",
|
||||||
|
required: false,
|
||||||
|
secure: false,
|
||||||
|
valueType: ValueType.PLAIN_TEXT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c",
|
||||||
|
name: "C",
|
||||||
|
required: true,
|
||||||
|
secure: true,
|
||||||
|
valueType: ValueType.PASSWORD,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const ctrl = new CredentialTypeController(data);
|
||||||
|
const required = ctrl.getRequiredFields();
|
||||||
|
expect(required).toHaveLength(2);
|
||||||
|
expect(required.map((f) => f.id)).toEqual(["a", "c"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// getSecureFields
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("getSecureFields()", () => {
|
||||||
|
test("returns only secure fields", () => {
|
||||||
|
const ctrl = new CredentialTypeController(buildMockCredentialType());
|
||||||
|
const secure = ctrl.getSecureFields();
|
||||||
|
expect(secure).toHaveLength(1);
|
||||||
|
expect(secure[0]!.id).toBe("password");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// countCredentials
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("countCredentials()", () => {
|
||||||
|
test("returns 0 when no credentials", () => {
|
||||||
|
const ctrl = new CredentialTypeController(buildMockCredentialType());
|
||||||
|
expect(ctrl.countCredentials()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns correct count", () => {
|
||||||
|
const data = buildMockCredentialType({
|
||||||
|
credentials: [{ id: "c1" }, { id: "c2" }, { id: "c3" }],
|
||||||
|
});
|
||||||
|
const ctrl = new CredentialTypeController(data);
|
||||||
|
expect(ctrl.countCredentials()).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// toJson
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("toJson()", () => {
|
||||||
|
test("returns base JSON without credential count by default", () => {
|
||||||
|
const ctrl = new CredentialTypeController(buildMockCredentialType());
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json.id).toBe("ctype-1");
|
||||||
|
expect(json.name).toBe("Login Credential");
|
||||||
|
expect(json.credentialCount).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes credential count when option is set", () => {
|
||||||
|
const data = buildMockCredentialType({
|
||||||
|
credentials: [{ id: "c1" }, { id: "c2" }],
|
||||||
|
});
|
||||||
|
const ctrl = new CredentialTypeController(data);
|
||||||
|
const json = ctrl.toJson({ includeCredentialCount: true });
|
||||||
|
expect(json.credentialCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes all expected keys", () => {
|
||||||
|
const ctrl = new CredentialTypeController(buildMockCredentialType());
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json).toHaveProperty("id");
|
||||||
|
expect(json).toHaveProperty("name");
|
||||||
|
expect(json).toHaveProperty("permissionScope");
|
||||||
|
expect(json).toHaveProperty("icon");
|
||||||
|
expect(json).toHaveProperty("fields");
|
||||||
|
expect(json).toHaveProperty("createdAt");
|
||||||
|
expect(json).toHaveProperty("updatedAt");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { RoleController } from "../../../src/controllers/RoleController";
|
||||||
|
import { buildMockRole, buildMockUser } from "../../setup";
|
||||||
|
|
||||||
|
describe("RoleController", () => {
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Constructor
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("constructor", () => {
|
||||||
|
test("sets public properties from role data", () => {
|
||||||
|
const data = buildMockRole();
|
||||||
|
const ctrl = new RoleController(data);
|
||||||
|
expect(ctrl.id).toBe("role-1");
|
||||||
|
expect(ctrl.title).toBe("Test Role");
|
||||||
|
expect(ctrl.moniker).toBe("test-role");
|
||||||
|
expect(ctrl.deleted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets timestamps", () => {
|
||||||
|
const ctrl = new RoleController(buildMockRole());
|
||||||
|
expect(ctrl.createdAt).toBeInstanceOf(Date);
|
||||||
|
expect(ctrl.updatedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// getUsers
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("getUsers()", () => {
|
||||||
|
test("returns empty collection when no users", () => {
|
||||||
|
const ctrl = new RoleController(buildMockRole({ users: [] }));
|
||||||
|
const users = ctrl.getUsers();
|
||||||
|
expect(users.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns collection of UserController instances", () => {
|
||||||
|
const userData = buildMockUser({ id: "u-1" });
|
||||||
|
const ctrl = new RoleController(buildMockRole({ users: [userData] }));
|
||||||
|
const users = ctrl.getUsers();
|
||||||
|
expect(users.size).toBe(1);
|
||||||
|
expect(users.get("u-1")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// toJson
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("toJson()", () => {
|
||||||
|
test("returns base JSON without permissions or users by default", () => {
|
||||||
|
const ctrl = new RoleController(buildMockRole());
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json.id).toBe("role-1");
|
||||||
|
expect(json.title).toBe("Test Role");
|
||||||
|
expect(json.moniker).toBe("test-role");
|
||||||
|
expect(json.permissions).toBeUndefined();
|
||||||
|
expect(json.users).toBeUndefined();
|
||||||
|
expect(json.createdAt).toBeDefined();
|
||||||
|
expect(json.updatedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes users when viewUsers is true", () => {
|
||||||
|
const userData = buildMockUser({
|
||||||
|
id: "u-1",
|
||||||
|
roles: [{ id: "role-1", moniker: "test-role" }],
|
||||||
|
});
|
||||||
|
const ctrl = new RoleController(buildMockRole({ users: [userData] }));
|
||||||
|
const json = ctrl.toJson({ viewUsers: true });
|
||||||
|
expect(json.users).toBeDefined();
|
||||||
|
expect(json.users).toHaveLength(1);
|
||||||
|
expect(json.users![0]!.id).toBe("u-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { SessionController } from "../../../src/controllers/SessionController";
|
||||||
|
import { buildMockSession } from "../../setup";
|
||||||
|
|
||||||
|
describe("SessionController", () => {
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Constructor
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("constructor", () => {
|
||||||
|
test("sets all public properties from session data", () => {
|
||||||
|
const data = buildMockSession();
|
||||||
|
const ctrl = new SessionController(data);
|
||||||
|
expect(ctrl.id).toBe("session-1");
|
||||||
|
expect(ctrl.sessionKey).toBe("sk-abc123");
|
||||||
|
expect(ctrl.userId).toBe("user-1");
|
||||||
|
expect(ctrl.expires).toBeInstanceOf(Date);
|
||||||
|
expect(ctrl.refreshedAt).toBeNull();
|
||||||
|
expect(ctrl.invalidatedAt).toBeNull();
|
||||||
|
expect(ctrl.terminated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets custom values from overrides", () => {
|
||||||
|
const refreshDate = new Date("2025-06-01");
|
||||||
|
const ctrl = new SessionController(
|
||||||
|
buildMockSession({ refreshedAt: refreshDate }),
|
||||||
|
);
|
||||||
|
expect(ctrl.refreshedAt).toEqual(refreshDate);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// invalidate
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("invalidate()", () => {
|
||||||
|
test("throws when session is already invalidated", async () => {
|
||||||
|
const ctrl = new SessionController(
|
||||||
|
buildMockSession({ invalidatedAt: new Date() }),
|
||||||
|
);
|
||||||
|
await expect(ctrl.invalidate()).rejects.toThrow(
|
||||||
|
"Session has already been invalidated",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// generateTokens
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("generateTokens()", () => {
|
||||||
|
test("throws when tokens have already been generated", async () => {
|
||||||
|
const ctrl = new SessionController(
|
||||||
|
buildMockSession({ refreshTokenGenerated: true }),
|
||||||
|
);
|
||||||
|
await expect(ctrl.generateTokens()).rejects.toThrow(
|
||||||
|
"Tokens have alredy been generated",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { UnifiSiteController } from "../../../src/controllers/UnifiSiteController";
|
||||||
|
import { buildMockUnifiSite } from "../../setup";
|
||||||
|
|
||||||
|
describe("UnifiSiteController", () => {
|
||||||
|
describe("constructor", () => {
|
||||||
|
test("sets all properties from site data", () => {
|
||||||
|
const ctrl = new UnifiSiteController(buildMockUnifiSite());
|
||||||
|
expect(ctrl.id).toBe("usite-1");
|
||||||
|
expect(ctrl.name).toBe("Main Office");
|
||||||
|
expect(ctrl.siteId).toBe("default");
|
||||||
|
expect(ctrl.companyId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts non-null companyId", () => {
|
||||||
|
const ctrl = new UnifiSiteController(
|
||||||
|
buildMockUnifiSite({ companyId: "company-1" }),
|
||||||
|
);
|
||||||
|
expect(ctrl.companyId).toBe("company-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toJson()", () => {
|
||||||
|
test("returns all properties", () => {
|
||||||
|
const ctrl = new UnifiSiteController(
|
||||||
|
buildMockUnifiSite({ companyId: "comp-abc" }),
|
||||||
|
);
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json).toEqual({
|
||||||
|
id: "usite-1",
|
||||||
|
name: "Main Office",
|
||||||
|
siteId: "default",
|
||||||
|
companyId: "comp-abc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("companyId is null when unlinked", () => {
|
||||||
|
const ctrl = new UnifiSiteController(buildMockUnifiSite());
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json.companyId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import UserController from "../../../src/controllers/UserController";
|
||||||
|
import { buildMockUser } from "../../setup";
|
||||||
|
|
||||||
|
describe("UserController", () => {
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Constructor
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("constructor", () => {
|
||||||
|
test("sets all public properties", () => {
|
||||||
|
const ctrl = new UserController(buildMockUser());
|
||||||
|
expect(ctrl.id).toBe("user-1");
|
||||||
|
expect(ctrl.name).toBe("Test User");
|
||||||
|
expect(ctrl.login).toBe("test@example.com");
|
||||||
|
expect(ctrl.email).toBe("test@example.com");
|
||||||
|
expect(ctrl.image).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets timestamps", () => {
|
||||||
|
const ctrl = new UserController(buildMockUser());
|
||||||
|
expect(ctrl.createdAt).toBeInstanceOf(Date);
|
||||||
|
expect(ctrl.updatedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds roles collection", () => {
|
||||||
|
const mockRole = {
|
||||||
|
id: "role-1",
|
||||||
|
title: "Admin",
|
||||||
|
moniker: "admin",
|
||||||
|
permissions: "tok",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
const ctrl = new UserController(buildMockUser({ roles: [mockRole] }));
|
||||||
|
// _roles is private, but we can verify via toJson
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json.roles).toContain("admin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// toJson
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("toJson()", () => {
|
||||||
|
test("returns full JSON by default", () => {
|
||||||
|
const ctrl = new UserController(buildMockUser());
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json.id).toBe("user-1");
|
||||||
|
expect(json.name).toBe("Test User");
|
||||||
|
expect(json.login).toBe("test@example.com");
|
||||||
|
expect(json.email).toBe("test@example.com");
|
||||||
|
expect(json.image).toBeNull();
|
||||||
|
expect(json.createdAt).toBeDefined();
|
||||||
|
expect(json.updatedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("safeReturn hides sensitive fields", () => {
|
||||||
|
const ctrl = new UserController(buildMockUser());
|
||||||
|
const json = ctrl.toJson({ safeReturn: true });
|
||||||
|
expect(json.id).toBe("user-1");
|
||||||
|
expect(json.name).toBe("Test User");
|
||||||
|
expect(json.login).toBeUndefined();
|
||||||
|
expect(json.email).toBeUndefined();
|
||||||
|
expect(json.roles).toBeUndefined();
|
||||||
|
expect(json.permissions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("roles is undefined when user has no roles", () => {
|
||||||
|
const ctrl = new UserController(buildMockUser({ roles: [] }));
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
// _roles.size == 0, so roles is undefined
|
||||||
|
expect(json.roles).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("roles returns monikers when present", () => {
|
||||||
|
const mockRoles = [
|
||||||
|
{
|
||||||
|
id: "r1",
|
||||||
|
title: "Admin",
|
||||||
|
moniker: "admin",
|
||||||
|
permissions: "t",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r2",
|
||||||
|
title: "User",
|
||||||
|
moniker: "user",
|
||||||
|
permissions: "t",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const ctrl = new UserController(buildMockUser({ roles: mockRoles }));
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json.roles).toHaveLength(2);
|
||||||
|
expect(json.roles).toContain("admin");
|
||||||
|
expect(json.roles).toContain("user");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("permissions returns empty array when user has no permissions token", () => {
|
||||||
|
const ctrl = new UserController(buildMockUser({ permissions: null }));
|
||||||
|
const json = ctrl.toJson();
|
||||||
|
expect(json.permissions).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// readPermissions
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("readPermissions()", () => {
|
||||||
|
test("returns empty array when permissions is null", () => {
|
||||||
|
const ctrl = new UserController(buildMockUser({ permissions: null }));
|
||||||
|
expect(ctrl.readPermissions()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { createRoute } from "../../src/modules/api-utils/createRoute";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
describe("createRoute", () => {
|
||||||
|
test("returns a Hono instance", () => {
|
||||||
|
const route = createRoute("get", ["/test"], (c) => c.text("ok"));
|
||||||
|
expect(route).toBeInstanceOf(Hono);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET route responds correctly", async () => {
|
||||||
|
const route = createRoute("get", ["/hello"], (c) =>
|
||||||
|
c.json({ message: "Hello" }),
|
||||||
|
);
|
||||||
|
const res = await route.request("/hello");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.message).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST route responds correctly", async () => {
|
||||||
|
const route = createRoute("post", ["/items"], async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
return c.json({ received: body });
|
||||||
|
});
|
||||||
|
const res = await route.request("/items", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name: "test" }),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const data: any = await res.json();
|
||||||
|
expect(data.received.name).toBe("test");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports multiple paths", async () => {
|
||||||
|
const route = createRoute("get", ["/a", "/b"], (c) =>
|
||||||
|
c.json({ path: c.req.path }),
|
||||||
|
);
|
||||||
|
const resA = await route.request("/a");
|
||||||
|
const resB = await route.request("/b");
|
||||||
|
expect(resA.status).toBe(200);
|
||||||
|
expect(resB.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applies middleware", async () => {
|
||||||
|
let middlewareRan = false;
|
||||||
|
const route = createRoute(
|
||||||
|
"get",
|
||||||
|
["/protected"],
|
||||||
|
(c) => c.json({ ok: true }),
|
||||||
|
async (c, next) => {
|
||||||
|
middlewareRan = true;
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await route.request("/protected");
|
||||||
|
expect(middlewareRan).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("middleware can block handler", async () => {
|
||||||
|
const route = createRoute(
|
||||||
|
"get",
|
||||||
|
["/blocked"],
|
||||||
|
(c) => c.json({ ok: true }),
|
||||||
|
async (c, _next) => {
|
||||||
|
return c.json({ blocked: true }, 403);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const res = await route.request("/blocked");
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.blocked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supports multiple middleware functions", async () => {
|
||||||
|
const order: number[] = [];
|
||||||
|
const route = createRoute(
|
||||||
|
"get",
|
||||||
|
["/multi"],
|
||||||
|
(c) => c.json({ order }),
|
||||||
|
async (_c, next) => {
|
||||||
|
order.push(1);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
async (_c, next) => {
|
||||||
|
order.push(2);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await route.request("/multi");
|
||||||
|
expect(order).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 404 for unmatched paths", async () => {
|
||||||
|
const route = createRoute("get", ["/exists"], (c) => c.text("ok"));
|
||||||
|
const res = await route.request("/not-exists");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("method mismatch returns 404 or 405", async () => {
|
||||||
|
const route = createRoute("get", ["/only-get"], (c) => c.text("ok"));
|
||||||
|
const res = await route.request("/only-get", { method: "POST" });
|
||||||
|
// Hono returns 404 for method mismatch by default
|
||||||
|
expect([404, 405]).toContain(res.status);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { ValueType } from "../../src/modules/credentials/credentialTypeDefs";
|
||||||
|
import type { CredentialTypeField } from "../../src/modules/credentials/credentialTypeDefs";
|
||||||
|
|
||||||
|
describe("credentialTypeDefs", () => {
|
||||||
|
describe("ValueType enum", () => {
|
||||||
|
test("has expected values", () => {
|
||||||
|
expect(ValueType.PLAIN_TEXT as string).toBe("plain_text");
|
||||||
|
expect(ValueType.LICENSE_KEY as string).toBe("license_key");
|
||||||
|
expect(ValueType.IP_ADDRESS as string).toBe("ip_address");
|
||||||
|
expect(ValueType.GENERIC_SECRET as string).toBe("generic_secret");
|
||||||
|
expect(ValueType.BITLOCKER_KEY as string).toBe("bitlocker_key");
|
||||||
|
expect(ValueType.PASSWORD as string).toBe("password");
|
||||||
|
expect(ValueType.MULTI_CREDENTIAL as string).toBe("multi_credential");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("has exactly 7 members", () => {
|
||||||
|
const values = Object.values(ValueType);
|
||||||
|
expect(values).toHaveLength(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CredentialTypeField type", () => {
|
||||||
|
test("valid field shape satisfies interface", () => {
|
||||||
|
const field: CredentialTypeField = {
|
||||||
|
id: "test",
|
||||||
|
name: "Test Field",
|
||||||
|
required: true,
|
||||||
|
secure: false,
|
||||||
|
valueType: ValueType.PLAIN_TEXT,
|
||||||
|
};
|
||||||
|
expect(field.id).toBe("test");
|
||||||
|
expect(field.subFields).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("field with subFields satisfies interface", () => {
|
||||||
|
const field: CredentialTypeField = {
|
||||||
|
id: "multi",
|
||||||
|
name: "Multi",
|
||||||
|
required: false,
|
||||||
|
secure: false,
|
||||||
|
valueType: ValueType.MULTI_CREDENTIAL,
|
||||||
|
subFields: [
|
||||||
|
{
|
||||||
|
id: "sub1",
|
||||||
|
name: "Sub 1",
|
||||||
|
required: true,
|
||||||
|
secure: false,
|
||||||
|
valueType: ValueType.PLAIN_TEXT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(field.subFields).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import GenericError from "../../src/Errors/GenericError";
|
||||||
|
import AuthenticationError from "../../src/Errors/AuthenticationError";
|
||||||
|
import AuthorizationError from "../../src/Errors/AuthorizationError";
|
||||||
|
import BodyError from "../../src/Errors/BodyError";
|
||||||
|
import InsufficientPermission from "../../src/Errors/InsufficientPermission";
|
||||||
|
import SessionError from "../../src/Errors/SessionError";
|
||||||
|
import SessionTokenError from "../../src/Errors/SessionTokenError";
|
||||||
|
import UserError from "../../src/Errors/UserError";
|
||||||
|
import RoleError from "../../src/Errors/RoleError";
|
||||||
|
import MissingBodyValue from "../../src/Errors/MissingBodyValue";
|
||||||
|
import ExpiredAccessTokenError from "../../src/Errors/ExpiredAccessTokenError";
|
||||||
|
import ExpiredRefreshTokenError from "../../src/Errors/ExpiredRefreshTokenError";
|
||||||
|
import PermissionsVerificationError from "../../src/Errors/PermissionsVerificationError";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GenericError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("GenericError", () => {
|
||||||
|
test("sets name, message, status, and cause", () => {
|
||||||
|
const err = new GenericError({
|
||||||
|
name: "TestError",
|
||||||
|
message: "Something went wrong",
|
||||||
|
cause: "bad input",
|
||||||
|
status: 422,
|
||||||
|
});
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err.name).toBe("TestError");
|
||||||
|
expect(err.message).toBe("Something went wrong");
|
||||||
|
expect(err.cause).toBe("bad input");
|
||||||
|
expect(err.status).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults status to 400", () => {
|
||||||
|
const err = new GenericError({ name: "X", message: "Y" });
|
||||||
|
expect(err.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cause is optional", () => {
|
||||||
|
const err = new GenericError({ name: "X", message: "Y" });
|
||||||
|
expect(err.cause).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AuthenticationError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("AuthenticationError", () => {
|
||||||
|
test("sets correct name and message", () => {
|
||||||
|
const err = new AuthenticationError("Invalid credentials");
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err.name).toBe("AuthenticationError");
|
||||||
|
expect(err.message).toBe("Invalid credentials");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts optional cause", () => {
|
||||||
|
const err = new AuthenticationError("Fail", "token expired");
|
||||||
|
expect(err.cause).toBe("token expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cause defaults to undefined", () => {
|
||||||
|
const err = new AuthenticationError("Fail");
|
||||||
|
expect(err.cause).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AuthorizationError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("AuthorizationError", () => {
|
||||||
|
test("sets correct name and default status", () => {
|
||||||
|
const err = new AuthorizationError("Not authorized");
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err.name).toBe("AuthorizationError");
|
||||||
|
expect(err.message).toBe("Not authorized");
|
||||||
|
expect(err.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows custom status", () => {
|
||||||
|
const err = new AuthorizationError("Forbidden", "nope", 403);
|
||||||
|
expect(err.status).toBe(403);
|
||||||
|
expect(err.cause).toBe("nope");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BodyError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("BodyError", () => {
|
||||||
|
test("sets name and message", () => {
|
||||||
|
const err = new BodyError("Body is invalid");
|
||||||
|
expect(err.name).toBe("BodyError");
|
||||||
|
expect(err.message).toBe("Body is invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts optional cause", () => {
|
||||||
|
const err = new BodyError("Bad", "missing field");
|
||||||
|
expect(err.cause).toBe("missing field");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// InsufficientPermission
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("InsufficientPermission", () => {
|
||||||
|
test("always has status 403", () => {
|
||||||
|
const err = new InsufficientPermission("Nope");
|
||||||
|
expect(err.name).toBe("InsufficientPermission");
|
||||||
|
expect(err.status).toBe(403);
|
||||||
|
expect(err.message).toBe("Nope");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts optional cause", () => {
|
||||||
|
const err = new InsufficientPermission("Nope", "missing role");
|
||||||
|
expect(err.cause).toBe("missing role");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SessionError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("SessionError", () => {
|
||||||
|
test("sets name and message", () => {
|
||||||
|
const err = new SessionError("Invalid session");
|
||||||
|
expect(err.name).toBe("SessionError");
|
||||||
|
expect(err.message).toBe("Invalid session");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SessionTokenError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("SessionTokenError", () => {
|
||||||
|
test("sets name and message", () => {
|
||||||
|
const err = new SessionTokenError("Token invalid");
|
||||||
|
expect(err.name).toBe("SessionTokenError");
|
||||||
|
expect(err.message).toBe("Token invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts cause", () => {
|
||||||
|
const err = new SessionTokenError("Bad", "expired");
|
||||||
|
expect(err.cause).toBe("expired");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UserError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("UserError", () => {
|
||||||
|
test("sets name and message", () => {
|
||||||
|
const err = new UserError("User not found");
|
||||||
|
expect(err.name).toBe("UserError");
|
||||||
|
expect(err.message).toBe("User not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RoleError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("RoleError", () => {
|
||||||
|
test("sets name and message", () => {
|
||||||
|
const err = new RoleError("Role conflict");
|
||||||
|
expect(err.name).toBe("RoleError");
|
||||||
|
expect(err.message).toBe("Role conflict");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts cause", () => {
|
||||||
|
const err = new RoleError("Conflict", "moniker taken");
|
||||||
|
expect(err.cause).toBe("moniker taken");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MissingBodyValue
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("MissingBodyValue", () => {
|
||||||
|
test("formats message with value name", () => {
|
||||||
|
const err = new MissingBodyValue("email");
|
||||||
|
expect(err.name).toBe("MissingBodyValue");
|
||||||
|
expect(err.message).toBe("Value 'email' is missing from the body.");
|
||||||
|
expect(err.cause).toBe(
|
||||||
|
"A value that was required by the body of this request is missing.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works with different value names", () => {
|
||||||
|
const err = new MissingBodyValue("password");
|
||||||
|
expect(err.message).toContain("password");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ExpiredAccessTokenError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("ExpiredAccessTokenError", () => {
|
||||||
|
test("sets fixed name and message", () => {
|
||||||
|
const err = new ExpiredAccessTokenError();
|
||||||
|
expect(err.name).toBe("ExpiredAccessTokenError");
|
||||||
|
expect(err.message).toBe("The provided access token has expired.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts optional cause", () => {
|
||||||
|
const err = new ExpiredAccessTokenError("jwt expired");
|
||||||
|
expect(err.cause).toBe("jwt expired");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ExpiredRefreshTokenError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("ExpiredRefreshTokenError", () => {
|
||||||
|
test("sets fixed name and message", () => {
|
||||||
|
const err = new ExpiredRefreshTokenError();
|
||||||
|
expect(err.name).toBe("ExpiredRefreshTokenError");
|
||||||
|
expect(err.message).toBe("The provided refresh token has expired.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts optional cause", () => {
|
||||||
|
const err = new ExpiredRefreshTokenError("jwt expired");
|
||||||
|
expect(err.cause).toBe("jwt expired");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PermissionsVerificationError
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("PermissionsVerificationError", () => {
|
||||||
|
test("sets name and message", () => {
|
||||||
|
const err = new PermissionsVerificationError("Cannot verify");
|
||||||
|
expect(err.name).toBe("PermissionsVerificationError");
|
||||||
|
expect(err.message).toBe("Cannot verify");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts cause", () => {
|
||||||
|
const err = new PermissionsVerificationError("Fail", "key mismatch");
|
||||||
|
expect(err.cause).toBe("key mismatch");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cross-cutting: all errors are instanceof Error
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("All errors extend Error", () => {
|
||||||
|
const errors = [
|
||||||
|
new GenericError({ name: "G", message: "g" }),
|
||||||
|
new AuthenticationError("a"),
|
||||||
|
new AuthorizationError("a"),
|
||||||
|
new BodyError("b"),
|
||||||
|
new InsufficientPermission("i"),
|
||||||
|
new SessionError("s"),
|
||||||
|
new SessionTokenError("st"),
|
||||||
|
new UserError("u"),
|
||||||
|
new RoleError("r"),
|
||||||
|
new MissingBodyValue("v"),
|
||||||
|
new ExpiredAccessTokenError(),
|
||||||
|
new ExpiredRefreshTokenError(),
|
||||||
|
new PermissionsVerificationError("p"),
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(errors.map((e) => [e.constructor.name, e]))(
|
||||||
|
"%s is instanceof Error",
|
||||||
|
(_name, err) => {
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { ValueType } from "../../src/modules/credentials/credentialTypeDefs";
|
||||||
|
import { fieldValidator } from "../../src/modules/credentials/fieldValidator";
|
||||||
|
import type {
|
||||||
|
CredentialField,
|
||||||
|
CredentialTypeField,
|
||||||
|
} from "../../src/modules/credentials/credentialTypeDefs";
|
||||||
|
|
||||||
|
const baseAcceptableFields: CredentialTypeField[] = [
|
||||||
|
{
|
||||||
|
id: "username",
|
||||||
|
name: "Username",
|
||||||
|
required: true,
|
||||||
|
secure: false,
|
||||||
|
valueType: ValueType.PLAIN_TEXT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "password",
|
||||||
|
name: "Password",
|
||||||
|
required: true,
|
||||||
|
secure: true,
|
||||||
|
valueType: ValueType.PASSWORD,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notes",
|
||||||
|
name: "Notes",
|
||||||
|
required: false,
|
||||||
|
secure: false,
|
||||||
|
valueType: ValueType.PLAIN_TEXT,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("fieldValidator", () => {
|
||||||
|
test("validates correct fields and returns validated array", async () => {
|
||||||
|
const fields: CredentialField[] = [
|
||||||
|
{ fieldId: "username", value: "admin" },
|
||||||
|
{ fieldId: "password", value: "secret123" },
|
||||||
|
];
|
||||||
|
const result = await fieldValidator(fields, baseAcceptableFields);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]!.fieldId).toBe("username");
|
||||||
|
expect(result[0]!.secure).toBe(false);
|
||||||
|
expect(result[1]!.fieldId).toBe("password");
|
||||||
|
expect(result[1]!.secure).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws GenericError for unknown field ID", async () => {
|
||||||
|
const fields: CredentialField[] = [
|
||||||
|
{ fieldId: "nonexistent", value: "val" },
|
||||||
|
];
|
||||||
|
await expect(fieldValidator(fields, baseAcceptableFields)).rejects.toThrow(
|
||||||
|
"Invalid field ID: nonexistent",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles optional fields", async () => {
|
||||||
|
const fields: CredentialField[] = [
|
||||||
|
{ fieldId: "notes", value: "some note" },
|
||||||
|
];
|
||||||
|
const result = await fieldValidator(fields, baseAcceptableFields);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.secure).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty fields array", async () => {
|
||||||
|
const result = await fieldValidator([], baseAcceptableFields);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks MULTI_CREDENTIAL fields correctly", async () => {
|
||||||
|
const acceptableFields: CredentialTypeField[] = [
|
||||||
|
{
|
||||||
|
id: "sub_creds",
|
||||||
|
name: "Sub Credentials",
|
||||||
|
required: false,
|
||||||
|
secure: false,
|
||||||
|
valueType: ValueType.MULTI_CREDENTIAL,
|
||||||
|
subFields: [
|
||||||
|
{
|
||||||
|
id: "sub_user",
|
||||||
|
name: "Sub User",
|
||||||
|
required: true,
|
||||||
|
secure: false,
|
||||||
|
valueType: ValueType.PLAIN_TEXT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const fields: CredentialField[] = [{ fieldId: "sub_creds", value: "" }];
|
||||||
|
const result = await fieldValidator(fields, acceptableFields);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]!.isMultiCredential).toBe(true);
|
||||||
|
expect(result[0]!.secure).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { genImplicitPerm } from "../../src/modules/permission-utils/genImplicitPerm";
|
||||||
|
|
||||||
|
describe("genImplicitPerm", () => {
|
||||||
|
test("builds a dot-delimited implicit permission string", () => {
|
||||||
|
const result = genImplicitPerm("sessions", "sess-1", "user-1");
|
||||||
|
expect(result).toBe("resource.sessions.sess-1.user.user-1.implicit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works with different resource types", () => {
|
||||||
|
const result = genImplicitPerm("roles", "role-abc", "user-xyz");
|
||||||
|
expect(result).toBe("resource.roles.role-abc.user.user-xyz.implicit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles IDs with special characters", () => {
|
||||||
|
const result = genImplicitPerm("keys", "key-123_abc", "u-1");
|
||||||
|
expect(result).toBe("resource.keys.key-123_abc.user.u-1.implicit");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, test, expect, mock } from "bun:test";
|
||||||
|
import { Eventra } from "@duxcore/eventra";
|
||||||
|
|
||||||
|
// We test the globalEvents module shape and the setupEventDebugger function.
|
||||||
|
// We import directly since the module has minimal side-effects.
|
||||||
|
import { events, setupEventDebugger } from "../../src/modules/globalEvents";
|
||||||
|
|
||||||
|
describe("globalEvents", () => {
|
||||||
|
test("events is an Eventra instance", () => {
|
||||||
|
expect(events).toBeDefined();
|
||||||
|
expect(typeof events.emit).toBe("function");
|
||||||
|
expect(typeof events.on).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setupEventDebugger registers a catch-all listener", () => {
|
||||||
|
// Calling setupEventDebugger should not throw
|
||||||
|
expect(() => setupEventDebugger()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can emit and receive events", () => {
|
||||||
|
let received = false;
|
||||||
|
events.on("api:started", () => {
|
||||||
|
received = true;
|
||||||
|
});
|
||||||
|
events.emit("api:started");
|
||||||
|
expect(received).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { mergeArrays } from "../../src/modules/tools/mergeArrays";
|
||||||
|
|
||||||
|
describe("mergeArrays", () => {
|
||||||
|
test("merges two disjoint arrays", () => {
|
||||||
|
const result = mergeArrays([1, 2], [3, 4]);
|
||||||
|
expect(result).toEqual([1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes duplicates from second array", () => {
|
||||||
|
const result = mergeArrays([1, 2, 3], [2, 3, 4]);
|
||||||
|
expect(result).toEqual([1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty first array", () => {
|
||||||
|
const result = mergeArrays([], [1, 2]);
|
||||||
|
expect(result).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty second array", () => {
|
||||||
|
const result = mergeArrays([1, 2], []);
|
||||||
|
expect(result).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles both arrays empty", () => {
|
||||||
|
const result = mergeArrays([], []);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works with strings", () => {
|
||||||
|
const result = mergeArrays(["a", "b"], ["b", "c"]);
|
||||||
|
expect(result).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not mutate original arrays", () => {
|
||||||
|
const a = [1, 2];
|
||||||
|
const b = [2, 3];
|
||||||
|
const result = mergeArrays(a, b);
|
||||||
|
expect(a).toEqual([1, 2]);
|
||||||
|
expect(b).toEqual([2, 3]);
|
||||||
|
expect(result).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts custom predicate", () => {
|
||||||
|
const a = [{ id: 1, n: "a" }];
|
||||||
|
const b = [
|
||||||
|
{ id: 1, n: "b" },
|
||||||
|
{ id: 2, n: "c" },
|
||||||
|
];
|
||||||
|
const result = mergeArrays(a, b, (x: any, y: any) => x.id === y.id);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toEqual({ id: 1, n: "a" });
|
||||||
|
expect(result[1]).toEqual({ id: 2, n: "c" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps all elements when predicate never matches", () => {
|
||||||
|
const result = mergeArrays([1, 2], [3, 4], () => false);
|
||||||
|
expect(result).toEqual([1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
// We test the authMiddleware in isolation by importing and mounting it on a
|
||||||
|
// minimal Hono app, without touching the real session/user layer.
|
||||||
|
|
||||||
|
// Mock the managers and modules that authMiddleware depends on
|
||||||
|
mock.module("../../../src/managers/sessions", () => ({
|
||||||
|
sessions: {
|
||||||
|
fetch: mock(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("../../../src/modules/globalEvents", () => ({
|
||||||
|
events: {
|
||||||
|
emit: mock(),
|
||||||
|
on: mock(),
|
||||||
|
any: mock(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { authMiddleware } from "../../../src/api/middleware/authorization";
|
||||||
|
import { sessions } from "../../../src/managers/sessions";
|
||||||
|
import { apiResponse } from "../../../src/modules/api-utils/apiResponse";
|
||||||
|
|
||||||
|
function createTestApp(permParams?: Parameters<typeof authMiddleware>[0]) {
|
||||||
|
const app = new Hono();
|
||||||
|
app.onError((err, c) => {
|
||||||
|
const response = apiResponse.error(err);
|
||||||
|
return c.json(response, response.status);
|
||||||
|
});
|
||||||
|
app.use("*", authMiddleware(permParams));
|
||||||
|
app.get("/test", (c) => c.json({ ok: true }));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("authMiddleware", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset mocks
|
||||||
|
(sessions.fetch as any).mockReset?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Missing authorization header
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
test("rejects requests without authorization header", async () => {
|
||||||
|
const app = createTestApp();
|
||||||
|
const res = await app.request("/test");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.error).toBe("AuthorizationError");
|
||||||
|
expect(body.message).toContain("authorization");
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Malformed authorization header
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
test("rejects malformed authorization header", async () => {
|
||||||
|
const app = createTestApp();
|
||||||
|
const res = await app.request("/test", {
|
||||||
|
headers: { Authorization: "foobar" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.error).toBe("AuthorizationError");
|
||||||
|
expect(body.message).toContain("malformed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects authorization missing token value", async () => {
|
||||||
|
const app = createTestApp();
|
||||||
|
const res = await app.request("/test", {
|
||||||
|
headers: { Authorization: "Bearer " },
|
||||||
|
});
|
||||||
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.successful).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Forbidden auth types
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
test("rejects forbidden auth types", async () => {
|
||||||
|
const app = createTestApp({ forbiddenAuthTypes: ["Key"] });
|
||||||
|
const res = await app.request("/test", {
|
||||||
|
headers: { Authorization: "Key aaa.bbb.ccc" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.error).toBe("NonpermittedAuthType");
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Valid token flow
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
test("calls sessions.fetch with access token", async () => {
|
||||||
|
const mockUser = {
|
||||||
|
hasPermission: mock(() => Promise.resolve(true)),
|
||||||
|
};
|
||||||
|
const mockSession = {
|
||||||
|
fetchUser: mock(() => Promise.resolve(mockUser)),
|
||||||
|
};
|
||||||
|
(sessions.fetch as any).mockResolvedValue?.(mockSession) ??
|
||||||
|
((sessions as any).fetch = mock(() => Promise.resolve(mockSession)));
|
||||||
|
|
||||||
|
const app = createTestApp();
|
||||||
|
const res = await app.request("/test", {
|
||||||
|
headers: { Authorization: "Bearer aaa.bbb.ccc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// If sessions.fetch resolves, the middleware should pass through
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Permission checking
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
test("rejects when user lacks required permission", async () => {
|
||||||
|
const mockUser = {
|
||||||
|
hasPermission: mock(() => Promise.resolve(false)),
|
||||||
|
};
|
||||||
|
const mockSession = {
|
||||||
|
fetchUser: mock(() => Promise.resolve(mockUser)),
|
||||||
|
};
|
||||||
|
(sessions as any).fetch = mock(() => Promise.resolve(mockSession));
|
||||||
|
|
||||||
|
const app = createTestApp({ permissions: ["admin.super"] });
|
||||||
|
const res = await app.request("/test", {
|
||||||
|
headers: { Authorization: "Bearer aaa.bbb.ccc" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.message).toContain("permission");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows when user has all required permissions", async () => {
|
||||||
|
const mockUser = {
|
||||||
|
hasPermission: mock(() => Promise.resolve(true)),
|
||||||
|
};
|
||||||
|
const mockSession = {
|
||||||
|
fetchUser: mock(() => Promise.resolve(mockUser)),
|
||||||
|
};
|
||||||
|
(sessions as any).fetch = mock(() => Promise.resolve(mockSession));
|
||||||
|
|
||||||
|
const app = createTestApp({
|
||||||
|
permissions: ["company.fetch", "company.list"],
|
||||||
|
});
|
||||||
|
const res = await app.request("/test", {
|
||||||
|
headers: { Authorization: "Bearer aaa.bbb.ccc" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes through when no permissions required", async () => {
|
||||||
|
const mockUser = { hasPermission: mock(() => Promise.resolve(true)) };
|
||||||
|
const mockSession = { fetchUser: mock(() => Promise.resolve(mockUser)) };
|
||||||
|
(sessions as any).fetch = mock(() => Promise.resolve(mockSession));
|
||||||
|
|
||||||
|
const app = createTestApp();
|
||||||
|
const res = await app.request("/test", {
|
||||||
|
headers: { Authorization: "Bearer aaa.bbb.ccc" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import Password from "../../src/modules/tools/Password";
|
||||||
|
|
||||||
|
describe("Password", () => {
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// generateSalt
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("generateSalt()", () => {
|
||||||
|
test("returns a string of default length 12", () => {
|
||||||
|
const salt = Password.generateSalt();
|
||||||
|
expect(typeof salt).toBe("string");
|
||||||
|
expect(salt.length).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a string of custom length", () => {
|
||||||
|
const salt = Password.generateSalt({ length: 24 });
|
||||||
|
expect(salt.length).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates different salts each time", () => {
|
||||||
|
const s1 = Password.generateSalt();
|
||||||
|
const s2 = Password.generateSalt();
|
||||||
|
// Extremely unlikely to be equal
|
||||||
|
expect(s1).not.toBe(s2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns hex characters only", () => {
|
||||||
|
const salt = Password.generateSalt({ length: 20 });
|
||||||
|
expect(/^[0-9a-f]+$/.test(salt)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// hash
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("hash()", () => {
|
||||||
|
test("returns a BLAKE2s prefixed string", () => {
|
||||||
|
const hashed = Password.hash("mypassword");
|
||||||
|
expect(hashed.startsWith("BLAKE2s$")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains three dollar-sign separated parts", () => {
|
||||||
|
const hashed = Password.hash("mypassword", { overrideSalt: "testsalt" });
|
||||||
|
const parts = hashed.split("$");
|
||||||
|
expect(parts.length).toBe(3);
|
||||||
|
expect(parts[0]).toBe("BLAKE2s");
|
||||||
|
expect(parts[2]).toBe("testsalt");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same password + same salt produces same hash", () => {
|
||||||
|
const h1 = Password.hash("password", { overrideSalt: "salt123" });
|
||||||
|
const h2 = Password.hash("password", { overrideSalt: "salt123" });
|
||||||
|
expect(h1).toBe(h2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("different passwords produce different hashes", () => {
|
||||||
|
const h1 = Password.hash("password1", { overrideSalt: "salt" });
|
||||||
|
const h2 = Password.hash("password2", { overrideSalt: "salt" });
|
||||||
|
expect(h1).not.toBe(h2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("different salts produce different hashes", () => {
|
||||||
|
const h1 = Password.hash("password", { overrideSalt: "salt1" });
|
||||||
|
const h2 = Password.hash("password", { overrideSalt: "salt2" });
|
||||||
|
expect(h1).not.toBe(h2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates salt when saltOpts provided", () => {
|
||||||
|
const hashed = Password.hash("password", { saltOpts: { length: 16 } });
|
||||||
|
const parts = hashed.split("$");
|
||||||
|
// Should have a 16-char salt
|
||||||
|
expect(parts[2]!.length).toBe(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// validate
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("validate()", () => {
|
||||||
|
test("returns true for matching password", () => {
|
||||||
|
const hashed = Password.hash("correctpassword", { overrideSalt: "salt" });
|
||||||
|
expect(Password.validate("correctpassword", hashed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for wrong password", () => {
|
||||||
|
const hashed = Password.hash("correctpassword", { overrideSalt: "salt" });
|
||||||
|
// timingSafeEqual throws if buffers are different lengths, but since
|
||||||
|
// the hash output has the same length regardless, a wrong password
|
||||||
|
// with same-length output will return false.
|
||||||
|
// However if the buffers are different lengths it throws — in that
|
||||||
|
// case we just check the behaviour is consistent:
|
||||||
|
try {
|
||||||
|
const result = Password.validate("wrongpassword", hashed);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
} catch {
|
||||||
|
// timingSafeEqual may throw on different lengths, which is acceptable
|
||||||
|
expect(true).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("round-trips correctly with generated salt", () => {
|
||||||
|
const hashed = Password.hash("securePass123!", {
|
||||||
|
saltOpts: { length: 12 },
|
||||||
|
});
|
||||||
|
expect(Password.validate("securePass123!", hashed)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the PermissionNodes type definitions and structure.
|
||||||
|
* We import the permission nodes and validate the shape of the data.
|
||||||
|
*/
|
||||||
|
import { PERMISSION_NODES } from "../../src/types/PermissionNodes";
|
||||||
|
import type {
|
||||||
|
PermissionNode,
|
||||||
|
PermissionCategory,
|
||||||
|
} from "../../src/types/PermissionNodes";
|
||||||
|
|
||||||
|
describe("PermissionNodes", () => {
|
||||||
|
test("PERMISSION_NODES is defined and is an object", () => {
|
||||||
|
expect(PERMISSION_NODES).toBeDefined();
|
||||||
|
expect(typeof PERMISSION_NODES).toBe("object");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("has required top-level categories", () => {
|
||||||
|
expect(PERMISSION_NODES).toHaveProperty("global");
|
||||||
|
expect(PERMISSION_NODES).toHaveProperty("company");
|
||||||
|
expect(PERMISSION_NODES).toHaveProperty("credential");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each category has name, description, and permissions", () => {
|
||||||
|
for (const [key, category] of Object.entries(PERMISSION_NODES)) {
|
||||||
|
const cat = category as PermissionCategory;
|
||||||
|
expect(cat).toHaveProperty("name");
|
||||||
|
expect(typeof cat.name).toBe("string");
|
||||||
|
expect(cat).toHaveProperty("description");
|
||||||
|
expect(typeof cat.description).toBe("string");
|
||||||
|
expect(cat).toHaveProperty("permissions");
|
||||||
|
expect(Array.isArray(cat.permissions)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each permission node has required fields", () => {
|
||||||
|
for (const [_key, category] of Object.entries(PERMISSION_NODES)) {
|
||||||
|
const cat = category as PermissionCategory;
|
||||||
|
for (const perm of cat.permissions) {
|
||||||
|
expect(perm).toHaveProperty("node");
|
||||||
|
expect(typeof perm.node).toBe("string");
|
||||||
|
expect(perm.node.length).toBeGreaterThan(0);
|
||||||
|
expect(perm).toHaveProperty("description");
|
||||||
|
expect(typeof perm.description).toBe("string");
|
||||||
|
expect(perm).toHaveProperty("usedIn");
|
||||||
|
expect(Array.isArray(perm.usedIn)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("global category contains the wildcard * node", () => {
|
||||||
|
const globalPerms = (PERMISSION_NODES.global as PermissionCategory)
|
||||||
|
.permissions;
|
||||||
|
const wildcard = globalPerms.find((p) => p.node === "*");
|
||||||
|
expect(wildcard).toBeDefined();
|
||||||
|
expect(wildcard!.description).toContain("Full access");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all permission nodes are non-empty strings", () => {
|
||||||
|
for (const [_key, category] of Object.entries(PERMISSION_NODES)) {
|
||||||
|
const cat = category as PermissionCategory;
|
||||||
|
for (const perm of cat.permissions) {
|
||||||
|
expect(typeof perm.node).toBe("string");
|
||||||
|
expect(perm.node.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dependencies reference existing permission nodes", () => {
|
||||||
|
// Collect all nodes
|
||||||
|
const allNodes = new Set<string>();
|
||||||
|
for (const [_key, category] of Object.entries(PERMISSION_NODES)) {
|
||||||
|
const cat = category as PermissionCategory;
|
||||||
|
for (const perm of cat.permissions) {
|
||||||
|
allNodes.add(perm.node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all dependencies point to real nodes
|
||||||
|
for (const [_key, category] of Object.entries(PERMISSION_NODES)) {
|
||||||
|
const cat = category as PermissionCategory;
|
||||||
|
for (const perm of cat.permissions) {
|
||||||
|
if (perm.dependencies) {
|
||||||
|
for (const dep of perm.dependencies) {
|
||||||
|
expect(allNodes.has(dep)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { permissionValidator } from "../../src/modules/permission-utils/permissionValidator";
|
||||||
|
|
||||||
|
describe("permissionValidator", () => {
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Exact match
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("exact matches", () => {
|
||||||
|
test("returns true for exact permission match", () => {
|
||||||
|
expect(permissionValidator("company.fetch", ["company.fetch"])).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false when no match", () => {
|
||||||
|
expect(permissionValidator("company.fetch", ["company.create"])).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for empty expressions", () => {
|
||||||
|
expect(permissionValidator("company.fetch", [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles single string expression", () => {
|
||||||
|
expect(permissionValidator("company.fetch", "company.fetch")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles single string non-match", () => {
|
||||||
|
expect(permissionValidator("company.fetch", "company.create")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Wildcard *
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("wildcard (*)", () => {
|
||||||
|
test("* matches any single-segment permission", () => {
|
||||||
|
expect(permissionValidator("company", ["*"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("* matches multi-segment permissions", () => {
|
||||||
|
expect(permissionValidator("company.fetch.many", ["*"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("company.* matches company.fetch", () => {
|
||||||
|
expect(permissionValidator("company.fetch", ["company.*"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("company.* matches company.fetch.many", () => {
|
||||||
|
expect(permissionValidator("company.fetch.many", ["company.*"])).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("*.fetch matches company.fetch", () => {
|
||||||
|
expect(permissionValidator("company.fetch", ["*.fetch"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("company.fetch.* matches company.fetch.many", () => {
|
||||||
|
expect(
|
||||||
|
permissionValidator("company.fetch.many", ["company.fetch.*"]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("company.fetch.* does NOT match company.create", () => {
|
||||||
|
expect(permissionValidator("company.create", ["company.fetch.*"])).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Single-character wildcard ?
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("single-character wildcard (?)", () => {
|
||||||
|
test("? matches exactly one character", () => {
|
||||||
|
expect(permissionValidator("company.a", ["company.?"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("? does not match multiple characters", () => {
|
||||||
|
expect(permissionValidator("company.ab", ["company.?"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("? does not match dot separator", () => {
|
||||||
|
expect(permissionValidator("company.a.b", ["company.?"])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Bracket groups [a,b,c]
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("bracket groups [a,b,c]", () => {
|
||||||
|
test("matches first option in group", () => {
|
||||||
|
expect(
|
||||||
|
permissionValidator("company.fetch", ["company.[fetch,create]"]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches second option in group", () => {
|
||||||
|
expect(
|
||||||
|
permissionValidator("company.create", ["company.[fetch,create]"]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not match unlisted option", () => {
|
||||||
|
expect(
|
||||||
|
permissionValidator("company.delete", ["company.[fetch,create]"]),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Multiple expressions
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("multiple expressions", () => {
|
||||||
|
test("returns true if any expression matches", () => {
|
||||||
|
expect(
|
||||||
|
permissionValidator("role.create", [
|
||||||
|
"company.fetch",
|
||||||
|
"role.create",
|
||||||
|
"user.read",
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false if no expression matches", () => {
|
||||||
|
expect(
|
||||||
|
permissionValidator("role.delete", [
|
||||||
|
"company.fetch",
|
||||||
|
"role.create",
|
||||||
|
"user.read",
|
||||||
|
]),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Complex patterns
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
describe("complex patterns", () => {
|
||||||
|
test("combined wildcard and bracket", () => {
|
||||||
|
expect(
|
||||||
|
permissionValidator("company.fetch.many", ["company.[fetch,create].*"]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deeply nested permission with wildcard", () => {
|
||||||
|
expect(
|
||||||
|
permissionValidator("unifi.site.wifi.read.passphrase", [
|
||||||
|
"unifi.site.wifi.*",
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, test, expect, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
// Mock the user controller's hasPermission
|
||||||
|
const mockHasPermission = mock(() => Promise.resolve(true));
|
||||||
|
|
||||||
|
const mockUserController = {
|
||||||
|
hasPermission: mockHasPermission,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("processObjectValuePerms", () => {
|
||||||
|
// Import after mock setup
|
||||||
|
const { processObjectValuePerms, processObjectPermMap } =
|
||||||
|
require("../../src/modules/permission-utils/processObjectPermissions") as typeof import("../../src/modules/permission-utils/processObjectPermissions");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockHasPermission.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns only fields user has permission for", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
mockHasPermission.mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
// Allow field "name" but deny "secret"
|
||||||
|
return Promise.resolve(callCount === 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const obj = { name: "Test", secret: "hidden" };
|
||||||
|
const result = await processObjectValuePerms(
|
||||||
|
obj,
|
||||||
|
"scope",
|
||||||
|
mockUserController as any,
|
||||||
|
);
|
||||||
|
// First call: scope.name → true, second: scope.secret → false
|
||||||
|
expect(result.name).toBe("Test");
|
||||||
|
expect(result.secret).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty object when user has no permissions", async () => {
|
||||||
|
mockHasPermission.mockResolvedValue(false);
|
||||||
|
const obj = { a: 1, b: 2, c: 3 };
|
||||||
|
const result = await processObjectValuePerms(
|
||||||
|
obj,
|
||||||
|
"test",
|
||||||
|
mockUserController as any,
|
||||||
|
);
|
||||||
|
expect(Object.keys(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns full object when user has all permissions", async () => {
|
||||||
|
mockHasPermission.mockResolvedValue(true);
|
||||||
|
const obj = { x: "hello", y: 42 };
|
||||||
|
const result = await processObjectValuePerms(
|
||||||
|
obj,
|
||||||
|
"test",
|
||||||
|
mockUserController as any,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ x: "hello", y: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checks permission with correct scope.key pattern", async () => {
|
||||||
|
mockHasPermission.mockResolvedValue(true);
|
||||||
|
const obj = { fieldA: 1 };
|
||||||
|
await processObjectValuePerms(obj, "myScope", mockUserController as any);
|
||||||
|
expect(mockHasPermission).toHaveBeenCalledWith("myScope.fieldA");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processObjectPermMap", () => {
|
||||||
|
const { processObjectPermMap } =
|
||||||
|
require("../../src/modules/permission-utils/processObjectPermissions") as typeof import("../../src/modules/permission-utils/processObjectPermissions");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockHasPermission.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns boolean map for each key", async () => {
|
||||||
|
let idx = 0;
|
||||||
|
mockHasPermission.mockImplementation(() => {
|
||||||
|
idx++;
|
||||||
|
return Promise.resolve(idx % 2 === 1); // true, false, true, ...
|
||||||
|
});
|
||||||
|
|
||||||
|
const obj = { a: "x", b: "y", c: "z" };
|
||||||
|
const result = await processObjectPermMap(
|
||||||
|
obj,
|
||||||
|
"scope",
|
||||||
|
mockUserController as any,
|
||||||
|
);
|
||||||
|
expect(result.a).toBe(true);
|
||||||
|
expect(result.b).toBe(false);
|
||||||
|
expect(result.c).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all true when user has all permissions", async () => {
|
||||||
|
mockHasPermission.mockResolvedValue(true);
|
||||||
|
const obj = { foo: 1, bar: 2 };
|
||||||
|
const result = await processObjectPermMap(
|
||||||
|
obj,
|
||||||
|
"s",
|
||||||
|
mockUserController as any,
|
||||||
|
);
|
||||||
|
expect(result.foo).toBe(true);
|
||||||
|
expect(result.bar).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all false when user has no permissions", async () => {
|
||||||
|
mockHasPermission.mockResolvedValue(false);
|
||||||
|
const obj = { foo: 1, bar: 2 };
|
||||||
|
const result = await processObjectPermMap(
|
||||||
|
obj,
|
||||||
|
"s",
|
||||||
|
mockUserController as any,
|
||||||
|
);
|
||||||
|
expect(result.foo).toBe(false);
|
||||||
|
expect(result.bar).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the router aggregation pattern used throughout the app.
|
||||||
|
* Each router imports route modules and mounts them.
|
||||||
|
*/
|
||||||
|
describe("Router pattern", () => {
|
||||||
|
test("mounting multiple routes via Object.values pattern", () => {
|
||||||
|
// Simulate the pattern used in companyRouter.ts
|
||||||
|
const route1 = new Hono().get("/items", (c) => c.json({ route: "items" }));
|
||||||
|
const route2 = new Hono().get("/count", (c) => c.json({ route: "count" }));
|
||||||
|
|
||||||
|
const routes = { route1, route2 };
|
||||||
|
const router = new Hono();
|
||||||
|
Object.values(routes).map((r) => router.route("/", r));
|
||||||
|
|
||||||
|
// Mount router under a prefix
|
||||||
|
const app = new Hono();
|
||||||
|
app.route("/v1/resource", router);
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
(async () => {
|
||||||
|
const res = await app.request("/v1/resource/items");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.route).toBe("items");
|
||||||
|
})(),
|
||||||
|
(async () => {
|
||||||
|
const res = await app.request("/v1/resource/count");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.route).toBe("count");
|
||||||
|
})(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nested route parameters work correctly", async () => {
|
||||||
|
const route = new Hono().get("/items/:id", (c) =>
|
||||||
|
c.json({ id: c.req.param("id") }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const router = new Hono();
|
||||||
|
router.route("/", route);
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
app.route("/v1/test", router);
|
||||||
|
|
||||||
|
const res = await app.request("/v1/test/items/abc-123");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.id).toBe("abc-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("error propagation through router chain", async () => {
|
||||||
|
const route = new Hono().get("/fail", () => {
|
||||||
|
throw new Error("Boom!");
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = new Hono();
|
||||||
|
router.route("/", route);
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
app.onError((err, c) => c.json({ error: err.message, caught: true }, 500));
|
||||||
|
app.route("/v1", router);
|
||||||
|
|
||||||
|
const res = await app.request("/v1/fail");
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body: any = await res.json();
|
||||||
|
expect(body.caught).toBe(true);
|
||||||
|
expect(body.error).toBe("Boom!");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import teapot from "../../src/api/teapot";
|
||||||
|
|
||||||
|
describe("Teapot route handler", () => {
|
||||||
|
test("GET / returns 418 status", async () => {
|
||||||
|
const res = await teapot.request("/");
|
||||||
|
expect(res.status).toBe(418);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("response body has correct shape", async () => {
|
||||||
|
const res = await teapot.request("/");
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({
|
||||||
|
status: 418,
|
||||||
|
message: "I'm not a teapot",
|
||||||
|
successful: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns JSON content type", async () => {
|
||||||
|
const res = await teapot.request("/");
|
||||||
|
const ct = res.headers.get("content-type");
|
||||||
|
expect(ct).toContain("application/json");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the HonoTypes and PermissionTypes type exports.
|
||||||
|
* These are mainly compile-time checks that ensure the types exist
|
||||||
|
* and can be used.
|
||||||
|
*/
|
||||||
|
import type { Variables } from "../../src/types/HonoTypes";
|
||||||
|
import type {
|
||||||
|
PermissionIssuers,
|
||||||
|
DecodedPermissionsBlock,
|
||||||
|
} from "../../src/types/PermissionTypes";
|
||||||
|
|
||||||
|
describe("HonoTypes", () => {
|
||||||
|
test("Variables type is usable", () => {
|
||||||
|
// If this compiles and runs, the type exists
|
||||||
|
const vars: Variables = { user: {} as any };
|
||||||
|
expect(vars).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PermissionTypes", () => {
|
||||||
|
test("PermissionIssuers type accepts valid values", () => {
|
||||||
|
const issuers: PermissionIssuers[] = ["roles", "user", "api_key"];
|
||||||
|
expect(issuers).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DecodedPermissionsBlock shape is correct", () => {
|
||||||
|
const block: DecodedPermissionsBlock = {
|
||||||
|
permissions: ["admin.*"],
|
||||||
|
iat: 1234567890,
|
||||||
|
iss: "roles",
|
||||||
|
sub: "role-1",
|
||||||
|
};
|
||||||
|
expect(block.permissions).toEqual(["admin.*"]);
|
||||||
|
expect(block.iss).toBe("roles");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user