Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe71248e88 | |||
| 7411310083 | |||
| 30b408e0db | |||
| d7b374f8ab | |||
| 883b648d5e | |||
| b787120461 | |||
| 1326725995 | |||
| 508fa39835 | |||
| b1f6462ac3 | |||
| 51eb36f4a6 | |||
| 827b018f25 | |||
| 5852bd7819 | |||
| 4c21245044 | |||
| ce456257ea | |||
| 8949819396 |
@@ -24,9 +24,10 @@ Keep each layer focused:
|
||||
|
||||
## Runtime / tooling
|
||||
|
||||
The project runs on **Bun**. DB tooling uses **Prisma**; the generated client lives under `generated/prisma` (do NOT edit generated files). Key scripts in `package.json`:
|
||||
The project runs on **Bun** exclusively — **always use `bun` commands, never `npm`, `npx`, or `yarn`**. DB tooling uses **Prisma**; the generated client lives under `generated/prisma` (do NOT edit generated files). Test preloads are configured in `bunfig.toml` so bare `bun test` works. Key scripts in `package.json`:
|
||||
|
||||
- `dev` — `NODE_ENV=development bun --watch src/index.ts` (start dev server with hot reload)
|
||||
- `test` — `bun test` (runs all tests with preload from `bunfig.toml`)
|
||||
- `db:gen` — `prisma generate`
|
||||
- `db:push` — `prisma migrate dev --skip-generate`
|
||||
- `utils:dev` — `docker compose -f .docker/docker-compose.yml up --build`
|
||||
@@ -36,7 +37,7 @@ The project runs on **Bun**. DB tooling uses **Prisma**; the generated client li
|
||||
|
||||
## Data layer
|
||||
|
||||
Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` for browser type contexts). The shared `prisma` instance is exported from `src/constants.ts`. Always run `npm run db:gen` after updating `schema.prisma`.
|
||||
Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` for browser type contexts). The shared `prisma` instance is exported from `src/constants.ts`. Always run `bun run db:gen` after updating `schema.prisma`.
|
||||
|
||||
## Shared constants (`src/constants.ts`)
|
||||
|
||||
@@ -174,13 +175,14 @@ The `UnifiClient` class in `src/modules/unifi-api/UnifiClient.ts` wraps all UniF
|
||||
|
||||
## Local dev / quick checks
|
||||
|
||||
- Start dev server: `npm run dev`
|
||||
- Regenerate Prisma client: `npm run db:gen`
|
||||
- Apply DB migrations locally: `npm run db:push`
|
||||
- Docker dev utilities: `npm run utils:dev`
|
||||
- Generate private keys: `npm run utils:gen_private_keys`
|
||||
- Create admin role: `npm run utils:create_admin_role`
|
||||
- Assign user role: `npm run utils:assign_user_role`
|
||||
- Start dev server: `bun run dev`
|
||||
- Run tests: `bun test`
|
||||
- Regenerate Prisma client: `bun run db:gen`
|
||||
- Apply DB migrations locally: `bun run db:push`
|
||||
- Docker dev utilities: `bun run utils:dev`
|
||||
- Generate private keys: `bun run utils:gen_private_keys`
|
||||
- Create admin role: `bun run utils:create_admin_role`
|
||||
- Assign user role: `bun run utils:assign_user_role`
|
||||
|
||||
## When editing generated or infra files
|
||||
|
||||
|
||||
@@ -5,8 +5,28 @@ on:
|
||||
types: [created]
|
||||
|
||||
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:
|
||||
name: Build
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
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
|
||||
}
|
||||
}
|
||||
+1487
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -67,4 +67,7 @@ RUN bun install --frozen-lockfile
|
||||
COPY prisma/ prisma/
|
||||
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"]
|
||||
@@ -0,0 +1,148 @@
|
||||
setInternalReview - The quote is ready to be review before it is ready to be sent.
|
||||
setInternalApproved - The quote has been approved and is ready to be sent out.
|
||||
setQuoteSent - The Quote has been sent to the customer.
|
||||
setQuoteConfirmed - The quote has been recieved by the customer.
|
||||
setRevisionNeeded - The quote needs to be revised and is set to stage revision
|
||||
setFinalized - This locks any non-admins from modifying the quote saying that is the final iteration of the quote.
|
||||
convert - This converts the quote to a ticket. It will also update all the necessary fields.
|
||||
|
||||
addTime(activityId, user: string)
|
||||
|
||||
fetchProducts
|
||||
updateProduct
|
||||
addProduct
|
||||
|
||||
fetchNotes
|
||||
addNotes(note: string, user: string)
|
||||
|
||||
# Cat/SubCat/Bucket
|
||||
|
||||
## Ecosystems vs Categories
|
||||
|
||||
## Ecosystem Tree
|
||||
|
||||
- Networking
|
||||
- Manufacturer: Ubiquiti
|
||||
- Category: Technology
|
||||
- Subcategory: Network-\*
|
||||
- Manufacturer: TP-Link
|
||||
- Category: Technology
|
||||
- Subcategory: Network-\*
|
||||
- Video Surveillance
|
||||
- Manufacturer: Uniview
|
||||
- Category: Field
|
||||
- Subcategory: Surveillance-\*
|
||||
- Manufacturer: Hikvision
|
||||
- Category: Field
|
||||
- Subcategory: Surveillance-\*
|
||||
- Manufacturer: Alarm.com
|
||||
- Category: Field
|
||||
- Subcategory: Surveillance-\*
|
||||
- Burg/Alarm
|
||||
- Manufacturer: Qolsys
|
||||
- Category: Field
|
||||
- Subcategory: AlarmBurg-\*
|
||||
- DSC
|
||||
- Category: Field
|
||||
- Subcategory: AlarmBurg-\*
|
||||
|
||||
## Category Tree
|
||||
|
||||
- Technology
|
||||
- GeneralEquip
|
||||
- Home Entertainment
|
||||
- Monitor
|
||||
- Printers
|
||||
- Storage
|
||||
- Network
|
||||
- Network-Other
|
||||
- Network-Router
|
||||
- Network-Switch
|
||||
- Network-Wireless
|
||||
- Computer
|
||||
- Computer-Components
|
||||
- Computer-Desktop
|
||||
- Computer-Laptop
|
||||
- Recurring
|
||||
- Recurring - Online
|
||||
- Recurring - Other
|
||||
- Recurring - Protection
|
||||
- Recurring - Telephone
|
||||
- Telephone
|
||||
- Tele-HSet-Digital
|
||||
- Tele-HSet-IP
|
||||
- Tele-HSet-SLT
|
||||
- Tele-Misc
|
||||
- Tele-Paging
|
||||
- Tele-SystemCards
|
||||
- Tele-Systems
|
||||
- General
|
||||
- Batteries
|
||||
- Battery Backups
|
||||
- BulkWire
|
||||
- Cables
|
||||
- Cables-Adapters
|
||||
- Cables-HDMI
|
||||
- Cables-Network
|
||||
- Cables-Other
|
||||
- Cables-USB
|
||||
- Cables-VGA
|
||||
- Elec Cords & Adapters
|
||||
- Enclosures
|
||||
- PowerSupply
|
||||
- RackEquip
|
||||
- RackEquip-Rack
|
||||
- RackEquip-Shelves
|
||||
- Field
|
||||
- Conduit
|
||||
- Electric
|
||||
- GateControl
|
||||
- Locksets
|
||||
- Other
|
||||
- Relays
|
||||
- AccessControl
|
||||
- AccessControl-Controllers
|
||||
- AccessControl-Credential
|
||||
- AccessControl-LockDevices
|
||||
- AccessControl-Other
|
||||
- AccessControl-Readers
|
||||
- AccessControl-VideoEntry
|
||||
- AlarmBurg
|
||||
- AlarmBurg-Communicators
|
||||
- AlarmBurg-Keypads
|
||||
- AlarmBurg-Modules
|
||||
- AlarmBurg-Other
|
||||
- AlarmBurg-Panels
|
||||
- AlarmBurg-Sensors
|
||||
- AlarmBurg-Sensors-Wireless
|
||||
- AlarmBurg-Sensors-Wired
|
||||
- AlarmBurg-Siren
|
||||
- AlarmFire
|
||||
- AlarmFire-Communicators
|
||||
- AlarmFire-Devices
|
||||
- AlarmFire-Modules
|
||||
- AlarmFire-Other
|
||||
- AlarmFire-Panels
|
||||
- AlarmFire-Sensors
|
||||
- Automation
|
||||
- Automation-General
|
||||
- Automation-HVAC
|
||||
- Automation-Lights
|
||||
- Automation-Locks
|
||||
- Automation-Thermostat
|
||||
- AV
|
||||
- AV-Adapters&Cables
|
||||
- AV-Components
|
||||
- AV-Mounts
|
||||
- AV-Other
|
||||
- AV-Speakers
|
||||
- AV-Television
|
||||
- StrCbl?
|
||||
- StrCbl-Jacks
|
||||
- StrCbl-PatchPanel
|
||||
- StrCbl-Plates
|
||||
- Surveillance
|
||||
- Surveillance-Accs
|
||||
- Surveillance-CamerasAnalog
|
||||
- Surveillance-CamerasIP
|
||||
- Surveillance-NVR
|
||||
+222
@@ -115,6 +115,57 @@ 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.
|
||||
- **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, count, categories/ecosystems, or filter values | [src/api/procurement/fetchAll.ts](src/api/procurement/fetchAll.ts), [src/api/procurement/count.ts](src/api/procurement/count.ts), [src/api/procurement/categories.ts](src/api/procurement/categories.ts), [src/api/procurement/filters.ts](src/api/procurement/filters.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 (products, 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 (products, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/products.ts](src/api/sales/[id]/products.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/fetchNote.ts](src/api/sales/[id]/fetchNote.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | |
|
||||
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.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` |
|
||||
| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/[id]/createNote.ts](src/api/sales/[id]/createNote.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/[id]/updateNote.ts](src/api/sales/[id]/updateNote.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts) | `sales.opportunity.fetch` |
|
||||
| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.<field>` permissions. | [src/api/sales/[id]/addProduct.ts](src/api/sales/[id]/addProduct.ts) | `sales.opportunity.fetch` |
|
||||
|
||||
<details>
|
||||
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
|
||||
|
||||
Each submitted field is gated by a `sales.opportunity.product.field.<field>` permission node. Only fields the user has permission for are forwarded to ConnectWise.
|
||||
|
||||
| Field Permission Node | Description |
|
||||
| ----------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `sales.opportunity.product.field.catalogItem` | Set the catalog item reference |
|
||||
| `sales.opportunity.product.field.forecastDescription` | Set the forecast description |
|
||||
| `sales.opportunity.product.field.productDescription` | Set the product description |
|
||||
| `sales.opportunity.product.field.quantity` | Set the quantity |
|
||||
| `sales.opportunity.product.field.status` | Set the status reference |
|
||||
| `sales.opportunity.product.field.productClass` | Set the product class (e.g. Product, Service, Agreement) |
|
||||
| `sales.opportunity.product.field.forecastType` | Set the forecast type |
|
||||
| `sales.opportunity.product.field.revenue` | Set the revenue amount |
|
||||
| `sales.opportunity.product.field.cost` | Set the cost amount |
|
||||
| `sales.opportunity.product.field.includeFlag` | Set the include flag |
|
||||
| `sales.opportunity.product.field.linkFlag` | Set the link flag |
|
||||
| `sales.opportunity.product.field.recurringFlag` | Set the recurring flag |
|
||||
| `sales.opportunity.product.field.taxableFlag` | Set the taxable flag |
|
||||
| `sales.opportunity.product.field.recurringRevenue` | Set the recurring revenue amount |
|
||||
| `sales.opportunity.product.field.recurringCost` | Set the recurring cost amount |
|
||||
| `sales.opportunity.product.field.cycles` | Set the number of recurring cycles |
|
||||
| `sales.opportunity.product.field.sequenceNumber` | Set the sequence number (display order) |
|
||||
|
||||
</details>
|
||||
|
||||
### UniFi Permissions
|
||||
|
||||
Permissions for accessing and managing UniFi network infrastructure. The `unifi.access` permission is a gate permission required for **all** UniFi routes.
|
||||
@@ -152,6 +203,177 @@ The WiFi fetch route uses `processObjectValuePerms` to filter each WLAN object o
|
||||
| `unifi.site.wifi.ppsk` | View private pre-shared keys (PPSKs) for a specific WiFi network | [src/api/unifi/site/wifi/ppskFetchAll.ts](src/api/unifi/site/wifi/ppskFetchAll.ts), [src/api/unifi/site/wifi/ppskCreate.ts](src/api/unifi/site/wifi/ppskCreate.ts) | `unifi.access`, `unifi.site.wifi` |
|
||||
| `unifi.site.wifi.ppsk.create` | Create a private pre-shared key on a specific WiFi network | [src/api/unifi/site/wifi/ppskCreate.ts](src/api/unifi/site/wifi/ppskCreate.ts) | `unifi.access`, `unifi.site.wifi`, `unifi.site.wifi.ppsk` |
|
||||
|
||||
---
|
||||
|
||||
## Object Type Permissions (Field-Level Gating)
|
||||
|
||||
All fetch and fetchAll routes gate response object keys using `processObjectValuePerms`. For each object type, only fields whose corresponding `<scope>.<field>` permission the user holds are included in the response. Grant `<scope>.*` to allow all fields on that object type.
|
||||
|
||||
### Company (`obj.company`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| --------------------------- | ----------------------------------------- |
|
||||
| `obj.company.id` | View company ID |
|
||||
| `obj.company.name` | View company name |
|
||||
| `obj.company.cw_Identifier` | View ConnectWise identifier |
|
||||
| `obj.company.cw_CompanyId` | View ConnectWise company ID |
|
||||
| `obj.company.cw_Data` | View ConnectWise data (address, contacts) |
|
||||
| `obj.company.createdAt` | View creation timestamp |
|
||||
| `obj.company.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/companies/[id]/fetch.ts](src/api/companies/[id]/fetch.ts), [src/api/companies/fetchAll.ts](src/api/companies/fetchAll.ts)
|
||||
|
||||
### Credential (`obj.credential`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ---------------------------------- | ----------------------------- |
|
||||
| `obj.credential.id` | View credential ID |
|
||||
| `obj.credential.name` | View credential name |
|
||||
| `obj.credential.notes` | View credential notes |
|
||||
| `obj.credential.typeId` | View credential type ID |
|
||||
| `obj.credential.companyId` | View linked company ID |
|
||||
| `obj.credential.subCredentialOfId` | View parent credential ID |
|
||||
| `obj.credential.fields` | View credential field values |
|
||||
| `obj.credential.type` | View credential type object |
|
||||
| `obj.credential.company` | View linked company object |
|
||||
| `obj.credential.subCredentials` | View sub-credentials array |
|
||||
| `obj.credential.secureFieldIds` | View secure field identifiers |
|
||||
| `obj.credential.createdAt` | View creation timestamp |
|
||||
| `obj.credential.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/credentials/fetch.ts](src/api/credentials/fetch.ts), [src/api/credentials/fetchByCompany.ts](src/api/credentials/fetchByCompany.ts), [src/api/credentials/fetchSubCredentials.ts](src/api/credentials/fetchSubCredentials.ts), [src/api/credential-types/fetchCredentials.ts](src/api/credential-types/fetchCredentials.ts)
|
||||
|
||||
### Credential Type (`obj.credentialType`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ------------------------------------ | ----------------------------------------- |
|
||||
| `obj.credentialType.id` | View credential type ID |
|
||||
| `obj.credentialType.name` | View credential type name |
|
||||
| `obj.credentialType.permissionScope` | View permission scope |
|
||||
| `obj.credentialType.icon` | View icon |
|
||||
| `obj.credentialType.fields` | View field definitions |
|
||||
| `obj.credentialType.credentialCount` | View count of credentials using this type |
|
||||
| `obj.credentialType.createdAt` | View creation timestamp |
|
||||
| `obj.credentialType.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/credential-types/fetch.ts](src/api/credential-types/fetch.ts), [src/api/credential-types/fetchAll.ts](src/api/credential-types/fetchAll.ts)
|
||||
|
||||
### User (`obj.user`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ---------------------- | -------------------------------- |
|
||||
| `obj.user.id` | View user ID |
|
||||
| `obj.user.name` | View user display name |
|
||||
| `obj.user.roles` | View assigned role monikers |
|
||||
| `obj.user.permissions` | View aggregated permission nodes |
|
||||
| `obj.user.login` | View login identifier |
|
||||
| `obj.user.email` | View email address |
|
||||
| `obj.user.image` | View profile image URL |
|
||||
| `obj.user.createdAt` | View creation timestamp |
|
||||
| `obj.user.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/user/@me/fetch.ts](src/api/user/@me/fetch.ts), [src/api/user/fetch.ts](src/api/user/fetch.ts), [src/api/user/fetchAll.ts](src/api/user/fetchAll.ts), [src/api/roles/getUsers.ts](src/api/roles/getUsers.ts)
|
||||
|
||||
### Role (`obj.role`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ---------------------- | -------------------------------- |
|
||||
| `obj.role.id` | View role ID |
|
||||
| `obj.role.title` | View role title |
|
||||
| `obj.role.moniker` | View role moniker |
|
||||
| `obj.role.permissions` | View role permission nodes |
|
||||
| `obj.role.users` | View users assigned to this role |
|
||||
| `obj.role.createdAt` | View creation timestamp |
|
||||
| `obj.role.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/roles/fetch.ts](src/api/roles/fetch.ts), [src/api/roles/fetchAll.ts](src/api/roles/fetchAll.ts), [src/api/user/fetchRoles.ts](src/api/user/fetchRoles.ts)
|
||||
|
||||
### Catalog Item (`obj.catalogItem`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ------------------------------------- | -------------------------------- |
|
||||
| `obj.catalogItem.id` | View catalog item ID |
|
||||
| `obj.catalogItem.cwCatalogId` | View ConnectWise catalog ID |
|
||||
| `obj.catalogItem.identifier` | View item identifier |
|
||||
| `obj.catalogItem.name` | View item name |
|
||||
| `obj.catalogItem.description` | View description |
|
||||
| `obj.catalogItem.customerDescription` | View customer-facing description |
|
||||
| `obj.catalogItem.internalNotes` | View internal notes |
|
||||
| `obj.catalogItem.manufacturer` | View manufacturer name |
|
||||
| `obj.catalogItem.manufactureCwId` | View manufacturer ConnectWise ID |
|
||||
| `obj.catalogItem.partNumber` | View part number |
|
||||
| `obj.catalogItem.vendorName` | View vendor name |
|
||||
| `obj.catalogItem.vendorSku` | View vendor SKU |
|
||||
| `obj.catalogItem.vendorCwId` | View vendor ConnectWise ID |
|
||||
| `obj.catalogItem.price` | View price |
|
||||
| `obj.catalogItem.cost` | View cost |
|
||||
| `obj.catalogItem.inactive` | View inactive flag |
|
||||
| `obj.catalogItem.salesTaxable` | View sales-taxable flag |
|
||||
| `obj.catalogItem.onHand` | View on-hand inventory count |
|
||||
| `obj.catalogItem.cwLastUpdated` | View CW last-updated timestamp |
|
||||
| `obj.catalogItem.linkedItems` | View linked catalog items |
|
||||
| `obj.catalogItem.createdAt` | View creation timestamp |
|
||||
| `obj.catalogItem.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/procurement/fetchAll.ts](src/api/procurement/fetchAll.ts), [src/api/procurement/[id]/fetch.ts](src/api/procurement/[id]/fetch.ts), [src/api/procurement/[id]/fetchLinked.ts](src/api/procurement/[id]/fetchLinked.ts)
|
||||
|
||||
### Opportunity (`obj.opportunity`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ------------------------------------ | ------------------------------- |
|
||||
| `obj.opportunity.id` | View opportunity ID |
|
||||
| `obj.opportunity.cwOpportunityId` | View ConnectWise opportunity ID |
|
||||
| `obj.opportunity.name` | View opportunity name |
|
||||
| `obj.opportunity.notes` | View notes |
|
||||
| `obj.opportunity.type` | View opportunity type |
|
||||
| `obj.opportunity.stage` | View stage |
|
||||
| `obj.opportunity.status` | View status |
|
||||
| `obj.opportunity.priority` | View priority |
|
||||
| `obj.opportunity.rating` | View rating |
|
||||
| `obj.opportunity.source` | View source |
|
||||
| `obj.opportunity.campaign` | View campaign |
|
||||
| `obj.opportunity.primarySalesRep` | View primary sales rep |
|
||||
| `obj.opportunity.secondarySalesRep` | View secondary sales rep |
|
||||
| `obj.opportunity.company` | View company |
|
||||
| `obj.opportunity.contact` | View contact |
|
||||
| `obj.opportunity.site` | View site |
|
||||
| `obj.opportunity.customerPO` | View customer PO |
|
||||
| `obj.opportunity.totalSalesTax` | View total sales tax |
|
||||
| `obj.opportunity.location` | View location |
|
||||
| `obj.opportunity.department` | View department |
|
||||
| `obj.opportunity.expectedCloseDate` | View expected close date |
|
||||
| `obj.opportunity.pipelineChangeDate` | View pipeline change date |
|
||||
| `obj.opportunity.dateBecameLead` | View date became lead |
|
||||
| `obj.opportunity.closedDate` | View closed date |
|
||||
| `obj.opportunity.closedFlag` | View closed flag |
|
||||
| `obj.opportunity.closedBy` | View closed-by member |
|
||||
| `obj.opportunity.companyId` | View linked company ID |
|
||||
| `obj.opportunity.cwLastUpdated` | View CW last-updated timestamp |
|
||||
| `obj.opportunity.createdAt` | View creation timestamp |
|
||||
| `obj.opportunity.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts)
|
||||
|
||||
### UniFi Site (`obj.unifiSite`)
|
||||
|
||||
| Field Permission | Description |
|
||||
| ------------------------- | ----------------------------- |
|
||||
| `obj.unifiSite.id` | View site internal ID |
|
||||
| `obj.unifiSite.name` | View site name |
|
||||
| `obj.unifiSite.siteId` | View UniFi controller site ID |
|
||||
| `obj.unifiSite.companyId` | View linked company ID |
|
||||
| `obj.unifiSite.company` | View linked company object |
|
||||
| `obj.unifiSite.createdAt` | View creation timestamp |
|
||||
| `obj.unifiSite.updatedAt` | View last-updated timestamp |
|
||||
|
||||
**Used in:** [src/api/unifi/sites/fetchAll.ts](src/api/unifi/sites/fetchAll.ts), [src/api/unifi/site/fetch.ts](src/api/unifi/site/fetch.ts), [src/api/companies/[id]/unifiSites.ts](src/api/companies/[id]/unifiSites.ts)
|
||||
|
||||
### WiFi Network (`unifi.site.wifi.read`)
|
||||
|
||||
See **UniFi Permissions > Field-Level Permission Gating** above for the full list of `unifi.site.wifi.read.<field>` nodes.
|
||||
|
||||
---
|
||||
|
||||
## Permission Issuers
|
||||
|
||||
Permissions can be issued by different sources:
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"cuid": "^3.0.0",
|
||||
"hono": "^4.11.5",
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"keypair": "^1.0.4",
|
||||
"prisma": "^7.3.0",
|
||||
@@ -57,6 +58,8 @@
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
|
||||
|
||||
"@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="],
|
||||
|
||||
"@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="],
|
||||
|
||||
"@prisma/adapter-pg": ["@prisma/adapter-pg@7.3.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.3.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-iuYQMbIPO6i9O45Fv8TB7vWu00BXhCaNAShenqF7gLExGDbnGp5BfFB4yz1K59zQ59jF6tQ9YHrg0P6/J3OoLg=="],
|
||||
@@ -131,6 +134,8 @@
|
||||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
|
||||
@@ -223,6 +228,8 @@
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="],
|
||||
|
||||
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
@@ -241,8 +248,12 @@
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
|
||||
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
|
||||
|
||||
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||
|
||||
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
|
||||
@@ -331,6 +342,10 @@
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||
|
||||
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
|
||||
|
||||
"regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="],
|
||||
|
||||
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="],
|
||||
@@ -363,6 +378,8 @@
|
||||
|
||||
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./tests/setup.ts"]
|
||||
@@ -47,6 +47,11 @@ export type Company = Prisma.CompanyModel
|
||||
*
|
||||
*/
|
||||
export type CatalogItem = Prisma.CatalogItemModel
|
||||
/**
|
||||
* Model Opportunity
|
||||
*
|
||||
*/
|
||||
export type Opportunity = Prisma.OpportunityModel
|
||||
/**
|
||||
* Model CredentialType
|
||||
*
|
||||
|
||||
@@ -69,6 +69,11 @@ export type Company = Prisma.CompanyModel
|
||||
*
|
||||
*/
|
||||
export type CatalogItem = Prisma.CatalogItemModel
|
||||
/**
|
||||
* Model Opportunity
|
||||
*
|
||||
*/
|
||||
export type Opportunity = Prisma.OpportunityModel
|
||||
/**
|
||||
* Model CredentialType
|
||||
*
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -390,6 +390,7 @@ export const ModelName = {
|
||||
UnifiSite: 'UnifiSite',
|
||||
Company: 'Company',
|
||||
CatalogItem: 'CatalogItem',
|
||||
Opportunity: 'Opportunity',
|
||||
CredentialType: 'CredentialType',
|
||||
SecureValue: 'SecureValue',
|
||||
Credential: 'Credential'
|
||||
@@ -408,7 +409,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "credentialType" | "secureValue" | "credential"
|
||||
modelProps: "session" | "user" | "role" | "unifiSite" | "company" | "catalogItem" | "opportunity" | "credentialType" | "secureValue" | "credential"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
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: {
|
||||
payload: Prisma.$CredentialTypePayload<ExtArgs>
|
||||
fields: Prisma.CredentialTypeFieldRefs
|
||||
@@ -1138,6 +1213,7 @@ export const UserScalarFieldEnum = {
|
||||
email: 'email',
|
||||
emailVerified: 'emailVerified',
|
||||
image: 'image',
|
||||
cwIdentifier: 'cwIdentifier',
|
||||
userId: 'userId',
|
||||
token: 'token',
|
||||
createdAt: 'createdAt',
|
||||
@@ -1186,10 +1262,15 @@ export type CompanyScalarFieldEnum = (typeof CompanyScalarFieldEnum)[keyof typeo
|
||||
export const CatalogItemScalarFieldEnum = {
|
||||
id: 'id',
|
||||
cwCatalogId: 'cwCatalogId',
|
||||
identifier: 'identifier',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
customerDescription: 'customerDescription',
|
||||
internalNotes: 'internalNotes',
|
||||
category: 'category',
|
||||
categoryCwId: 'categoryCwId',
|
||||
subcategory: 'subcategory',
|
||||
subcategoryCwId: 'subcategoryCwId',
|
||||
manufacturer: 'manufacturer',
|
||||
manufactureCwId: 'manufactureCwId',
|
||||
partNumber: 'partNumber',
|
||||
@@ -1209,6 +1290,59 @@ export const 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',
|
||||
productSequence: 'productSequence',
|
||||
cwLastUpdated: 'cwLastUpdated',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type OpportunityScalarFieldEnum = (typeof OpportunityScalarFieldEnum)[keyof typeof OpportunityScalarFieldEnum]
|
||||
|
||||
|
||||
export const CredentialTypeScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
@@ -1473,6 +1607,7 @@ export type GlobalOmitConfig = {
|
||||
unifiSite?: Prisma.UnifiSiteOmit
|
||||
company?: Prisma.CompanyOmit
|
||||
catalogItem?: Prisma.CatalogItemOmit
|
||||
opportunity?: Prisma.OpportunityOmit
|
||||
credentialType?: Prisma.CredentialTypeOmit
|
||||
secureValue?: Prisma.SecureValueOmit
|
||||
credential?: Prisma.CredentialOmit
|
||||
|
||||
@@ -57,6 +57,7 @@ export const ModelName = {
|
||||
UnifiSite: 'UnifiSite',
|
||||
Company: 'Company',
|
||||
CatalogItem: 'CatalogItem',
|
||||
Opportunity: 'Opportunity',
|
||||
CredentialType: 'CredentialType',
|
||||
SecureValue: 'SecureValue',
|
||||
Credential: 'Credential'
|
||||
@@ -99,6 +100,7 @@ export const UserScalarFieldEnum = {
|
||||
email: 'email',
|
||||
emailVerified: 'emailVerified',
|
||||
image: 'image',
|
||||
cwIdentifier: 'cwIdentifier',
|
||||
userId: 'userId',
|
||||
token: 'token',
|
||||
createdAt: 'createdAt',
|
||||
@@ -147,10 +149,15 @@ export type CompanyScalarFieldEnum = (typeof CompanyScalarFieldEnum)[keyof typeo
|
||||
export const CatalogItemScalarFieldEnum = {
|
||||
id: 'id',
|
||||
cwCatalogId: 'cwCatalogId',
|
||||
identifier: 'identifier',
|
||||
name: 'name',
|
||||
description: 'description',
|
||||
customerDescription: 'customerDescription',
|
||||
internalNotes: 'internalNotes',
|
||||
category: 'category',
|
||||
categoryCwId: 'categoryCwId',
|
||||
subcategory: 'subcategory',
|
||||
subcategoryCwId: 'subcategoryCwId',
|
||||
manufacturer: 'manufacturer',
|
||||
manufactureCwId: 'manufactureCwId',
|
||||
partNumber: 'partNumber',
|
||||
@@ -170,6 +177,59 @@ export const 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',
|
||||
productSequence: 'productSequence',
|
||||
cwLastUpdated: 'cwLastUpdated',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type OpportunityScalarFieldEnum = (typeof OpportunityScalarFieldEnum)[keyof typeof OpportunityScalarFieldEnum]
|
||||
|
||||
|
||||
export const CredentialTypeScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
|
||||
@@ -14,6 +14,7 @@ export type * from './models/Role.ts'
|
||||
export type * from './models/UnifiSite.ts'
|
||||
export type * from './models/Company.ts'
|
||||
export type * from './models/CatalogItem.ts'
|
||||
export type * from './models/Opportunity.ts'
|
||||
export type * from './models/CredentialType.ts'
|
||||
export type * from './models/SecureValue.ts'
|
||||
export type * from './models/Credential.ts'
|
||||
|
||||
@@ -28,6 +28,8 @@ export type AggregateCatalogItem = {
|
||||
|
||||
export type CatalogItemAvgAggregateOutputType = {
|
||||
cwCatalogId: number | null
|
||||
categoryCwId: number | null
|
||||
subcategoryCwId: number | null
|
||||
manufactureCwId: number | null
|
||||
vendorCwId: number | null
|
||||
price: number | null
|
||||
@@ -37,6 +39,8 @@ export type CatalogItemAvgAggregateOutputType = {
|
||||
|
||||
export type CatalogItemSumAggregateOutputType = {
|
||||
cwCatalogId: number | null
|
||||
categoryCwId: number | null
|
||||
subcategoryCwId: number | null
|
||||
manufactureCwId: number | null
|
||||
vendorCwId: number | null
|
||||
price: number | null
|
||||
@@ -47,10 +51,15 @@ export type CatalogItemSumAggregateOutputType = {
|
||||
export type CatalogItemMinAggregateOutputType = {
|
||||
id: string | null
|
||||
cwCatalogId: number | null
|
||||
identifier: string | null
|
||||
name: string | null
|
||||
description: string | null
|
||||
customerDescription: string | null
|
||||
internalNotes: string | null
|
||||
category: string | null
|
||||
categoryCwId: number | null
|
||||
subcategory: string | null
|
||||
subcategoryCwId: number | null
|
||||
manufacturer: string | null
|
||||
manufactureCwId: number | null
|
||||
partNumber: string | null
|
||||
@@ -70,10 +79,15 @@ export type CatalogItemMinAggregateOutputType = {
|
||||
export type CatalogItemMaxAggregateOutputType = {
|
||||
id: string | null
|
||||
cwCatalogId: number | null
|
||||
identifier: string | null
|
||||
name: string | null
|
||||
description: string | null
|
||||
customerDescription: string | null
|
||||
internalNotes: string | null
|
||||
category: string | null
|
||||
categoryCwId: number | null
|
||||
subcategory: string | null
|
||||
subcategoryCwId: number | null
|
||||
manufacturer: string | null
|
||||
manufactureCwId: number | null
|
||||
partNumber: string | null
|
||||
@@ -93,10 +107,15 @@ export type CatalogItemMaxAggregateOutputType = {
|
||||
export type CatalogItemCountAggregateOutputType = {
|
||||
id: number
|
||||
cwCatalogId: number
|
||||
identifier: number
|
||||
name: number
|
||||
description: number
|
||||
customerDescription: number
|
||||
internalNotes: number
|
||||
category: number
|
||||
categoryCwId: number
|
||||
subcategory: number
|
||||
subcategoryCwId: number
|
||||
manufacturer: number
|
||||
manufactureCwId: number
|
||||
partNumber: number
|
||||
@@ -117,6 +136,8 @@ export type CatalogItemCountAggregateOutputType = {
|
||||
|
||||
export type CatalogItemAvgAggregateInputType = {
|
||||
cwCatalogId?: true
|
||||
categoryCwId?: true
|
||||
subcategoryCwId?: true
|
||||
manufactureCwId?: true
|
||||
vendorCwId?: true
|
||||
price?: true
|
||||
@@ -126,6 +147,8 @@ export type CatalogItemAvgAggregateInputType = {
|
||||
|
||||
export type CatalogItemSumAggregateInputType = {
|
||||
cwCatalogId?: true
|
||||
categoryCwId?: true
|
||||
subcategoryCwId?: true
|
||||
manufactureCwId?: true
|
||||
vendorCwId?: true
|
||||
price?: true
|
||||
@@ -136,10 +159,15 @@ export type CatalogItemSumAggregateInputType = {
|
||||
export type CatalogItemMinAggregateInputType = {
|
||||
id?: true
|
||||
cwCatalogId?: true
|
||||
identifier?: true
|
||||
name?: true
|
||||
description?: true
|
||||
customerDescription?: true
|
||||
internalNotes?: true
|
||||
category?: true
|
||||
categoryCwId?: true
|
||||
subcategory?: true
|
||||
subcategoryCwId?: true
|
||||
manufacturer?: true
|
||||
manufactureCwId?: true
|
||||
partNumber?: true
|
||||
@@ -159,10 +187,15 @@ export type CatalogItemMinAggregateInputType = {
|
||||
export type CatalogItemMaxAggregateInputType = {
|
||||
id?: true
|
||||
cwCatalogId?: true
|
||||
identifier?: true
|
||||
name?: true
|
||||
description?: true
|
||||
customerDescription?: true
|
||||
internalNotes?: true
|
||||
category?: true
|
||||
categoryCwId?: true
|
||||
subcategory?: true
|
||||
subcategoryCwId?: true
|
||||
manufacturer?: true
|
||||
manufactureCwId?: true
|
||||
partNumber?: true
|
||||
@@ -182,10 +215,15 @@ export type CatalogItemMaxAggregateInputType = {
|
||||
export type CatalogItemCountAggregateInputType = {
|
||||
id?: true
|
||||
cwCatalogId?: true
|
||||
identifier?: true
|
||||
name?: true
|
||||
description?: true
|
||||
customerDescription?: true
|
||||
internalNotes?: true
|
||||
category?: true
|
||||
categoryCwId?: true
|
||||
subcategory?: true
|
||||
subcategoryCwId?: true
|
||||
manufacturer?: true
|
||||
manufactureCwId?: true
|
||||
partNumber?: true
|
||||
@@ -292,10 +330,15 @@ export type CatalogItemGroupByArgs<ExtArgs extends runtime.Types.Extensions.Inte
|
||||
export type CatalogItemGroupByOutputType = {
|
||||
id: string
|
||||
cwCatalogId: number
|
||||
identifier: string | null
|
||||
name: string
|
||||
description: string | null
|
||||
customerDescription: string | null
|
||||
internalNotes: string | null
|
||||
category: string | null
|
||||
categoryCwId: number | null
|
||||
subcategory: string | null
|
||||
subcategoryCwId: number | null
|
||||
manufacturer: string | null
|
||||
manufactureCwId: number | null
|
||||
partNumber: string | null
|
||||
@@ -338,10 +381,15 @@ export type CatalogItemWhereInput = {
|
||||
NOT?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[]
|
||||
id?: Prisma.StringFilter<"CatalogItem"> | string
|
||||
cwCatalogId?: Prisma.IntFilter<"CatalogItem"> | number
|
||||
identifier?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
name?: Prisma.StringFilter<"CatalogItem"> | string
|
||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
internalNotes?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
category?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
categoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
subcategory?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
subcategoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
manufacturer?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
@@ -363,10 +411,15 @@ export type CatalogItemWhereInput = {
|
||||
export type CatalogItemOrderByWithRelationInput = {
|
||||
id?: Prisma.SortOrder
|
||||
cwCatalogId?: Prisma.SortOrder
|
||||
identifier?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
name?: Prisma.SortOrder
|
||||
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
category?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
@@ -388,6 +441,7 @@ export type CatalogItemOrderByWithRelationInput = {
|
||||
export type CatalogItemWhereUniqueInput = Prisma.AtLeast<{
|
||||
id?: string
|
||||
cwCatalogId?: number
|
||||
identifier?: string
|
||||
AND?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[]
|
||||
OR?: Prisma.CatalogItemWhereInput[]
|
||||
NOT?: Prisma.CatalogItemWhereInput | Prisma.CatalogItemWhereInput[]
|
||||
@@ -395,6 +449,10 @@ export type CatalogItemWhereUniqueInput = Prisma.AtLeast<{
|
||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
internalNotes?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
category?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
categoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
subcategory?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
subcategoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
manufacturer?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
@@ -411,15 +469,20 @@ export type CatalogItemWhereUniqueInput = Prisma.AtLeast<{
|
||||
updatedAt?: Prisma.DateTimeFilter<"CatalogItem"> | Date | string
|
||||
linkedItems?: Prisma.CatalogItemListRelationFilter
|
||||
linkedTo?: Prisma.CatalogItemListRelationFilter
|
||||
}, "id" | "cwCatalogId">
|
||||
}, "id" | "cwCatalogId" | "identifier">
|
||||
|
||||
export type CatalogItemOrderByWithAggregationInput = {
|
||||
id?: Prisma.SortOrder
|
||||
cwCatalogId?: Prisma.SortOrder
|
||||
identifier?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
name?: Prisma.SortOrder
|
||||
description?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
category?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
@@ -447,10 +510,15 @@ export type CatalogItemScalarWhereWithAggregatesInput = {
|
||||
NOT?: Prisma.CatalogItemScalarWhereWithAggregatesInput | Prisma.CatalogItemScalarWhereWithAggregatesInput[]
|
||||
id?: Prisma.StringWithAggregatesFilter<"CatalogItem"> | string
|
||||
cwCatalogId?: Prisma.IntWithAggregatesFilter<"CatalogItem"> | number
|
||||
identifier?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
name?: Prisma.StringWithAggregatesFilter<"CatalogItem"> | string
|
||||
description?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
customerDescription?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
internalNotes?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
category?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
categoryCwId?: Prisma.IntNullableWithAggregatesFilter<"CatalogItem"> | number | null
|
||||
subcategory?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
subcategoryCwId?: Prisma.IntNullableWithAggregatesFilter<"CatalogItem"> | number | null
|
||||
manufacturer?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
manufactureCwId?: Prisma.IntNullableWithAggregatesFilter<"CatalogItem"> | number | null
|
||||
partNumber?: Prisma.StringNullableWithAggregatesFilter<"CatalogItem"> | string | null
|
||||
@@ -470,10 +538,15 @@ export type CatalogItemScalarWhereWithAggregatesInput = {
|
||||
export type CatalogItemCreateInput = {
|
||||
id?: string
|
||||
cwCatalogId: number
|
||||
identifier?: string | null
|
||||
name: string
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -495,10 +568,15 @@ export type CatalogItemCreateInput = {
|
||||
export type CatalogItemUncheckedCreateInput = {
|
||||
id?: string
|
||||
cwCatalogId: number
|
||||
identifier?: string | null
|
||||
name: string
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -520,10 +598,15 @@ export type CatalogItemUncheckedCreateInput = {
|
||||
export type CatalogItemUpdateInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -545,10 +628,15 @@ export type CatalogItemUpdateInput = {
|
||||
export type CatalogItemUncheckedUpdateInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -570,10 +658,15 @@ export type CatalogItemUncheckedUpdateInput = {
|
||||
export type CatalogItemCreateManyInput = {
|
||||
id?: string
|
||||
cwCatalogId: number
|
||||
identifier?: string | null
|
||||
name: string
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -593,10 +686,15 @@ export type CatalogItemCreateManyInput = {
|
||||
export type CatalogItemUpdateManyMutationInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -616,10 +714,15 @@ export type CatalogItemUpdateManyMutationInput = {
|
||||
export type CatalogItemUncheckedUpdateManyInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -649,10 +752,15 @@ export type CatalogItemOrderByRelationAggregateInput = {
|
||||
export type CatalogItemCountOrderByAggregateInput = {
|
||||
id?: Prisma.SortOrder
|
||||
cwCatalogId?: Prisma.SortOrder
|
||||
identifier?: Prisma.SortOrder
|
||||
name?: Prisma.SortOrder
|
||||
description?: Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrder
|
||||
category?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrder
|
||||
@@ -671,6 +779,8 @@ export type CatalogItemCountOrderByAggregateInput = {
|
||||
|
||||
export type CatalogItemAvgOrderByAggregateInput = {
|
||||
cwCatalogId?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
vendorCwId?: Prisma.SortOrder
|
||||
price?: Prisma.SortOrder
|
||||
@@ -681,10 +791,15 @@ export type CatalogItemAvgOrderByAggregateInput = {
|
||||
export type CatalogItemMaxOrderByAggregateInput = {
|
||||
id?: Prisma.SortOrder
|
||||
cwCatalogId?: Prisma.SortOrder
|
||||
identifier?: Prisma.SortOrder
|
||||
name?: Prisma.SortOrder
|
||||
description?: Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrder
|
||||
category?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrder
|
||||
@@ -704,10 +819,15 @@ export type CatalogItemMaxOrderByAggregateInput = {
|
||||
export type CatalogItemMinOrderByAggregateInput = {
|
||||
id?: Prisma.SortOrder
|
||||
cwCatalogId?: Prisma.SortOrder
|
||||
identifier?: Prisma.SortOrder
|
||||
name?: Prisma.SortOrder
|
||||
description?: Prisma.SortOrder
|
||||
customerDescription?: Prisma.SortOrder
|
||||
internalNotes?: Prisma.SortOrder
|
||||
category?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategory?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufacturer?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
partNumber?: Prisma.SortOrder
|
||||
@@ -726,6 +846,8 @@ export type CatalogItemMinOrderByAggregateInput = {
|
||||
|
||||
export type CatalogItemSumOrderByAggregateInput = {
|
||||
cwCatalogId?: Prisma.SortOrder
|
||||
categoryCwId?: Prisma.SortOrder
|
||||
subcategoryCwId?: Prisma.SortOrder
|
||||
manufactureCwId?: Prisma.SortOrder
|
||||
vendorCwId?: Prisma.SortOrder
|
||||
price?: Prisma.SortOrder
|
||||
@@ -828,10 +950,15 @@ export type CatalogItemUncheckedUpdateManyWithoutLinkedItemsNestedInput = {
|
||||
export type CatalogItemCreateWithoutLinkedToInput = {
|
||||
id?: string
|
||||
cwCatalogId: number
|
||||
identifier?: string | null
|
||||
name: string
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -852,10 +979,15 @@ export type CatalogItemCreateWithoutLinkedToInput = {
|
||||
export type CatalogItemUncheckedCreateWithoutLinkedToInput = {
|
||||
id?: string
|
||||
cwCatalogId: number
|
||||
identifier?: string | null
|
||||
name: string
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -881,10 +1013,15 @@ export type CatalogItemCreateOrConnectWithoutLinkedToInput = {
|
||||
export type CatalogItemCreateWithoutLinkedItemsInput = {
|
||||
id?: string
|
||||
cwCatalogId: number
|
||||
identifier?: string | null
|
||||
name: string
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -905,10 +1042,15 @@ export type CatalogItemCreateWithoutLinkedItemsInput = {
|
||||
export type CatalogItemUncheckedCreateWithoutLinkedItemsInput = {
|
||||
id?: string
|
||||
cwCatalogId: number
|
||||
identifier?: string | null
|
||||
name: string
|
||||
description?: string | null
|
||||
customerDescription?: string | null
|
||||
internalNotes?: string | null
|
||||
category?: string | null
|
||||
categoryCwId?: number | null
|
||||
subcategory?: string | null
|
||||
subcategoryCwId?: number | null
|
||||
manufacturer?: string | null
|
||||
manufactureCwId?: number | null
|
||||
partNumber?: string | null
|
||||
@@ -953,10 +1095,15 @@ export type CatalogItemScalarWhereInput = {
|
||||
NOT?: Prisma.CatalogItemScalarWhereInput | Prisma.CatalogItemScalarWhereInput[]
|
||||
id?: Prisma.StringFilter<"CatalogItem"> | string
|
||||
cwCatalogId?: Prisma.IntFilter<"CatalogItem"> | number
|
||||
identifier?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
name?: Prisma.StringFilter<"CatalogItem"> | string
|
||||
description?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
customerDescription?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
internalNotes?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
category?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
categoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
subcategory?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
subcategoryCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
manufacturer?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
manufactureCwId?: Prisma.IntNullableFilter<"CatalogItem"> | number | null
|
||||
partNumber?: Prisma.StringNullableFilter<"CatalogItem"> | string | null
|
||||
@@ -992,10 +1139,15 @@ export type CatalogItemUpdateManyWithWhereWithoutLinkedItemsInput = {
|
||||
export type CatalogItemUpdateWithoutLinkedToInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1016,10 +1168,15 @@ export type CatalogItemUpdateWithoutLinkedToInput = {
|
||||
export type CatalogItemUncheckedUpdateWithoutLinkedToInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1040,10 +1197,15 @@ export type CatalogItemUncheckedUpdateWithoutLinkedToInput = {
|
||||
export type CatalogItemUncheckedUpdateManyWithoutLinkedToInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1063,10 +1225,15 @@ export type CatalogItemUncheckedUpdateManyWithoutLinkedToInput = {
|
||||
export type CatalogItemUpdateWithoutLinkedItemsInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1087,10 +1254,15 @@ export type CatalogItemUpdateWithoutLinkedItemsInput = {
|
||||
export type CatalogItemUncheckedUpdateWithoutLinkedItemsInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1111,10 +1283,15 @@ export type CatalogItemUncheckedUpdateWithoutLinkedItemsInput = {
|
||||
export type CatalogItemUncheckedUpdateManyWithoutLinkedItemsInput = {
|
||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
cwCatalogId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
identifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
customerDescription?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
internalNotes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
category?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
categoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
subcategory?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
subcategoryCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
manufacturer?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
manufactureCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||
partNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -1174,10 +1351,15 @@ export type CatalogItemCountOutputTypeCountLinkedToArgs<ExtArgs extends runtime.
|
||||
export type CatalogItemSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||
id?: boolean
|
||||
cwCatalogId?: boolean
|
||||
identifier?: boolean
|
||||
name?: boolean
|
||||
description?: boolean
|
||||
customerDescription?: boolean
|
||||
internalNotes?: boolean
|
||||
category?: boolean
|
||||
categoryCwId?: boolean
|
||||
subcategory?: boolean
|
||||
subcategoryCwId?: boolean
|
||||
manufacturer?: boolean
|
||||
manufactureCwId?: boolean
|
||||
partNumber?: boolean
|
||||
@@ -1200,10 +1382,15 @@ 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<{
|
||||
id?: boolean
|
||||
cwCatalogId?: boolean
|
||||
identifier?: boolean
|
||||
name?: boolean
|
||||
description?: boolean
|
||||
customerDescription?: boolean
|
||||
internalNotes?: boolean
|
||||
category?: boolean
|
||||
categoryCwId?: boolean
|
||||
subcategory?: boolean
|
||||
subcategoryCwId?: boolean
|
||||
manufacturer?: boolean
|
||||
manufactureCwId?: boolean
|
||||
partNumber?: boolean
|
||||
@@ -1223,10 +1410,15 @@ 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<{
|
||||
id?: boolean
|
||||
cwCatalogId?: boolean
|
||||
identifier?: boolean
|
||||
name?: boolean
|
||||
description?: boolean
|
||||
customerDescription?: boolean
|
||||
internalNotes?: boolean
|
||||
category?: boolean
|
||||
categoryCwId?: boolean
|
||||
subcategory?: boolean
|
||||
subcategoryCwId?: boolean
|
||||
manufacturer?: boolean
|
||||
manufactureCwId?: boolean
|
||||
partNumber?: boolean
|
||||
@@ -1246,10 +1438,15 @@ export type CatalogItemSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
||||
export type CatalogItemSelectScalar = {
|
||||
id?: boolean
|
||||
cwCatalogId?: boolean
|
||||
identifier?: boolean
|
||||
name?: boolean
|
||||
description?: boolean
|
||||
customerDescription?: boolean
|
||||
internalNotes?: boolean
|
||||
category?: boolean
|
||||
categoryCwId?: boolean
|
||||
subcategory?: boolean
|
||||
subcategoryCwId?: boolean
|
||||
manufacturer?: boolean
|
||||
manufactureCwId?: boolean
|
||||
partNumber?: boolean
|
||||
@@ -1266,7 +1463,7 @@ export type CatalogItemSelectScalar = {
|
||||
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" | "category" | "categoryCwId" | "subcategory" | "subcategoryCwId" | "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> = {
|
||||
linkedItems?: boolean | Prisma.CatalogItem$linkedItemsArgs<ExtArgs>
|
||||
linkedTo?: boolean | Prisma.CatalogItem$linkedToArgs<ExtArgs>
|
||||
@@ -1284,10 +1481,15 @@ export type $CatalogItemPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
id: string
|
||||
cwCatalogId: number
|
||||
identifier: string | null
|
||||
name: string
|
||||
description: string | null
|
||||
customerDescription: string | null
|
||||
internalNotes: string | null
|
||||
category: string | null
|
||||
categoryCwId: number | null
|
||||
subcategory: string | null
|
||||
subcategoryCwId: number | null
|
||||
manufacturer: string | null
|
||||
manufactureCwId: number | null
|
||||
partNumber: string | null
|
||||
@@ -1729,10 +1931,15 @@ export interface Prisma__CatalogItemClient<T, Null = never, ExtArgs extends runt
|
||||
export interface CatalogItemFieldRefs {
|
||||
readonly id: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly cwCatalogId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
||||
readonly identifier: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly name: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly description: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly customerDescription: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly internalNotes: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly category: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly categoryCwId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
||||
readonly subcategory: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly subcategoryCwId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
||||
readonly manufacturer: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
readonly manufactureCwId: Prisma.FieldRef<"CatalogItem", 'Int'>
|
||||
readonly partNumber: Prisma.FieldRef<"CatalogItem", 'String'>
|
||||
|
||||
@@ -226,6 +226,7 @@ export type CompanyWhereInput = {
|
||||
updatedAt?: Prisma.DateTimeFilter<"Company"> | Date | string
|
||||
credentials?: Prisma.CredentialListRelationFilter
|
||||
unifiSites?: Prisma.UnifiSiteListRelationFilter
|
||||
opportunities?: Prisma.OpportunityListRelationFilter
|
||||
}
|
||||
|
||||
export type CompanyOrderByWithRelationInput = {
|
||||
@@ -237,6 +238,7 @@ export type CompanyOrderByWithRelationInput = {
|
||||
updatedAt?: Prisma.SortOrder
|
||||
credentials?: Prisma.CredentialOrderByRelationAggregateInput
|
||||
unifiSites?: Prisma.UnifiSiteOrderByRelationAggregateInput
|
||||
opportunities?: Prisma.OpportunityOrderByRelationAggregateInput
|
||||
}
|
||||
|
||||
export type CompanyWhereUniqueInput = Prisma.AtLeast<{
|
||||
@@ -251,6 +253,7 @@ export type CompanyWhereUniqueInput = Prisma.AtLeast<{
|
||||
updatedAt?: Prisma.DateTimeFilter<"Company"> | Date | string
|
||||
credentials?: Prisma.CredentialListRelationFilter
|
||||
unifiSites?: Prisma.UnifiSiteListRelationFilter
|
||||
opportunities?: Prisma.OpportunityListRelationFilter
|
||||
}, "id" | "cw_CompanyId" | "cw_Identifier">
|
||||
|
||||
export type CompanyOrderByWithAggregationInput = {
|
||||
@@ -288,6 +291,7 @@ export type CompanyCreateInput = {
|
||||
updatedAt?: Date | string
|
||||
credentials?: Prisma.CredentialCreateNestedManyWithoutCompanyInput
|
||||
unifiSites?: Prisma.UnifiSiteCreateNestedManyWithoutCompanyInput
|
||||
opportunities?: Prisma.OpportunityCreateNestedManyWithoutCompanyInput
|
||||
}
|
||||
|
||||
export type CompanyUncheckedCreateInput = {
|
||||
@@ -299,6 +303,7 @@ export type CompanyUncheckedCreateInput = {
|
||||
updatedAt?: Date | string
|
||||
credentials?: Prisma.CredentialUncheckedCreateNestedManyWithoutCompanyInput
|
||||
unifiSites?: Prisma.UnifiSiteUncheckedCreateNestedManyWithoutCompanyInput
|
||||
opportunities?: Prisma.OpportunityUncheckedCreateNestedManyWithoutCompanyInput
|
||||
}
|
||||
|
||||
export type CompanyUpdateInput = {
|
||||
@@ -310,6 +315,7 @@ export type CompanyUpdateInput = {
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
credentials?: Prisma.CredentialUpdateManyWithoutCompanyNestedInput
|
||||
unifiSites?: Prisma.UnifiSiteUpdateManyWithoutCompanyNestedInput
|
||||
opportunities?: Prisma.OpportunityUpdateManyWithoutCompanyNestedInput
|
||||
}
|
||||
|
||||
export type CompanyUncheckedUpdateInput = {
|
||||
@@ -321,6 +327,7 @@ export type CompanyUncheckedUpdateInput = {
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
credentials?: Prisma.CredentialUncheckedUpdateManyWithoutCompanyNestedInput
|
||||
unifiSites?: Prisma.UnifiSiteUncheckedUpdateManyWithoutCompanyNestedInput
|
||||
opportunities?: Prisma.OpportunityUncheckedUpdateManyWithoutCompanyNestedInput
|
||||
}
|
||||
|
||||
export type CompanyCreateManyInput = {
|
||||
@@ -419,6 +426,22 @@ export type IntFieldUpdateOperationsInput = {
|
||||
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 = {
|
||||
create?: Prisma.XOR<Prisma.CompanyCreateWithoutCredentialsInput, Prisma.CompanyUncheckedCreateWithoutCredentialsInput>
|
||||
connectOrCreate?: Prisma.CompanyCreateOrConnectWithoutCredentialsInput
|
||||
@@ -441,6 +464,7 @@ export type CompanyCreateWithoutUnifiSitesInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
credentials?: Prisma.CredentialCreateNestedManyWithoutCompanyInput
|
||||
opportunities?: Prisma.OpportunityCreateNestedManyWithoutCompanyInput
|
||||
}
|
||||
|
||||
export type CompanyUncheckedCreateWithoutUnifiSitesInput = {
|
||||
@@ -451,6 +475,7 @@ export type CompanyUncheckedCreateWithoutUnifiSitesInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
credentials?: Prisma.CredentialUncheckedCreateNestedManyWithoutCompanyInput
|
||||
opportunities?: Prisma.OpportunityUncheckedCreateNestedManyWithoutCompanyInput
|
||||
}
|
||||
|
||||
export type CompanyCreateOrConnectWithoutUnifiSitesInput = {
|
||||
@@ -477,6 +502,7 @@ export type CompanyUpdateWithoutUnifiSitesInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
credentials?: Prisma.CredentialUpdateManyWithoutCompanyNestedInput
|
||||
opportunities?: Prisma.OpportunityUpdateManyWithoutCompanyNestedInput
|
||||
}
|
||||
|
||||
export type CompanyUncheckedUpdateWithoutUnifiSitesInput = {
|
||||
@@ -487,6 +513,67 @@ export type CompanyUncheckedUpdateWithoutUnifiSitesInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
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 = {
|
||||
@@ -497,6 +584,7 @@ export type CompanyCreateWithoutCredentialsInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
unifiSites?: Prisma.UnifiSiteCreateNestedManyWithoutCompanyInput
|
||||
opportunities?: Prisma.OpportunityCreateNestedManyWithoutCompanyInput
|
||||
}
|
||||
|
||||
export type CompanyUncheckedCreateWithoutCredentialsInput = {
|
||||
@@ -507,6 +595,7 @@ export type CompanyUncheckedCreateWithoutCredentialsInput = {
|
||||
createdAt?: Date | string
|
||||
updatedAt?: Date | string
|
||||
unifiSites?: Prisma.UnifiSiteUncheckedCreateNestedManyWithoutCompanyInput
|
||||
opportunities?: Prisma.OpportunityUncheckedCreateNestedManyWithoutCompanyInput
|
||||
}
|
||||
|
||||
export type CompanyCreateOrConnectWithoutCredentialsInput = {
|
||||
@@ -533,6 +622,7 @@ export type CompanyUpdateWithoutCredentialsInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
unifiSites?: Prisma.UnifiSiteUpdateManyWithoutCompanyNestedInput
|
||||
opportunities?: Prisma.OpportunityUpdateManyWithoutCompanyNestedInput
|
||||
}
|
||||
|
||||
export type CompanyUncheckedUpdateWithoutCredentialsInput = {
|
||||
@@ -543,6 +633,7 @@ export type CompanyUncheckedUpdateWithoutCredentialsInput = {
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
unifiSites?: Prisma.UnifiSiteUncheckedUpdateManyWithoutCompanyNestedInput
|
||||
opportunities?: Prisma.OpportunityUncheckedUpdateManyWithoutCompanyNestedInput
|
||||
}
|
||||
|
||||
|
||||
@@ -553,11 +644,13 @@ export type CompanyUncheckedUpdateWithoutCredentialsInput = {
|
||||
export type CompanyCountOutputType = {
|
||||
credentials: number
|
||||
unifiSites: number
|
||||
opportunities: number
|
||||
}
|
||||
|
||||
export type CompanyCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
credentials?: boolean | CompanyCountOutputTypeCountCredentialsArgs
|
||||
unifiSites?: boolean | CompanyCountOutputTypeCountUnifiSitesArgs
|
||||
opportunities?: boolean | CompanyCountOutputTypeCountOpportunitiesArgs
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -584,6 +677,13 @@ export type CompanyCountOutputTypeCountUnifiSitesArgs<ExtArgs extends runtime.Ty
|
||||
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<{
|
||||
id?: boolean
|
||||
@@ -594,6 +694,7 @@ export type CompanySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
|
||||
updatedAt?: boolean
|
||||
credentials?: boolean | Prisma.Company$credentialsArgs<ExtArgs>
|
||||
unifiSites?: boolean | Prisma.Company$unifiSitesArgs<ExtArgs>
|
||||
opportunities?: boolean | Prisma.Company$opportunitiesArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.CompanyCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}, 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> = {
|
||||
credentials?: boolean | Prisma.Company$credentialsArgs<ExtArgs>
|
||||
unifiSites?: boolean | Prisma.Company$unifiSitesArgs<ExtArgs>
|
||||
opportunities?: boolean | Prisma.Company$opportunitiesArgs<ExtArgs>
|
||||
_count?: boolean | Prisma.CompanyCountOutputTypeDefaultArgs<ExtArgs>
|
||||
}
|
||||
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: {
|
||||
credentials: Prisma.$CredentialPayload<ExtArgs>[]
|
||||
unifiSites: Prisma.$UnifiSitePayload<ExtArgs>[]
|
||||
opportunities: Prisma.$OpportunityPayload<ExtArgs>[]
|
||||
}
|
||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||
id: string
|
||||
@@ -1042,6 +1145,7 @@ export interface Prisma__CompanyClient<T, Null = never, ExtArgs extends runtime.
|
||||
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>
|
||||
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.
|
||||
* @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[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ export type UserMinAggregateOutputType = {
|
||||
email: string | null
|
||||
emailVerified: Date | null
|
||||
image: string | null
|
||||
cwIdentifier: string | null
|
||||
userId: string | null
|
||||
token: string | null
|
||||
createdAt: Date | null
|
||||
@@ -46,6 +47,7 @@ export type UserMaxAggregateOutputType = {
|
||||
email: string | null
|
||||
emailVerified: Date | null
|
||||
image: string | null
|
||||
cwIdentifier: string | null
|
||||
userId: string | null
|
||||
token: string | null
|
||||
createdAt: Date | null
|
||||
@@ -60,6 +62,7 @@ export type UserCountAggregateOutputType = {
|
||||
email: number
|
||||
emailVerified: number
|
||||
image: number
|
||||
cwIdentifier: number
|
||||
userId: number
|
||||
token: number
|
||||
createdAt: number
|
||||
@@ -76,6 +79,7 @@ export type UserMinAggregateInputType = {
|
||||
email?: true
|
||||
emailVerified?: true
|
||||
image?: true
|
||||
cwIdentifier?: true
|
||||
userId?: true
|
||||
token?: true
|
||||
createdAt?: true
|
||||
@@ -90,6 +94,7 @@ export type UserMaxAggregateInputType = {
|
||||
email?: true
|
||||
emailVerified?: true
|
||||
image?: true
|
||||
cwIdentifier?: true
|
||||
userId?: true
|
||||
token?: true
|
||||
createdAt?: true
|
||||
@@ -104,6 +109,7 @@ export type UserCountAggregateInputType = {
|
||||
email?: true
|
||||
emailVerified?: true
|
||||
image?: true
|
||||
cwIdentifier?: true
|
||||
userId?: true
|
||||
token?: true
|
||||
createdAt?: true
|
||||
@@ -191,6 +197,7 @@ export type UserGroupByOutputType = {
|
||||
email: string
|
||||
emailVerified: Date | null
|
||||
image: string | null
|
||||
cwIdentifier: string | null
|
||||
userId: string
|
||||
token: string | null
|
||||
createdAt: Date
|
||||
@@ -226,6 +233,7 @@ export type UserWhereInput = {
|
||||
email?: Prisma.StringFilter<"User"> | string
|
||||
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
cwIdentifier?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
userId?: Prisma.StringFilter<"User"> | string
|
||||
token?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
@@ -242,6 +250,7 @@ export type UserOrderByWithRelationInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -262,6 +271,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||
name?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
cwIdentifier?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
token?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
@@ -277,6 +287,7 @@ export type UserOrderByWithAggregationInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
image?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -297,6 +308,7 @@ export type UserScalarWhereWithAggregatesInput = {
|
||||
email?: Prisma.StringWithAggregatesFilter<"User"> | string
|
||||
emailVerified?: Prisma.DateTimeNullableWithAggregatesFilter<"User"> | Date | string | null
|
||||
image?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
cwIdentifier?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
userId?: Prisma.StringWithAggregatesFilter<"User"> | string
|
||||
token?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string
|
||||
@@ -311,6 +323,7 @@ export type UserCreateInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -327,6 +340,7 @@ export type UserUncheckedCreateInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -343,6 +357,7 @@ export type UserUpdateInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -359,6 +374,7 @@ export type UserUncheckedUpdateInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -375,6 +391,7 @@ export type UserCreateManyInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -389,6 +406,7 @@ export type UserUpdateManyMutationInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -403,6 +421,7 @@ export type UserUncheckedUpdateManyInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -422,6 +441,7 @@ export type UserCountOrderByAggregateInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -436,6 +456,7 @@ export type UserMaxOrderByAggregateInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -450,6 +471,7 @@ export type UserMinOrderByAggregateInput = {
|
||||
email?: Prisma.SortOrder
|
||||
emailVerified?: Prisma.SortOrder
|
||||
image?: Prisma.SortOrder
|
||||
cwIdentifier?: Prisma.SortOrder
|
||||
userId?: Prisma.SortOrder
|
||||
token?: Prisma.SortOrder
|
||||
createdAt?: Prisma.SortOrder
|
||||
@@ -530,6 +552,7 @@ export type UserCreateWithoutSessionsInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -545,6 +568,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -576,6 +600,7 @@ export type UserUpdateWithoutSessionsInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -591,6 +616,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -606,6 +632,7 @@ export type UserCreateWithoutRolesInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -621,6 +648,7 @@ export type UserUncheckedCreateWithoutRolesInput = {
|
||||
email: string
|
||||
emailVerified?: Date | string | null
|
||||
image?: string | null
|
||||
cwIdentifier?: string | null
|
||||
userId: string
|
||||
token?: string | null
|
||||
createdAt?: Date | string
|
||||
@@ -660,6 +688,7 @@ export type UserScalarWhereInput = {
|
||||
email?: Prisma.StringFilter<"User"> | string
|
||||
emailVerified?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null
|
||||
image?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
cwIdentifier?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
userId?: Prisma.StringFilter<"User"> | string
|
||||
token?: Prisma.StringNullableFilter<"User"> | string | null
|
||||
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
|
||||
@@ -674,6 +703,7 @@ export type UserUpdateWithoutRolesInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -689,6 +719,7 @@ export type UserUncheckedUpdateWithoutRolesInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -704,6 +735,7 @@ export type UserUncheckedUpdateManyWithoutRolesInput = {
|
||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
emailVerified?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cwIdentifier?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
token?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
@@ -758,6 +790,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
||||
email?: boolean
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
cwIdentifier?: boolean
|
||||
userId?: boolean
|
||||
token?: boolean
|
||||
createdAt?: boolean
|
||||
@@ -775,6 +808,7 @@ export type UserSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
||||
email?: boolean
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
cwIdentifier?: boolean
|
||||
userId?: boolean
|
||||
token?: boolean
|
||||
createdAt?: boolean
|
||||
@@ -789,6 +823,7 @@ export type UserSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
|
||||
email?: boolean
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
cwIdentifier?: boolean
|
||||
userId?: boolean
|
||||
token?: boolean
|
||||
createdAt?: boolean
|
||||
@@ -803,13 +838,14 @@ export type UserSelectScalar = {
|
||||
email?: boolean
|
||||
emailVerified?: boolean
|
||||
image?: boolean
|
||||
cwIdentifier?: boolean
|
||||
userId?: boolean
|
||||
token?: boolean
|
||||
createdAt?: boolean
|
||||
updatedAt?: boolean
|
||||
}
|
||||
|
||||
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "permissions" | "login" | "name" | "email" | "emailVerified" | "image" | "userId" | "token" | "createdAt" | "updatedAt", ExtArgs["result"]["user"]>
|
||||
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "permissions" | "login" | "name" | "email" | "emailVerified" | "image" | "cwIdentifier" | "userId" | "token" | "createdAt" | "updatedAt", ExtArgs["result"]["user"]>
|
||||
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
roles?: boolean | Prisma.User$rolesArgs<ExtArgs>
|
||||
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
|
||||
@@ -832,6 +868,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
||||
email: string
|
||||
emailVerified: Date | null
|
||||
image: string | null
|
||||
cwIdentifier: string | null
|
||||
userId: string
|
||||
token: string | null
|
||||
createdAt: Date
|
||||
@@ -1268,6 +1305,7 @@ export interface UserFieldRefs {
|
||||
readonly email: Prisma.FieldRef<"User", 'String'>
|
||||
readonly emailVerified: Prisma.FieldRef<"User", 'DateTime'>
|
||||
readonly image: Prisma.FieldRef<"User", 'String'>
|
||||
readonly cwIdentifier: Prisma.FieldRef<"User", 'String'>
|
||||
readonly userId: Prisma.FieldRef<"User", 'String'>
|
||||
readonly token: Prisma.FieldRef<"User", 'String'>
|
||||
readonly createdAt: Prisma.FieldRef<"User", 'DateTime'>
|
||||
|
||||
+4
-1
@@ -19,13 +19,15 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development bun --watch src/index.ts",
|
||||
"test": "bun test --preload ./tests/setup.ts",
|
||||
"db:gen": "prisma generate",
|
||||
"db:push": "prisma migrate dev --skip-generate",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"utils:dev": "docker compose -f .docker/docker-compose.yml up --build",
|
||||
"utils:gen_private_keys": "bun ./utils/genPrivateKeys",
|
||||
"utils:create_admin_role": "bun ./utils/createAdminRole",
|
||||
"utils:assign_user_role": "bun ./utils/assignUserRole"
|
||||
"utils:assign_user_role": "bun ./utils/assignUserRole",
|
||||
"db:check": "bunx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --shadow-database-url $DATABASE_URL --exit-code"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^5.0.2",
|
||||
@@ -39,6 +41,7 @@
|
||||
"cors": "^2.8.6",
|
||||
"cuid": "^3.0.0",
|
||||
"hono": "^4.11.5",
|
||||
"ioredis": "^5.10.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"keypair": "^1.0.4",
|
||||
"prisma": "^7.3.0",
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Resolve any previously failed migrations so deploy can proceed.
|
||||
# Only migrations explicitly marked as "Failed" in the status output are
|
||||
# resolved. We grep for lines containing "Failed" and extract the name.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "[migrate] Checking for failed migrations..."
|
||||
STATUS_OUTPUT=$(bunx prisma migrate status 2>&1 || true)
|
||||
echo "$STATUS_OUTPUT"
|
||||
|
||||
# Only resolve migrations whose status line explicitly says "Failed"
|
||||
echo "$STATUS_OUTPUT" | grep -i "failed" | grep -oE '[0-9]{14}_[a-zA-Z_]+' | while read -r MIGRATION; do
|
||||
echo "[migrate] Resolving failed migration: $MIGRATION"
|
||||
bunx prisma migrate resolve --rolled-back "$MIGRATION" || true
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Deploy all pending migrations from the migrations directory.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "[migrate] Running prisma migrate deploy..."
|
||||
bunx prisma migrate deploy
|
||||
@@ -0,0 +1,63 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "UnifiSite" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"siteId" TEXT NOT NULL,
|
||||
"companyId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "UnifiSite_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CatalogItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"cwCatalogId" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"customerDescription" TEXT,
|
||||
"internalNotes" TEXT,
|
||||
"manufacturer" TEXT,
|
||||
"manufactureCwId" INTEGER,
|
||||
"partNumber" TEXT,
|
||||
"vendorName" TEXT,
|
||||
"vendorSku" TEXT,
|
||||
"vendorCwId" INTEGER,
|
||||
"price" DOUBLE PRECISION NOT NULL,
|
||||
"cost" DOUBLE PRECISION NOT NULL,
|
||||
"inactive" BOOLEAN NOT NULL DEFAULT false,
|
||||
"salesTaxable" BOOLEAN NOT NULL DEFAULT true,
|
||||
"onHand" INTEGER NOT NULL DEFAULT 0,
|
||||
"cwLastUpdated" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "CatalogItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_LinkedItems" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_LinkedItems_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UnifiSite_siteId_key" ON "UnifiSite"("siteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CatalogItem_cwCatalogId_key" ON "CatalogItem"("cwCatalogId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_LinkedItems_B_index" ON "_LinkedItems"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UnifiSite" ADD CONSTRAINT "UnifiSite_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_LinkedItems" ADD CONSTRAINT "_LinkedItems_A_fkey" FOREIGN KEY ("A") REFERENCES "CatalogItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_LinkedItems" ADD CONSTRAINT "_LinkedItems_B_fkey" FOREIGN KEY ("B") REFERENCES "CatalogItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "identifier" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CatalogItem_identifier_key" ON "CatalogItem"("identifier");
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterTable: User
|
||||
ALTER TABLE "User" ADD COLUMN "cwIdentifier" TEXT;
|
||||
|
||||
-- AlterTable: CatalogItem
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "category" TEXT;
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "categoryCwId" INTEGER;
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "subcategory" TEXT;
|
||||
ALTER TABLE "CatalogItem" ADD COLUMN "subcategoryCwId" INTEGER;
|
||||
|
||||
-- AlterTable: Opportunity
|
||||
ALTER TABLE "Opportunity" ADD COLUMN "productSequence" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
|
||||
+82
-2
@@ -34,6 +34,8 @@ model User {
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
|
||||
cwIdentifier String?
|
||||
|
||||
userId String @unique
|
||||
token String?
|
||||
|
||||
@@ -75,8 +77,9 @@ model Company {
|
||||
cw_CompanyId Int @unique
|
||||
cw_Identifier String @unique
|
||||
|
||||
credentials Credential[]
|
||||
unifiSites UnifiSite[]
|
||||
credentials Credential[]
|
||||
unifiSites UnifiSite[]
|
||||
opportunities Opportunity[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -85,6 +88,7 @@ model Company {
|
||||
model CatalogItem {
|
||||
id String @id @default(cuid())
|
||||
cwCatalogId Int @unique
|
||||
identifier String? @unique
|
||||
name String
|
||||
description String?
|
||||
customerDescription String?
|
||||
@@ -93,6 +97,11 @@ model CatalogItem {
|
||||
linkedItems CatalogItem[] @relation("LinkedItems")
|
||||
linkedTo CatalogItem[] @relation("LinkedItems")
|
||||
|
||||
category String?
|
||||
categoryCwId Int?
|
||||
subcategory String?
|
||||
subcategoryCwId Int?
|
||||
|
||||
manufacturer String?
|
||||
manufactureCwId Int?
|
||||
|
||||
@@ -115,6 +124,77 @@ model CatalogItem {
|
||||
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])
|
||||
|
||||
// Local product sequence — array of CW forecast item IDs in display order.
|
||||
// When present, fetchProducts() uses this order instead of CW sequenceNumber.
|
||||
productSequence Int[] @default([])
|
||||
|
||||
cwLastUpdated DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model CredentialType {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono/tiny";
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import * as msal from "@azure/msal-node";
|
||||
import { io, msalClient } from "../../constants";
|
||||
import { API_BASE_URL, io, msalClient } from "../../constants";
|
||||
import { users } from "../../managers/users";
|
||||
|
||||
/* /v1/auth/redirect */
|
||||
@@ -11,7 +11,7 @@ export default createRoute("get", ["/redirect"], async (c) => {
|
||||
const tokenRequest: msal.AuthorizationCodeRequest = {
|
||||
code: c.req.query().code as string,
|
||||
scopes: ["user.read"],
|
||||
redirectUri: "http://localhost:3000/v1/auth/redirect",
|
||||
redirectUri: `${API_BASE_URL}/v1/auth/redirect`,
|
||||
};
|
||||
|
||||
const authResult = await msalClient.acquireTokenByCode(tokenRequest);
|
||||
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
import { Hono } from "hono/tiny";
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { API_BASE_URL } from "../../constants";
|
||||
import cuid from "cuid";
|
||||
|
||||
/* /v1/auth/uri */
|
||||
@@ -7,7 +8,8 @@ export default createRoute("get", ["/uri"], (c) => {
|
||||
c.status(200);
|
||||
|
||||
const callbackKey = cuid();
|
||||
const msUri = `https://login.microsoftonline.com/${process.env.MICROSOFT_TENANT_ID}/oauth2/v2.0/authorize?client_id=${process.env.MICROSOFT_CLIENT_ID}&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fv1%2Fauth%2Fredirect&scope=openid+User.Read&state=${callbackKey}&prompt=login`;
|
||||
const redirectUri = encodeURIComponent(`${API_BASE_URL}/v1/auth/redirect`);
|
||||
const msUri = `https://login.microsoftonline.com/${process.env.MICROSOFT_TENANT_ID}/oauth2/v2.0/authorize?client_id=${process.env.MICROSOFT_CLIENT_ID}&response_type=code&redirect_uri=${redirectUri}&scope=openid+User.Read&state=${callbackKey}&prompt=login`;
|
||||
|
||||
return c.json({
|
||||
status: 200,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/company/companies/[id] */
|
||||
export default createRoute(
|
||||
@@ -42,13 +43,20 @@ export default createRoute(
|
||||
}
|
||||
}
|
||||
|
||||
const companyData = company.toJson({
|
||||
includeAddress,
|
||||
includePrimaryContact,
|
||||
includeAllContacts,
|
||||
});
|
||||
const gatedData = await processObjectValuePerms(
|
||||
companyData,
|
||||
"obj.company",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company Fetched Successfully!",
|
||||
company.toJson({
|
||||
includeAddress,
|
||||
includePrimaryContact,
|
||||
includeAllContacts,
|
||||
}),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { companies } from "../../../managers/companies";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/company/companies/:identifier/unifi/sites */
|
||||
export default createRoute(
|
||||
@@ -12,9 +13,16 @@ export default createRoute(
|
||||
async (c) => {
|
||||
const company = await companies.fetch(c.req.param("identifier"));
|
||||
const sites = await unifiSites.fetchByCompany(company.id);
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
sites.map((site) =>
|
||||
processObjectValuePerms(site, "obj.unifiSite", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company UniFi Sites Fetched Successfully!",
|
||||
sites,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { companies } from "../../managers/companies";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/company/companies */
|
||||
export default createRoute(
|
||||
@@ -22,9 +23,15 @@ export default createRoute(
|
||||
? (await companies.search(search, 1, 999999)).length
|
||||
: await companies.count();
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
data.map((item) =>
|
||||
processObjectValuePerms(item, "obj.company", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
let response = apiResponse.successful(
|
||||
"Companies Fetched Successfully!",
|
||||
data,
|
||||
gatedData,
|
||||
{
|
||||
pagination: {
|
||||
previousPage: page == 1 ? null : page - 1, // Previous Page
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentialTypes } from "../../managers/credentialTypes";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/credential-type/:identifier */
|
||||
export default createRoute(
|
||||
@@ -15,9 +16,15 @@ export default createRoute(
|
||||
c.req.param("identifier"),
|
||||
);
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
credentialType.toJson({ includeCredentialCount: true }),
|
||||
"obj.credentialType",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Credential Type Fetched Successfully!",
|
||||
credentialType.toJson({ includeCredentialCount: true }),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentialTypes } from "../../managers/credentialTypes";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/credential-type */
|
||||
export default createRoute(
|
||||
@@ -13,11 +14,19 @@ export default createRoute(
|
||||
async (c) => {
|
||||
const allCredentialTypes = await credentialTypes.fetchAll();
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
allCredentialTypes.map((ct) =>
|
||||
processObjectValuePerms(
|
||||
ct.toJson({ includeCredentialCount: true }),
|
||||
"obj.credentialType",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Credential Types Fetched Successfully!",
|
||||
allCredentialTypes.map((ct) =>
|
||||
ct.toJson({ includeCredentialCount: true }),
|
||||
),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentialTypes } from "../../managers/credentialTypes";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/credential-type/:id/credentials */
|
||||
export default createRoute(
|
||||
@@ -14,9 +15,15 @@ export default createRoute(
|
||||
const credentialType = await credentialTypes.fetch(c.req.param("id"));
|
||||
const credentials = await credentialType.fetchCredentials();
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
credentials.map((cred) =>
|
||||
processObjectValuePerms(cred.toJson(), "obj.credential", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Credentials Fetched Successfully!",
|
||||
credentials.map((cred) => cred.toJson()),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentials } from "../../managers/credentials";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/credential/:id */
|
||||
export default createRoute(
|
||||
@@ -12,10 +13,15 @@ export default createRoute(
|
||||
|
||||
async (c) => {
|
||||
const credential = await credentials.fetch(c.req.param("id"));
|
||||
const gatedData = await processObjectValuePerms(
|
||||
credential.toJson(),
|
||||
"obj.credential",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Credential Fetched Successfully!",
|
||||
credential.toJson(),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { credentials } from "../../managers/credentials";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /v1/credential/company/:companyId */
|
||||
export default createRoute(
|
||||
@@ -15,9 +16,15 @@ export default createRoute(
|
||||
c.req.param("companyId"),
|
||||
);
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
companyCredentials.map((cred) =>
|
||||
processObjectValuePerms(cred.toJson(), "obj.credential", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company Credentials Fetched Successfully!",
|
||||
companyCredentials.map((cred) => cred.toJson()),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { credentials } from "../../managers/credentials";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/credential/credentials/:id/sub-credentials */
|
||||
export default createRoute(
|
||||
@@ -17,9 +18,15 @@ export default createRoute(
|
||||
|
||||
const subCredentials = await credentials.fetchSubCredentials(parentId);
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
subCredentials.map((sc) =>
|
||||
processObjectValuePerms(sc.toJson(), "obj.credential", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Sub-Credentials Fetched Successfully!",
|
||||
subCredentials.map((sc) => sc.toJson()),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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 { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /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 gatedData = await processObjectValuePerms(
|
||||
item.toJson({ includeLinkedItems }),
|
||||
"obj.catalogItem",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Catalog item fetched successfully!",
|
||||
gatedData,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
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 { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* 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 gatedData = await Promise.all(
|
||||
linkedItems.map((linked) =>
|
||||
processObjectValuePerms(linked, "obj.catalogItem", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Linked catalog items fetched successfully!",
|
||||
gatedData,
|
||||
);
|
||||
|
||||
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,26 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import {
|
||||
serializeCategoryTree,
|
||||
serializeEcosystemTree,
|
||||
} from "../../modules/catalog-categories/catalogCategories";
|
||||
|
||||
/* /v1/procurement/categories */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/categories"],
|
||||
async (c) => {
|
||||
const categories = serializeCategoryTree();
|
||||
const ecosystems = serializeEcosystemTree();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Category and ecosystem data fetched successfully!",
|
||||
{ categories, ecosystems },
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
|
||||
);
|
||||
@@ -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,80 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { procurement, CatalogFilterOpts } from "../../managers/procurement";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* /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";
|
||||
|
||||
// Category / filter params
|
||||
const category = c.req.query("category") as string | undefined;
|
||||
const subcategory = c.req.query("subcategory") as string | undefined;
|
||||
const group = c.req.query("group") as string | undefined;
|
||||
const manufacturer = c.req.query("manufacturer") as string | undefined;
|
||||
const ecosystem = c.req.query("ecosystem") as string | undefined;
|
||||
const inStock = c.req.query("inStock") === "true" ? true : undefined;
|
||||
const minPrice = c.req.query("minPrice")
|
||||
? Number(c.req.query("minPrice"))
|
||||
: undefined;
|
||||
const maxPrice = c.req.query("maxPrice")
|
||||
? Number(c.req.query("maxPrice"))
|
||||
: undefined;
|
||||
|
||||
const filterOpts: CatalogFilterOpts = {
|
||||
includeInactive,
|
||||
category,
|
||||
subcategory,
|
||||
group,
|
||||
manufacturer,
|
||||
ecosystem,
|
||||
inStock,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
};
|
||||
|
||||
const data = search
|
||||
? await procurement.search(search, page, rpp, filterOpts)
|
||||
: await procurement.fetchPages(page, rpp, filterOpts);
|
||||
|
||||
const totalRecords = search
|
||||
? await procurement.countSearch(search, filterOpts)
|
||||
: await procurement.count(filterOpts);
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
data.map((item) =>
|
||||
processObjectValuePerms(
|
||||
item.toJson(),
|
||||
"obj.catalogItem",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Catalog items fetched successfully!",
|
||||
gatedData,
|
||||
{
|
||||
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,32 @@
|
||||
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/filters */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/filters"],
|
||||
async (c) => {
|
||||
const category = c.req.query("category") as string | undefined;
|
||||
const subcategory = c.req.query("subcategory") as string | undefined;
|
||||
const includeInactive = c.req.query("includeInactive") === "true";
|
||||
|
||||
const filterOpts = { category, subcategory, includeInactive };
|
||||
|
||||
const [categories, subcategories, manufacturers] = await Promise.all([
|
||||
procurement.fetchDistinctValues("category", filterOpts),
|
||||
procurement.fetchDistinctValues("subcategory", filterOpts),
|
||||
procurement.fetchDistinctValues("manufacturer", filterOpts),
|
||||
]);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Available filter values fetched successfully!",
|
||||
{ categories, subcategories, manufacturers },
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
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";
|
||||
import { default as categories } from "./categories";
|
||||
import { default as filters } from "./filters";
|
||||
|
||||
export {
|
||||
categories,
|
||||
count,
|
||||
fetch,
|
||||
fetchAll,
|
||||
fetchLinked,
|
||||
filters,
|
||||
link,
|
||||
refreshInventory,
|
||||
unlink,
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { roles } from "../../managers/roles";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/role/:identifier */
|
||||
export default createRoute(
|
||||
@@ -15,9 +16,15 @@ export default createRoute(
|
||||
|
||||
const role = await roles.fetch(identifier);
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
role.toJson({ viewPermissions: true }),
|
||||
"obj.role",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Role Fetched Successfully!",
|
||||
role.toJson({ viewPermissions: true }),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { roles } from "../../managers/roles";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/role */
|
||||
export default createRoute(
|
||||
@@ -13,13 +14,19 @@ export default createRoute(
|
||||
async (c) => {
|
||||
const allRoles = await roles.fetchAllRoles();
|
||||
|
||||
const rolesArray = allRoles.map((role) =>
|
||||
role.toJson({ viewPermissions: true }),
|
||||
const gatedData = await Promise.all(
|
||||
allRoles.map((role) =>
|
||||
processObjectValuePerms(
|
||||
role.toJson({ viewPermissions: true }),
|
||||
"obj.role",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Roles Fetched Successfully!",
|
||||
rolesArray,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { roles } from "../../managers/roles";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/role/:identifier/users */
|
||||
export default createRoute(
|
||||
@@ -16,11 +17,15 @@ export default createRoute(
|
||||
const role = await roles.fetch(identifier);
|
||||
const users = role.getUsers();
|
||||
|
||||
const usersArray = users.map((user) => user.toJson());
|
||||
const gatedData = await Promise.all(
|
||||
users.map((user) =>
|
||||
processObjectValuePerms(user.toJson(), "obj.user", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Users Fetched Successfully!",
|
||||
usersArray,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -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,69 @@
|
||||
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 { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
import { z } from "zod";
|
||||
|
||||
const productItemSchema = z
|
||||
.object({
|
||||
catalogItem: z.object({ id: z.number().int().positive() }).optional(),
|
||||
forecastDescription: z.string().optional(),
|
||||
productDescription: z.string().optional(),
|
||||
quantity: z.number().positive().optional(),
|
||||
status: z.object({ id: z.number().int().positive() }).optional(),
|
||||
productClass: z.string().optional(),
|
||||
forecastType: z.string().optional(),
|
||||
revenue: z.number().optional(),
|
||||
cost: z.number().optional(),
|
||||
includeFlag: z.boolean().optional(),
|
||||
linkFlag: z.boolean().optional(),
|
||||
recurringFlag: z.boolean().optional(),
|
||||
taxableFlag: z.boolean().optional(),
|
||||
recurringRevenue: z.number().optional(),
|
||||
recurringCost: z.number().optional(),
|
||||
cycles: z.number().int().min(0).optional(),
|
||||
sequenceNumber: z.number().int().min(0).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const addProductSchema = z.union([
|
||||
productItemSchema,
|
||||
z.array(productItemSchema).min(1, "At least one product is required"),
|
||||
]);
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/products */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/products"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const validated = addProductSchema.parse(body);
|
||||
const inputItems = Array.isArray(validated) ? validated : [validated];
|
||||
|
||||
// Gate each submitted field against user permissions.
|
||||
// Only fields the user has permission for are forwarded to ConnectWise.
|
||||
const user = c.get("user");
|
||||
const gatedItems = await Promise.all(
|
||||
inputItems.map((item) =>
|
||||
processObjectValuePerms(item, "sales.opportunity.product.field", user),
|
||||
),
|
||||
);
|
||||
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
const created = await item.addProducts(gatedItems);
|
||||
|
||||
const isBatch = Array.isArray(body);
|
||||
const response = apiResponse.created(
|
||||
isBatch
|
||||
? `${created.length} product(s) added to opportunity successfully!`
|
||||
: "Product added to opportunity successfully!",
|
||||
isBatch ? created.map((p) => p.toJson()) : created[0]!.toJson(),
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.add"] }),
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
/* 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 data = await item.fetchContacts();
|
||||
|
||||
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,47 @@
|
||||
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 { resolveMember } from "../../../modules/cw-utils/members/memberCache";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/notes */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/notes"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const schema = z.object({
|
||||
text: z.string().min(1, "Note text is required"),
|
||||
flagged: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const data = schema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
const user = c.get("user");
|
||||
|
||||
const created = await item.addNote(data.text, user.login, {
|
||||
flagged: data.flagged,
|
||||
});
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Opportunity note created successfully!",
|
||||
{
|
||||
id: created.id,
|
||||
text: created.text,
|
||||
type: created.type
|
||||
? { id: created.type.id, name: created.type.name }
|
||||
: null,
|
||||
flagged: created.flagged,
|
||||
enteredBy: await resolveMember(created.enteredBy),
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.note.create"] }),
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
|
||||
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
export default createRoute(
|
||||
"delete",
|
||||
["/opportunities/:identifier/notes/:noteId"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const noteId = Number(c.req.param("noteId"));
|
||||
|
||||
if (isNaN(noteId))
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidNoteId",
|
||||
message: "Note ID must be a number",
|
||||
});
|
||||
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
await item.deleteNote(noteId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity note deleted successfully!",
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.note.delete"] }),
|
||||
);
|
||||
@@ -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 { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* 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);
|
||||
|
||||
// Eagerly load site data so toJson() includes full site info
|
||||
await item.fetchSite();
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
item.toJson(),
|
||||
"obj.opportunity",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity fetched successfully!",
|
||||
gatedData,
|
||||
);
|
||||
|
||||
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 GenericError from "../../../Errors/GenericError";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/notes/:noteId"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const noteId = Number(c.req.param("noteId"));
|
||||
|
||||
if (isNaN(noteId))
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidNoteId",
|
||||
message: "Note ID must be a number",
|
||||
});
|
||||
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
const data = await item.fetchNote(noteId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity note 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";
|
||||
|
||||
/* 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 data = await item.fetchNotes();
|
||||
|
||||
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";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/products */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/products"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
|
||||
const data = await item.fetchProducts();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity products fetched successfully!",
|
||||
data.map((p) => p.toJson()),
|
||||
);
|
||||
|
||||
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,37 @@
|
||||
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 { z } from "zod";
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/:identifier/products/sequence"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const schema = z.object({
|
||||
orderedIds: z
|
||||
.array(z.number().int().positive())
|
||||
.min(1, "At least one forecast item ID is required"),
|
||||
});
|
||||
|
||||
const { orderedIds } = schema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
const updated = await item.resequenceProducts(orderedIds);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Product sequence updated successfully!",
|
||||
{
|
||||
products: updated.map((p) => p.toJson()),
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { resolveMember } from "../../../modules/cw-utils/members/memberCache";
|
||||
import { z } from "zod";
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/:identifier/notes/:noteId"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const noteId = Number(c.req.param("noteId"));
|
||||
|
||||
if (isNaN(noteId))
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidNoteId",
|
||||
message: "Note ID must be a number",
|
||||
});
|
||||
|
||||
const body = await c.req.json();
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
text: z.string().min(1).optional(),
|
||||
flagged: z.boolean().optional(),
|
||||
})
|
||||
.refine((d) => d.text !== undefined || d.flagged !== undefined, {
|
||||
message: "At least one of 'text' or 'flagged' must be provided",
|
||||
});
|
||||
|
||||
const data = schema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
const updated = await item.updateNote(noteId, data);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity note updated successfully!",
|
||||
{
|
||||
id: updated.id,
|
||||
text: updated.text,
|
||||
type: updated.type
|
||||
? { id: updated.type.id, name: updated.type.name }
|
||||
: null,
|
||||
flagged: updated.flagged,
|
||||
enteredBy: await resolveMember(updated.enteredBy),
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.note.update"] }),
|
||||
);
|
||||
@@ -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,54 @@
|
||||
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 { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* 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 = search
|
||||
? await opportunities.searchCount(search, { includeClosed })
|
||||
: await opportunities.count({ openOnly: !includeClosed });
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
data.map((item) =>
|
||||
processObjectValuePerms(
|
||||
item.toJson(),
|
||||
"obj.opportunity",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunities fetched successfully!",
|
||||
gatedData,
|
||||
{
|
||||
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,20 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { QUOTE_STATUSES } from "../../types/QuoteStatuses";
|
||||
|
||||
/* GET /v1/sales/opportunity-types */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunity-types"],
|
||||
|
||||
async (c) => {
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity Types Fetched Successfully!",
|
||||
QUOTE_STATUSES,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
import { default as fetchAll } from "./fetchAll";
|
||||
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
|
||||
import { default as count } from "./count";
|
||||
import { default as fetch } from "./[id]/fetch";
|
||||
import { default as refresh } from "./[id]/refresh";
|
||||
import { default as products } from "./[id]/products";
|
||||
import { default as addProduct } from "./[id]/addProduct";
|
||||
import { default as resequenceProducts } from "./[id]/resequenceProducts";
|
||||
import { default as notes } from "./[id]/notes";
|
||||
import { default as fetchNote } from "./[id]/fetchNote";
|
||||
import { default as createNote } from "./[id]/createNote";
|
||||
import { default as updateNote } from "./[id]/updateNote";
|
||||
import { default as deleteNote } from "./[id]/deleteNote";
|
||||
import { default as contacts } from "./[id]/contacts";
|
||||
|
||||
export {
|
||||
addProduct,
|
||||
count,
|
||||
fetch,
|
||||
fetchAll,
|
||||
fetchOpportunityTypes,
|
||||
products,
|
||||
resequenceProducts,
|
||||
notes,
|
||||
fetchNote,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
contacts,
|
||||
refresh,
|
||||
};
|
||||
@@ -55,6 +55,8 @@ v1.route("/credential-type", require("./routers/credentialTypeRouter").default);
|
||||
v1.route("/role", require("./routers/roleRouter").default);
|
||||
v1.route("/permissions", require("./routers/permissionRouter").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);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/unifi/site/:id */
|
||||
export default createRoute(
|
||||
@@ -10,9 +11,16 @@ export default createRoute(
|
||||
["/site/:id"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
site,
|
||||
"obj.unifiSite",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Site Fetched Successfully!",
|
||||
site,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/unifi/sites */
|
||||
export default createRoute(
|
||||
@@ -10,9 +11,16 @@ export default createRoute(
|
||||
["/sites"],
|
||||
async (c) => {
|
||||
const sites = await unifiSites.fetchAll();
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
sites.map((site) =>
|
||||
processObjectValuePerms(site, "obj.unifiSite", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Sites Fetched Successfully!",
|
||||
sites,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -2,16 +2,20 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
// /v1/user/@me
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/@me"],
|
||||
(c) => {
|
||||
const response = apiResponse.successful(
|
||||
"Fetched user.",
|
||||
async (c) => {
|
||||
const gatedData = await processObjectValuePerms(
|
||||
c.get("user")?.toJson(),
|
||||
"obj.user",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful("Fetched user.", gatedData);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ scopes: ["user.read"] }),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { users } from "../../managers/users";
|
||||
import GenericError from "../../Errors/GenericError";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/user/users/:identifier */
|
||||
export default createRoute(
|
||||
@@ -21,9 +22,15 @@ export default createRoute(
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const gatedData = await processObjectValuePerms(
|
||||
user.toJson(),
|
||||
"obj.user",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"User Fetched Successfully!",
|
||||
user.toJson(),
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { users } from "../../managers/users";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/user/users */
|
||||
export default createRoute(
|
||||
@@ -11,11 +12,16 @@ export default createRoute(
|
||||
|
||||
async (c) => {
|
||||
const allUsers = await users.fetchAllUsers();
|
||||
const usersArray = allUsers.map((u) => u.toJson());
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
allUsers.map((u) =>
|
||||
processObjectValuePerms(u.toJson(), "obj.user", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Users Fetched Successfully!",
|
||||
usersArray,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { users } from "../../managers/users";
|
||||
import GenericError from "../../Errors/GenericError";
|
||||
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/user/users/:identifier/roles */
|
||||
export default createRoute(
|
||||
@@ -22,11 +23,20 @@ export default createRoute(
|
||||
});
|
||||
|
||||
const roles = await user.fetchRoles();
|
||||
const rolesArray = roles.map((r) => r.toJson({ viewPermissions: true }));
|
||||
|
||||
const gatedData = await Promise.all(
|
||||
roles.map((r) =>
|
||||
processObjectValuePerms(
|
||||
r.toJson({ viewPermissions: true }),
|
||||
"obj.role",
|
||||
c.get("user"),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"User Roles Fetched Successfully!",
|
||||
rolesArray,
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Server } from "socket.io";
|
||||
import { Server as Engine } from "@socket.io/bun-engine";
|
||||
import axios from "axios";
|
||||
import { UnifiClient } from "./modules/unifi-api/UnifiClient";
|
||||
import Redis from "ioredis";
|
||||
|
||||
const connectionString = `${process.env.DATABASE_URL}`;
|
||||
const adapter = new PrismaPg({ connectionString });
|
||||
@@ -17,9 +18,15 @@ interface EnvKey {
|
||||
// ENV CONSTANTS
|
||||
|
||||
export const PORT = process.env.PORT;
|
||||
export const API_BASE_URL =
|
||||
process.env.API_BASE_URL || `http://localhost:${PORT || 3000}`;
|
||||
|
||||
export const prisma = new PrismaClient({ adapter });
|
||||
|
||||
// Redis Client
|
||||
|
||||
export const redis = new Redis(process.env.REDIS_URL!);
|
||||
|
||||
export const sessionDuration = 30 * 24 * 60 * 60000;
|
||||
export const accessTokenDuration = "10min";
|
||||
export const refreshTokenDuration = "30d";
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
CWActivity,
|
||||
CWActivityCustomField,
|
||||
CWPatchOperation,
|
||||
CWCreateActivity,
|
||||
} from "../modules/cw-utils/activities/activity.types";
|
||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import { fetchActivity } from "../modules/cw-utils/activities/fetchActivity";
|
||||
|
||||
/**
|
||||
* Activity Controller
|
||||
*
|
||||
* Domain model class that encapsulates a ConnectWise Activity entity.
|
||||
* Activities are not persisted locally — all data is sourced directly
|
||||
* from the ConnectWise API.
|
||||
*/
|
||||
export class ActivityController {
|
||||
public readonly cwActivityId: number;
|
||||
public name: string;
|
||||
public notes: string | null;
|
||||
|
||||
public typeName: string | null;
|
||||
public typeCwId: number | null;
|
||||
public statusName: string | null;
|
||||
public statusCwId: number | null;
|
||||
|
||||
public companyCwId: number | null;
|
||||
public companyName: string | null;
|
||||
public companyIdentifier: string | null;
|
||||
public contactCwId: number | null;
|
||||
public contactName: string | null;
|
||||
|
||||
public phoneNumber: string | null;
|
||||
public email: string | null;
|
||||
|
||||
public opportunityCwId: number | null;
|
||||
public opportunityName: string | null;
|
||||
public ticketCwId: number | null;
|
||||
public ticketName: string | null;
|
||||
public agreementCwId: number | null;
|
||||
public agreementName: string | null;
|
||||
public campaignCwId: number | null;
|
||||
public campaignName: string | null;
|
||||
|
||||
public assignToCwId: number | null;
|
||||
public assignToName: string | null;
|
||||
public assignToIdentifier: string | null;
|
||||
|
||||
public scheduleStatusCwId: number | null;
|
||||
public scheduleStatusName: string | null;
|
||||
public reminderCwId: number | null;
|
||||
public reminderName: string | null;
|
||||
public whereCwId: number | null;
|
||||
public whereName: string | null;
|
||||
|
||||
public dateStart: Date | null;
|
||||
public dateEnd: Date | null;
|
||||
public notifyFlag: boolean;
|
||||
|
||||
public currencyCwId: number | null;
|
||||
public currencyName: string | null;
|
||||
|
||||
public mobileGuid: string | null;
|
||||
public customFields: CWActivityCustomField[];
|
||||
|
||||
public cwLastUpdated: Date | null;
|
||||
public cwDateEntered: Date | null;
|
||||
public cwEnteredBy: string | null;
|
||||
public cwUpdatedBy: string | null;
|
||||
|
||||
constructor(data: CWActivity) {
|
||||
this.cwActivityId = data.id;
|
||||
this.name = data.name;
|
||||
this.notes = data.notes ?? null;
|
||||
|
||||
this.typeName = data.type?.name ?? null;
|
||||
this.typeCwId = data.type?.id ?? null;
|
||||
this.statusName = data.status?.name ?? null;
|
||||
this.statusCwId = data.status?.id ?? null;
|
||||
|
||||
this.companyCwId = data.company?.id ?? null;
|
||||
this.companyName = data.company?.name ?? null;
|
||||
this.companyIdentifier = data.company?.identifier ?? null;
|
||||
this.contactCwId = data.contact?.id ?? null;
|
||||
this.contactName = data.contact?.name ?? null;
|
||||
|
||||
this.phoneNumber = data.phoneNumber ?? null;
|
||||
this.email = data.email ?? null;
|
||||
|
||||
this.opportunityCwId = data.opportunity?.id ?? null;
|
||||
this.opportunityName = data.opportunity?.name ?? null;
|
||||
this.ticketCwId = data.ticket?.id ?? null;
|
||||
this.ticketName = data.ticket?.name ?? null;
|
||||
this.agreementCwId = data.agreement?.id ?? null;
|
||||
this.agreementName = data.agreement?.name ?? null;
|
||||
this.campaignCwId = data.campaign?.id ?? null;
|
||||
this.campaignName = data.campaign?.name ?? null;
|
||||
|
||||
this.assignToCwId = data.assignTo?.id ?? null;
|
||||
this.assignToName = data.assignTo?.name ?? null;
|
||||
this.assignToIdentifier = data.assignTo?.identifier ?? null;
|
||||
|
||||
this.scheduleStatusCwId = data.scheduleStatus?.id ?? null;
|
||||
this.scheduleStatusName = data.scheduleStatus?.name ?? null;
|
||||
this.reminderCwId = data.reminder?.id ?? null;
|
||||
this.reminderName = data.reminder?.name ?? null;
|
||||
this.whereCwId = data.where?.id ?? null;
|
||||
this.whereName = data.where?.name ?? null;
|
||||
|
||||
this.dateStart = data.dateStart ? new Date(data.dateStart) : null;
|
||||
this.dateEnd = data.dateEnd ? new Date(data.dateEnd) : null;
|
||||
this.notifyFlag = data.notifyFlag ?? false;
|
||||
|
||||
this.currencyCwId = data.currency?.id ?? null;
|
||||
this.currencyName = data.currency?.name ?? null;
|
||||
|
||||
this.mobileGuid = data.mobileGuid ?? null;
|
||||
this.customFields = data.customFields ?? [];
|
||||
|
||||
this.cwLastUpdated = data._info?.lastUpdated
|
||||
? new Date(data._info.lastUpdated)
|
||||
: null;
|
||||
this.cwDateEntered = data._info?.dateEntered
|
||||
? new Date(data._info.dateEntered)
|
||||
: null;
|
||||
this.cwEnteredBy = data._info?.enteredBy ?? null;
|
||||
this.cwUpdatedBy = data._info?.updatedBy ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh from ConnectWise
|
||||
*
|
||||
* Fetches the latest activity data from CW and returns
|
||||
* a new ActivityController instance with updated state.
|
||||
*/
|
||||
public async refreshFromCW(): Promise<ActivityController> {
|
||||
const cwData = await fetchActivity(this.cwActivityId);
|
||||
return new ActivityController(cwData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw CW data
|
||||
*
|
||||
* Returns the raw ConnectWise activity object.
|
||||
*/
|
||||
public async fetchCwData(): Promise<CWActivity> {
|
||||
return fetchActivity(this.cwActivityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update in ConnectWise
|
||||
*
|
||||
* Applies JSON Patch operations to this activity in ConnectWise
|
||||
* and returns a new controller with the updated data.
|
||||
*/
|
||||
public async update(
|
||||
operations: CWPatchOperation[],
|
||||
): Promise<ActivityController> {
|
||||
const updated = await activityCw.update(this.cwActivityId, operations);
|
||||
return new ActivityController(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete from ConnectWise
|
||||
*
|
||||
* Deletes this activity in ConnectWise.
|
||||
*/
|
||||
public async delete(): Promise<void> {
|
||||
await activityCw.delete(this.cwActivityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Activity (static factory)
|
||||
*
|
||||
* Creates a new activity in ConnectWise and returns a controller instance.
|
||||
*/
|
||||
public static async create(
|
||||
data: CWCreateActivity,
|
||||
): Promise<ActivityController> {
|
||||
const created = await activityCw.create(data);
|
||||
return new ActivityController(created);
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Serializes the activity into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
return {
|
||||
cwActivityId: this.cwActivityId,
|
||||
name: this.name,
|
||||
notes: this.notes,
|
||||
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
|
||||
status: this.statusCwId
|
||||
? { id: this.statusCwId, name: this.statusName }
|
||||
: null,
|
||||
company: this.companyCwId
|
||||
? {
|
||||
id: this.companyCwId,
|
||||
identifier: this.companyIdentifier,
|
||||
name: this.companyName,
|
||||
}
|
||||
: null,
|
||||
contact: this.contactCwId
|
||||
? { id: this.contactCwId, name: this.contactName }
|
||||
: null,
|
||||
phoneNumber: this.phoneNumber,
|
||||
email: this.email,
|
||||
opportunity: this.opportunityCwId
|
||||
? { id: this.opportunityCwId, name: this.opportunityName }
|
||||
: null,
|
||||
ticket: this.ticketCwId
|
||||
? { id: this.ticketCwId, name: this.ticketName }
|
||||
: null,
|
||||
agreement: this.agreementCwId
|
||||
? { id: this.agreementCwId, name: this.agreementName }
|
||||
: null,
|
||||
campaign: this.campaignCwId
|
||||
? { id: this.campaignCwId, name: this.campaignName }
|
||||
: null,
|
||||
assignTo: this.assignToCwId
|
||||
? {
|
||||
id: this.assignToCwId,
|
||||
identifier: this.assignToIdentifier,
|
||||
name: this.assignToName,
|
||||
}
|
||||
: null,
|
||||
scheduleStatus: this.scheduleStatusCwId
|
||||
? { id: this.scheduleStatusCwId, name: this.scheduleStatusName }
|
||||
: null,
|
||||
reminder: this.reminderCwId
|
||||
? { id: this.reminderCwId, name: this.reminderName }
|
||||
: null,
|
||||
where: this.whereCwId
|
||||
? { id: this.whereCwId, name: this.whereName }
|
||||
: null,
|
||||
dateStart: this.dateStart,
|
||||
dateEnd: this.dateEnd,
|
||||
notifyFlag: this.notifyFlag,
|
||||
currency: this.currencyCwId
|
||||
? { id: this.currencyCwId, name: this.currencyName }
|
||||
: null,
|
||||
mobileGuid: this.mobileGuid,
|
||||
customFields: this.customFields,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
cwDateEntered: this.cwDateEntered,
|
||||
cwEnteredBy: this.cwEnteredBy,
|
||||
cwUpdatedBy: this.cwUpdatedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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 category: string | null;
|
||||
public categoryCwId: number | null;
|
||||
public subcategory: string | null;
|
||||
public subcategoryCwId: number | 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.category = itemData.category;
|
||||
this.categoryCwId = itemData.categoryCwId;
|
||||
this.subcategory = itemData.subcategory;
|
||||
this.subcategoryCwId = itemData.subcategoryCwId;
|
||||
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,
|
||||
category: this.category,
|
||||
categoryCwId: this.categoryCwId,
|
||||
subcategory: this.subcategory,
|
||||
subcategoryCwId: this.subcategoryCwId,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Company } from "../../generated/prisma/client";
|
||||
import { connectWiseApi } from "../constants";
|
||||
import { fetchCwCompanyById } from "../modules/cw-utils/fetchCompany";
|
||||
import { fetchCompanyConfigurations } from "../modules/cw-utils/configurations/fetchCompanyConfigurations";
|
||||
import { updateCwInternalCompany } from "../modules/cw-utils/updateCompany";
|
||||
import {
|
||||
fetchCompanySites,
|
||||
fetchCompanySite,
|
||||
serializeCwSite,
|
||||
} from "../modules/cw-utils/sites/companySites";
|
||||
import { Company as CWCompany, Contact } from "../types/ConnectWiseTypes";
|
||||
|
||||
/**
|
||||
@@ -16,9 +22,9 @@ export class CompanyController {
|
||||
public name: string;
|
||||
public readonly cw_Identifier: string;
|
||||
public readonly cw_CompanyId: number;
|
||||
public readonly cw_Data?: {
|
||||
public cw_Data?: {
|
||||
company: CWCompany;
|
||||
defaultContact: Contact;
|
||||
defaultContact: Contact | null;
|
||||
allContacts: Contact[];
|
||||
};
|
||||
|
||||
@@ -30,6 +36,38 @@ export class CompanyController {
|
||||
this.cw_Data = cwData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate CW Data
|
||||
*
|
||||
* Fetches and populates the full ConnectWise company data
|
||||
* (company, default contact, all contacts) if not already loaded.
|
||||
*
|
||||
* @returns {ThisType}
|
||||
*/
|
||||
public async hydrateCwData() {
|
||||
if (this.cw_Data) return this;
|
||||
|
||||
const cwCompany = await fetchCwCompanyById(this.cw_CompanyId);
|
||||
if (!cwCompany) return this;
|
||||
|
||||
const contactHref = cwCompany.defaultContact?._info?.contact_href;
|
||||
const defaultContactData = contactHref
|
||||
? await connectWiseApi.get(contactHref)
|
||||
: undefined;
|
||||
|
||||
const allContactsData = await connectWiseApi.get(
|
||||
`${cwCompany._info.contacts_href}&pageSize=1000`,
|
||||
);
|
||||
|
||||
this.cw_Data = {
|
||||
company: cwCompany,
|
||||
defaultContact: defaultContactData?.data ?? null,
|
||||
allContacts: allContactsData.data,
|
||||
};
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Internal Company Data from ConnectWise
|
||||
*
|
||||
@@ -71,6 +109,30 @@ export class CompanyController {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company Sites
|
||||
*
|
||||
* Retrieves all sites for this company from ConnectWise
|
||||
* and returns them as serialized site objects.
|
||||
*/
|
||||
public async fetchSites() {
|
||||
const sites = await fetchCompanySites(this.cw_CompanyId);
|
||||
return sites.map(serializeCwSite);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company Site by ID
|
||||
*
|
||||
* Retrieves a single site by its ConnectWise site ID
|
||||
* and returns a serialized site object.
|
||||
*
|
||||
* @param cwSiteId - The ConnectWise site ID
|
||||
*/
|
||||
public async fetchSite(cwSiteId: number) {
|
||||
const site = await fetchCompanySite(this.cw_CompanyId, cwSiteId);
|
||||
return serializeCwSite(site);
|
||||
}
|
||||
|
||||
public toJson(opts?: {
|
||||
includeAddress: boolean;
|
||||
includePrimaryContact: boolean;
|
||||
@@ -96,23 +158,25 @@ export class CompanyController {
|
||||
},
|
||||
primaryContact: !opts?.includePrimaryContact
|
||||
? undefined
|
||||
: {
|
||||
firstName: this.cw_Data?.defaultContact.firstName,
|
||||
lastName: this.cw_Data?.defaultContact.lastName,
|
||||
cwId: this.cw_Data?.defaultContact.id,
|
||||
inactive: this.cw_Data?.defaultContact.inactiveFlag,
|
||||
title: this.cw_Data?.defaultContact.title,
|
||||
phone: this.cw_Data?.defaultContact.defaultPhoneNbr,
|
||||
email: (() => {
|
||||
if (!this.cw_Data?.defaultContact.communicationItems)
|
||||
return null;
|
||||
return (
|
||||
this.cw_Data?.defaultContact.communicationItems.find(
|
||||
(v) => v.type.name === "Email",
|
||||
)?.value ?? null
|
||||
);
|
||||
})(),
|
||||
},
|
||||
: this.cw_Data?.defaultContact
|
||||
? {
|
||||
firstName: this.cw_Data.defaultContact.firstName,
|
||||
lastName: this.cw_Data.defaultContact.lastName,
|
||||
cwId: this.cw_Data.defaultContact.id,
|
||||
inactive: this.cw_Data.defaultContact.inactiveFlag,
|
||||
title: this.cw_Data.defaultContact.title,
|
||||
phone: this.cw_Data.defaultContact.defaultPhoneNbr,
|
||||
email: (() => {
|
||||
if (!this.cw_Data?.defaultContact?.communicationItems)
|
||||
return null;
|
||||
return (
|
||||
this.cw_Data.defaultContact.communicationItems.find(
|
||||
(v) => v.type.name === "Email",
|
||||
)?.value ?? null
|
||||
);
|
||||
})(),
|
||||
}
|
||||
: null,
|
||||
allContacts: !opts?.includeAllContacts
|
||||
? undefined
|
||||
: this.cw_Data?.allContacts.map((contact) => ({
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { CWForecastItem } from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
|
||||
/**
|
||||
* Forecast Product Controller
|
||||
*
|
||||
* Domain model class that encapsulates a ConnectWise Forecast Item (product/
|
||||
* revenue line item on an opportunity). Forecast products are not persisted
|
||||
* locally — all data is sourced directly from the ConnectWise API.
|
||||
*/
|
||||
export class ForecastProductController {
|
||||
public readonly cwForecastId: number;
|
||||
public forecastDescription: string;
|
||||
|
||||
public opportunityCwId: number | null;
|
||||
public opportunityName: string | null;
|
||||
|
||||
public quantity: number;
|
||||
|
||||
public statusCwId: number | null;
|
||||
public statusName: string | null;
|
||||
|
||||
public catalogItemCwId: number | null;
|
||||
public catalogItemIdentifier: string | null;
|
||||
|
||||
public productDescription: string;
|
||||
public productClass: string;
|
||||
public forecastType: string;
|
||||
|
||||
public revenue: number;
|
||||
public cost: number;
|
||||
public margin: number;
|
||||
public percentage: number;
|
||||
|
||||
public includeFlag: boolean;
|
||||
public linkFlag: boolean;
|
||||
public recurringFlag: boolean;
|
||||
public taxableFlag: boolean;
|
||||
|
||||
public recurringRevenue: number;
|
||||
public recurringCost: number;
|
||||
public cycles: number;
|
||||
|
||||
public sequenceNumber: number;
|
||||
public subNumber: number;
|
||||
public quoteWerksQuantity: number;
|
||||
|
||||
public cwLastUpdated: Date | null;
|
||||
public cwUpdatedBy: string | null;
|
||||
|
||||
// Cancellation data (from procurement products endpoint)
|
||||
public cancelledFlag: boolean;
|
||||
public quantityCancelled: number;
|
||||
public cancelledReason: string | null;
|
||||
public cancelledBy: number | null;
|
||||
public cancelledDate: Date | null;
|
||||
|
||||
// Internal inventory data (from local CatalogItem database)
|
||||
public onHand: number | null;
|
||||
public inStock: boolean | null;
|
||||
|
||||
constructor(data: CWForecastItem) {
|
||||
this.cwForecastId = data.id;
|
||||
this.forecastDescription = data.forecastDescription;
|
||||
|
||||
this.opportunityCwId = data.opportunity?.id ?? null;
|
||||
this.opportunityName = data.opportunity?.name ?? null;
|
||||
|
||||
this.quantity = data.quantity;
|
||||
|
||||
this.statusCwId = data.status?.id ?? null;
|
||||
this.statusName = data.status?.name ?? null;
|
||||
|
||||
this.catalogItemCwId = data.catalogItem?.id ?? null;
|
||||
this.catalogItemIdentifier = data.catalogItem?.identifier ?? null;
|
||||
|
||||
this.productDescription = data.productDescription;
|
||||
this.productClass = data.productClass;
|
||||
this.forecastType = data.forecastType;
|
||||
|
||||
this.revenue = data.revenue;
|
||||
this.cost = data.cost;
|
||||
this.margin = data.margin;
|
||||
this.percentage = data.percentage;
|
||||
|
||||
this.includeFlag = data.includeFlag ?? false;
|
||||
this.linkFlag = data.linkFlag ?? false;
|
||||
this.recurringFlag = data.recurringFlag ?? false;
|
||||
this.taxableFlag = data.taxableFlag ?? false;
|
||||
|
||||
this.recurringRevenue = data.recurringRevenue ?? 0;
|
||||
this.recurringCost = data.recurringCost ?? 0;
|
||||
this.cycles = data.cycles ?? 0;
|
||||
|
||||
this.sequenceNumber = data.sequenceNumber ?? 0;
|
||||
this.subNumber = data.subNumber ?? 0;
|
||||
this.quoteWerksQuantity = data.quoteWerksQuantity ?? 0;
|
||||
|
||||
this.cwLastUpdated = data._info?.lastUpdated
|
||||
? new Date(data._info.lastUpdated)
|
||||
: null;
|
||||
this.cwUpdatedBy = data._info?.updatedBy ?? null;
|
||||
|
||||
// Cancellation defaults — enriched later via applyCancellationData()
|
||||
this.cancelledFlag = false;
|
||||
this.quantityCancelled = 0;
|
||||
this.cancelledReason = null;
|
||||
this.cancelledBy = null;
|
||||
this.cancelledDate = null;
|
||||
|
||||
// Inventory defaults — enriched later via applyInventoryData()
|
||||
this.onHand = null;
|
||||
this.inStock = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Cancellation Data
|
||||
*
|
||||
* Enriches this forecast product with cancellation data from the
|
||||
* procurement products endpoint.
|
||||
*/
|
||||
public applyCancellationData(data: {
|
||||
cancelledFlag?: boolean;
|
||||
quantityCancelled?: number;
|
||||
cancelledReason?: string;
|
||||
cancelledBy?: number;
|
||||
cancelledDate?: string;
|
||||
}): void {
|
||||
this.cancelledFlag = data.cancelledFlag ?? false;
|
||||
this.quantityCancelled = data.quantityCancelled ?? 0;
|
||||
this.cancelledReason = data.cancelledReason ?? null;
|
||||
this.cancelledBy = data.cancelledBy ?? null;
|
||||
this.cancelledDate = data.cancelledDate
|
||||
? new Date(data.cancelledDate)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Inventory Data
|
||||
*
|
||||
* Enriches this forecast product with internal inventory data from
|
||||
* the local CatalogItem database.
|
||||
*/
|
||||
public applyInventoryData(data: { onHand: number }): void {
|
||||
this.onHand = data.onHand;
|
||||
this.inStock = data.onHand > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profit
|
||||
*
|
||||
* Returns the calculated profit (revenue - cost).
|
||||
*/
|
||||
public get profit(): number {
|
||||
return this.revenue - this.cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelled
|
||||
*
|
||||
* Returns true if the forecast item has been cancelled (fully or partially).
|
||||
*/
|
||||
public get cancelled(): boolean {
|
||||
return this.cancelledFlag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellation Type
|
||||
*
|
||||
* Returns the type of cancellation:
|
||||
* - `"full"` — all units have been cancelled (`quantityCancelled >= quantity`)
|
||||
* - `"partial"` — some units cancelled but not all
|
||||
* - `null` — not cancelled
|
||||
*/
|
||||
public get cancellationType(): "full" | "partial" | null {
|
||||
if (!this.cancelledFlag || this.quantityCancelled <= 0) return null;
|
||||
return this.quantityCancelled >= this.quantity ? "full" : "partial";
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Serializes the forecast product into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
return {
|
||||
id: this.cwForecastId,
|
||||
forecastDescription: this.forecastDescription,
|
||||
opportunity: this.opportunityCwId
|
||||
? { id: this.opportunityCwId, name: this.opportunityName }
|
||||
: null,
|
||||
quantity: this.quantity,
|
||||
status: this.statusCwId
|
||||
? { id: this.statusCwId, name: this.statusName }
|
||||
: null,
|
||||
cancelled: this.cancelled,
|
||||
cancellationType: this.cancellationType,
|
||||
quantityCancelled: this.quantityCancelled,
|
||||
cancelledReason: this.cancelledReason,
|
||||
cancelledDate: this.cancelledDate,
|
||||
catalogItem: this.catalogItemCwId
|
||||
? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier }
|
||||
: null,
|
||||
productDescription: this.productDescription,
|
||||
productClass: this.productClass,
|
||||
forecastType: this.forecastType,
|
||||
revenue: this.revenue,
|
||||
cost: this.cost,
|
||||
margin: this.margin,
|
||||
profit: this.profit,
|
||||
percentage: this.percentage,
|
||||
includeFlag: this.includeFlag,
|
||||
linkFlag: this.linkFlag,
|
||||
recurringFlag: this.recurringFlag,
|
||||
taxableFlag: this.taxableFlag,
|
||||
recurringRevenue: this.recurringRevenue,
|
||||
recurringCost: this.recurringCost,
|
||||
cycles: this.cycles,
|
||||
sequenceNumber: this.sequenceNumber,
|
||||
subNumber: this.subNumber,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
cwUpdatedBy: this.cwUpdatedBy,
|
||||
onHand: this.onHand,
|
||||
inStock: this.inStock,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
import { Company, Opportunity } from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { CompanyController } from "./CompanyController";
|
||||
import { ActivityController } from "./ActivityController";
|
||||
import { fetchOpportunity } from "../modules/cw-utils/opportunities/fetchOpportunity";
|
||||
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import {
|
||||
fetchCompanySite,
|
||||
serializeCwSite,
|
||||
} from "../modules/cw-utils/sites/companySites";
|
||||
import {
|
||||
CWCustomField,
|
||||
CWForecastItemCreate,
|
||||
CWOpportunity,
|
||||
CWOpportunityNote,
|
||||
} from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
import { resolveMember } from "../modules/cw-utils/members/memberCache";
|
||||
import { ForecastProductController } from "./ForecastProductController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Local product display order — array of CW forecast item IDs.
|
||||
// When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
|
||||
public productSequence: number[];
|
||||
|
||||
public readonly createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
private _company: CompanyController | null = null;
|
||||
private _siteData: ReturnType<typeof serializeCwSite> | null = null;
|
||||
private _customFields: CWCustomField[] | null = null;
|
||||
private _activities: ActivityController[] | null = null;
|
||||
|
||||
constructor(
|
||||
data: Opportunity & { company?: Company | null },
|
||||
opts?: {
|
||||
company?: CompanyController;
|
||||
customFields?: CWCustomField[];
|
||||
activities?: ActivityController[];
|
||||
},
|
||||
) {
|
||||
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.productSequence = data.productSequence;
|
||||
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
|
||||
this._company =
|
||||
opts?.company ??
|
||||
(data.company ? new CompanyController(data.company) : null);
|
||||
|
||||
this._customFields = opts?.customFields ?? null;
|
||||
this._activities = opts?.activities ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company
|
||||
*
|
||||
* Lazily loads the associated CompanyController from the database
|
||||
* if not already loaded via the Prisma include.
|
||||
*
|
||||
* @returns {Promise<CompanyController | null>}
|
||||
*/
|
||||
public async fetchCompany(): Promise<CompanyController | null> {
|
||||
if (this._company) {
|
||||
await this._company.hydrateCwData();
|
||||
return this._company;
|
||||
}
|
||||
if (!this.companyId) return null;
|
||||
|
||||
const companyData = await prisma.company.findUnique({
|
||||
where: { id: this.companyId },
|
||||
});
|
||||
|
||||
if (!companyData) return null;
|
||||
|
||||
this._company = new CompanyController(companyData);
|
||||
await this._company.hydrateCwData();
|
||||
return this._company;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Site
|
||||
*
|
||||
* Fetches the full site details (address, phone, flags) from ConnectWise
|
||||
* for the site associated with this opportunity.
|
||||
* Requires both companyCwId and siteCwId to be set.
|
||||
*
|
||||
* @returns Serialized site object or null
|
||||
*/
|
||||
public async fetchSite() {
|
||||
if (this._siteData) return this._siteData;
|
||||
if (!this.companyCwId || !this.siteCwId) return null;
|
||||
|
||||
const cwSite = await fetchCompanySite(this.companyCwId, this.siteCwId);
|
||||
this._siteData = serializeCwSite(cwSite);
|
||||
return this._siteData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Contacts
|
||||
*
|
||||
* Fetches contacts associated with this opportunity from ConnectWise
|
||||
* and returns a serialized array.
|
||||
*/
|
||||
public async fetchContacts() {
|
||||
const contacts = await opportunityCw.fetchContacts(this.cwOpportunityId);
|
||||
|
||||
return 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,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Notes
|
||||
*
|
||||
* Fetches notes associated with this opportunity from ConnectWise
|
||||
* and returns a serialized array.
|
||||
*/
|
||||
public async fetchNotes() {
|
||||
const notes = await opportunityCw.fetchNotes(this.cwOpportunityId);
|
||||
|
||||
return Promise.all(
|
||||
notes.map(async (n) => ({
|
||||
id: n.id,
|
||||
text: n.text,
|
||||
type: n.type ? { id: n.type.id, name: n.type.name } : null,
|
||||
flagged: n.flagged,
|
||||
dateEntered: n._info?.lastUpdated
|
||||
? new Date(n._info.lastUpdated)
|
||||
: null,
|
||||
enteredBy: await resolveMember(n.enteredBy),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Single Note
|
||||
*
|
||||
* Fetches a single note by its ID from ConnectWise.
|
||||
*
|
||||
* @param noteId - The CW note ID
|
||||
*/
|
||||
public async fetchNote(noteId: number) {
|
||||
const note = await opportunityCw.fetchNote(this.cwOpportunityId, noteId);
|
||||
|
||||
return {
|
||||
id: note.id,
|
||||
text: note.text,
|
||||
type: note.type ? { id: note.type.id, name: note.type.name } : null,
|
||||
flagged: note.flagged,
|
||||
enteredBy: await resolveMember(note.enteredBy),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Activities
|
||||
*
|
||||
* Fetches activities associated with this opportunity from ConnectWise
|
||||
* and returns an array of ActivityController instances.
|
||||
* Results are cached after the first call.
|
||||
*/
|
||||
public async fetchActivities(): Promise<ActivityController[]> {
|
||||
if (this._activities) return this._activities;
|
||||
|
||||
const collection = await activityCw.fetchByOpportunity(
|
||||
this.cwOpportunityId,
|
||||
);
|
||||
this._activities = collection.map((item) => new ActivityController(item));
|
||||
return this._activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Products
|
||||
*
|
||||
* Fetches products (forecast/revenue items) for this opportunity from
|
||||
* ConnectWise and returns ForecastProductController instances.
|
||||
*/
|
||||
public async fetchProducts(): Promise<ForecastProductController[]> {
|
||||
const [forecast, procProducts] = await Promise.all([
|
||||
opportunityCw.fetchProducts(this.cwOpportunityId),
|
||||
opportunityCw.fetchProcurementProducts(this.cwOpportunityId),
|
||||
]);
|
||||
|
||||
// Build a map of forecastDetailId → procurement product cancellation data
|
||||
const cancellationMap = new Map<number, Record<string, unknown>>();
|
||||
for (const pp of procProducts) {
|
||||
const forecastDetailId = pp.forecastDetailId as number | undefined;
|
||||
if (forecastDetailId) {
|
||||
cancellationMap.set(forecastDetailId, pp);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply local ordering if productSequence is set, otherwise fall back
|
||||
// to CW sequenceNumber.
|
||||
const forecastItems = forecast.forecastItems ?? [];
|
||||
let ordered: typeof forecastItems;
|
||||
|
||||
if (this.productSequence.length > 0) {
|
||||
const itemById = new Map(forecastItems.map((fi) => [fi.id, fi]));
|
||||
// Items in the specified order first, then any new items not yet sequenced
|
||||
const sequenced = this.productSequence
|
||||
.map((id) => itemById.get(id))
|
||||
.filter((fi): fi is NonNullable<typeof fi> => fi !== undefined);
|
||||
const sequencedIds = new Set(this.productSequence);
|
||||
const unsequenced = forecastItems
|
||||
.filter((fi) => !sequencedIds.has(fi.id))
|
||||
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
ordered = [...sequenced, ...unsequenced];
|
||||
} else {
|
||||
ordered = [...forecastItems].sort(
|
||||
(a, b) => a.sequenceNumber - b.sequenceNumber,
|
||||
);
|
||||
}
|
||||
|
||||
const controllers = ordered.map((item) => {
|
||||
const ctrl = new ForecastProductController(item);
|
||||
const procData = cancellationMap.get(item.id);
|
||||
if (procData) {
|
||||
ctrl.applyCancellationData(procData as any);
|
||||
}
|
||||
return ctrl;
|
||||
});
|
||||
|
||||
// Enrich with internal inventory data from local CatalogItem DB
|
||||
const catalogCwIds = controllers
|
||||
.map((c) => c.catalogItemCwId)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
if (catalogCwIds.length > 0) {
|
||||
const catalogItems = await prisma.catalogItem.findMany({
|
||||
where: { cwCatalogId: { in: catalogCwIds } },
|
||||
select: { cwCatalogId: true, onHand: true },
|
||||
});
|
||||
const inventoryMap = new Map(
|
||||
catalogItems.map((ci) => [ci.cwCatalogId, ci]),
|
||||
);
|
||||
for (const ctrl of controllers) {
|
||||
const inv = ctrl.catalogItemCwId
|
||||
? inventoryMap.get(ctrl.catalogItemCwId)
|
||||
: undefined;
|
||||
if (inv) ctrl.applyInventoryData(inv);
|
||||
}
|
||||
}
|
||||
|
||||
return controllers;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Opportunity Activity / Workflow Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set Internal Review
|
||||
*
|
||||
* The quote is ready to be reviewed before it is ready to be sent.
|
||||
*/
|
||||
public async setInternalReview(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Internal Approved
|
||||
*
|
||||
* The quote has been approved and is ready to be sent out.
|
||||
*/
|
||||
public async setInternalApproved(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Quote Sent
|
||||
*
|
||||
* The quote has been sent to the customer.
|
||||
*/
|
||||
public async setQuoteSent(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Quote Confirmed
|
||||
*
|
||||
* The quote has been received by the customer.
|
||||
*/
|
||||
public async setQuoteConfirmed(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Revision Needed
|
||||
*
|
||||
* The quote needs to be revised and is set to stage revision.
|
||||
*/
|
||||
public async setRevisionNeeded(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Finalized
|
||||
*
|
||||
* Locks any non-admins from modifying the quote, indicating
|
||||
* this is the final iteration of the quote.
|
||||
*/
|
||||
public async setFinalized(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert
|
||||
*
|
||||
* Converts the quote to a ticket and updates all necessary fields.
|
||||
*/
|
||||
public async convert(): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Time
|
||||
*
|
||||
* Adds time to an activity on this opportunity.
|
||||
*
|
||||
* @param activityId - The CW activity ID to add time to
|
||||
* @param user - The user identifier adding time
|
||||
*/
|
||||
public async addTime(activityId: number, user: string): Promise<void> {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Product
|
||||
*
|
||||
* Updates an existing product/line item on this opportunity via PATCH.
|
||||
*
|
||||
* @param forecastItemId - The CW forecast item ID to update
|
||||
* @param data - Key/value pairs to patch
|
||||
*/
|
||||
public async updateProduct(
|
||||
forecastItemId: number,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<ForecastProductController> {
|
||||
try {
|
||||
const updated = await opportunityCw.updateProduct(
|
||||
this.cwOpportunityId,
|
||||
forecastItemId,
|
||||
data,
|
||||
);
|
||||
return new ForecastProductController(updated);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`[updateProduct] Failed to patch forecast item ${forecastItemId} on opportunity ${this.cwOpportunityId}`,
|
||||
JSON.stringify(
|
||||
{
|
||||
data,
|
||||
status: err?.response?.status,
|
||||
statusText: err?.response?.statusText,
|
||||
responseData: err?.response?.data,
|
||||
message: err?.message,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resequence Products
|
||||
*
|
||||
* Stores the desired display order of forecast item IDs locally in
|
||||
* the database. No CW API calls are made — CW item IDs are stable
|
||||
* and ordering is applied when `fetchProducts()` is called.
|
||||
*
|
||||
* @param orderedIds - Forecast item IDs in the desired display order
|
||||
*/
|
||||
public async resequenceProducts(
|
||||
orderedIds: number[],
|
||||
): Promise<ForecastProductController[]> {
|
||||
// Validate all IDs exist in CW
|
||||
const forecast = await opportunityCw.fetchProducts(this.cwOpportunityId);
|
||||
const existingIds = new Set(
|
||||
(forecast.forecastItems ?? []).map((fi) => fi.id),
|
||||
);
|
||||
for (const id of orderedIds) {
|
||||
if (!existingIds.has(id)) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${id} not found on opportunity ${this.cwOpportunityId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the sequence locally
|
||||
await prisma.opportunity.update({
|
||||
where: { id: this.id },
|
||||
data: { productSequence: orderedIds },
|
||||
});
|
||||
this.productSequence = orderedIds;
|
||||
|
||||
// Return items in the new order
|
||||
return this.fetchProducts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Products
|
||||
*
|
||||
* Adds one or more products/line items to this opportunity via the
|
||||
* ConnectWise forecast endpoint. The caller passes only the fields
|
||||
* the user is permitted to set (already filtered by field-level
|
||||
* permission gating in the route handler).
|
||||
*
|
||||
* Accepts a single item or an array of items.
|
||||
*/
|
||||
public async addProducts(
|
||||
data: CWForecastItemCreate | CWForecastItemCreate[],
|
||||
): Promise<ForecastProductController[]> {
|
||||
try {
|
||||
const created = await opportunityCw.createProducts(
|
||||
this.cwOpportunityId,
|
||||
data,
|
||||
);
|
||||
return created.map((item) => new ForecastProductController(item));
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`[addProducts] Failed to create forecast item(s) on opportunity ${this.cwOpportunityId}`,
|
||||
JSON.stringify(
|
||||
{
|
||||
data,
|
||||
status: err?.response?.status,
|
||||
statusText: err?.response?.statusText,
|
||||
responseData: err?.response?.data,
|
||||
message: err?.message,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
throw new GenericError({
|
||||
status: err?.response?.status ?? 500,
|
||||
name: "AddProductFailed",
|
||||
message:
|
||||
err?.response?.data?.message ??
|
||||
"Failed to add product(s) to opportunity",
|
||||
cause: err?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Note
|
||||
*
|
||||
* Creates a new note on this opportunity in ConnectWise.
|
||||
*
|
||||
* @param note - The note text to add
|
||||
* @param user - The user identifier adding the note
|
||||
* @param opts - Optional flags
|
||||
*/
|
||||
public async addNote(
|
||||
note: string,
|
||||
user: string,
|
||||
opts?: { flagged?: boolean },
|
||||
): Promise<CWOpportunityNote> {
|
||||
const created = await opportunityCw.createNote(this.cwOpportunityId, {
|
||||
text: note,
|
||||
flagged: opts?.flagged ?? false,
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Note
|
||||
*
|
||||
* Updates an existing note on this opportunity in ConnectWise.
|
||||
*
|
||||
* @param noteId - The CW note ID to update
|
||||
* @param data - The fields to update
|
||||
*/
|
||||
public async updateNote(
|
||||
noteId: number,
|
||||
data: { text?: string; flagged?: boolean },
|
||||
): Promise<CWOpportunityNote> {
|
||||
const updated = await opportunityCw.updateNote(
|
||||
this.cwOpportunityId,
|
||||
noteId,
|
||||
data,
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Note
|
||||
*
|
||||
* Deletes a note from this opportunity in ConnectWise.
|
||||
*
|
||||
* @param noteId - The CW note ID to delete
|
||||
*/
|
||||
public async deleteNote(noteId: number): Promise<void> {
|
||||
await opportunityCw.deleteNote(this.cwOpportunityId, noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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._company
|
||||
? this._company.toJson({
|
||||
includeAllContacts: true,
|
||||
includeAddress: true,
|
||||
includePrimaryContact: false,
|
||||
})
|
||||
: this.companyCwId
|
||||
? { id: this.companyCwId, name: this.companyName }
|
||||
: null,
|
||||
contact: this.contactCwId
|
||||
? { id: this.contactCwId, name: this.contactName }
|
||||
: null,
|
||||
site: this._siteData
|
||||
? this._siteData
|
||||
: 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,
|
||||
productSequence: this.productSequence,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
customFields: this._customFields ?? [],
|
||||
activities: this._activities?.map((a) => a.toJson()) ?? [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export default class UserController {
|
||||
public login: string;
|
||||
public email: string;
|
||||
public image: string | null;
|
||||
public cwIdentifier: string | null;
|
||||
|
||||
private _roles: Collection<string, Role>;
|
||||
private _permissions: string | null;
|
||||
@@ -31,6 +32,7 @@ export default class UserController {
|
||||
this.login = userdata.login;
|
||||
this.email = userdata.email;
|
||||
this.image = userdata.image;
|
||||
this.cwIdentifier = userdata.cwIdentifier ?? null;
|
||||
this.updatedAt = userdata.updatedAt;
|
||||
this.createdAt = userdata.createdAt;
|
||||
this._permissions = userdata.permissions ?? null;
|
||||
@@ -57,6 +59,7 @@ export default class UserController {
|
||||
this.login = userdata.login;
|
||||
this.email = userdata.email;
|
||||
this.image = userdata.image;
|
||||
this.cwIdentifier = userdata.cwIdentifier ?? null;
|
||||
this.updatedAt = userdata.updatedAt;
|
||||
this.createdAt = userdata.createdAt;
|
||||
}
|
||||
@@ -178,6 +181,46 @@ export default class UserController {
|
||||
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
|
||||
*
|
||||
@@ -262,9 +305,19 @@ export default class UserController {
|
||||
: this._roles.size > 0
|
||||
? this._roles.map((v) => v.moniker)
|
||||
: 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,
|
||||
email: opts?.safeReturn ? undefined : this.email,
|
||||
cwIdentifier: opts?.safeReturn ? undefined : this.cwIdentifier,
|
||||
image: this.image,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
|
||||
+135
-26
@@ -1,43 +1,48 @@
|
||||
import { refresh } from "./api/auth";
|
||||
import app from "./api/server";
|
||||
import { engine, PORT, unifi, unifiPassword, unifiUsername } from "./constants";
|
||||
import {
|
||||
engine,
|
||||
PORT,
|
||||
prisma,
|
||||
unifi,
|
||||
unifiPassword,
|
||||
unifiUsername,
|
||||
} from "./constants";
|
||||
import { unifiSites } from "./managers/unifiSites";
|
||||
import { refreshCompanies } from "./modules/cw-utils/refreshCompanies";
|
||||
import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog";
|
||||
import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory";
|
||||
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
|
||||
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
|
||||
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
|
||||
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
|
||||
import { events, setupEventDebugger } from "./modules/globalEvents";
|
||||
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
||||
import { RoleController } from "./controllers/RoleController";
|
||||
import cuid from "cuid";
|
||||
|
||||
// Setup global event debugger in non-production environments
|
||||
if (Bun.env.NODE_ENV == "development") setupEventDebugger();
|
||||
|
||||
// Refresh the internal list of companies every minute
|
||||
await refreshCompanies();
|
||||
setInterval(() => {
|
||||
return refreshCompanies();
|
||||
}, 60 * 1000);
|
||||
|
||||
// Refresh the internal catalog every minute
|
||||
await refreshCatalog();
|
||||
setInterval(() => {
|
||||
return refreshCatalog();
|
||||
}, 60 * 1000);
|
||||
|
||||
// Refresh inventory on hand every 2 minutes
|
||||
await refreshInventory();
|
||||
setInterval(
|
||||
() => {
|
||||
return refreshInventory();
|
||||
},
|
||||
2 * 60 * 1000,
|
||||
);
|
||||
|
||||
await unifiSites.syncSites();
|
||||
setInterval(() => {
|
||||
return unifiSites.syncSites();
|
||||
}, 60 * 1000);
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start the HTTP server FIRST so the pod is reachable immediately.
|
||||
// All data-sync tasks run afterwards and are non-blocking.
|
||||
// ---------------------------------------------------------------------------
|
||||
Bun.serve({
|
||||
port: PORT,
|
||||
idleTimeout: 255,
|
||||
websocket: engine.handler().websocket,
|
||||
fetch: (req, server) => {
|
||||
const url = new URL(req.url);
|
||||
@@ -49,3 +54,107 @@ Bun.serve({
|
||||
return app.fetch(req, server);
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[startup] Server listening on port ${PORT}`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background initialisation — none of this blocks the server.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Ensure administrator role exists
|
||||
await safeStartup("ensureAdminRole", async () => {
|
||||
const existingAdmin = await prisma.role.findFirst({
|
||||
where: { moniker: "administrator" },
|
||||
include: { users: { include: { roles: true } } },
|
||||
});
|
||||
|
||||
if (!existingAdmin) {
|
||||
const id = cuid();
|
||||
const created = await prisma.role.create({
|
||||
data: {
|
||||
id,
|
||||
moniker: "administrator",
|
||||
title: "Admin",
|
||||
permissions: signPermissions({
|
||||
issuer: "roles",
|
||||
subject: id,
|
||||
permissions: ["*"],
|
||||
}),
|
||||
},
|
||||
include: { users: { include: { roles: true } } },
|
||||
});
|
||||
events.emit("role:created", new RoleController(created));
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh the internal list of companies every minute
|
||||
await safeStartup("refreshCompanies", refreshCompanies);
|
||||
setInterval(() => {
|
||||
return refreshCompanies().catch((err) =>
|
||||
console.error("[interval] refreshCompanies failed", err),
|
||||
);
|
||||
}, 60 * 1000);
|
||||
|
||||
// Refresh the internal catalog every minute
|
||||
await safeStartup("refreshCatalog", refreshCatalog);
|
||||
setInterval(() => {
|
||||
return refreshCatalog().catch((err) =>
|
||||
console.error("[interval] refreshCatalog failed", err),
|
||||
);
|
||||
}, 60 * 1000);
|
||||
|
||||
// Refresh inventory on hand every 2 minutes
|
||||
await safeStartup("refreshInventory", refreshInventory);
|
||||
setInterval(
|
||||
() => {
|
||||
return refreshInventory().catch((err) =>
|
||||
console.error("[interval] refreshInventory failed", err),
|
||||
);
|
||||
},
|
||||
2 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Refresh opportunities every minute
|
||||
await safeStartup("refreshOpportunities", refreshOpportunities);
|
||||
setInterval(() => {
|
||||
return refreshOpportunities().catch((err) =>
|
||||
console.error("[interval] refreshOpportunities failed", err),
|
||||
);
|
||||
}, 60 * 1000);
|
||||
|
||||
// Refresh opportunity CW cache every 30 seconds (activities + company hydration)
|
||||
await safeStartup("refreshOpportunityCache", refreshOpportunityCache);
|
||||
setInterval(() => {
|
||||
return refreshOpportunityCache().catch((err) =>
|
||||
console.error("[interval] refreshOpportunityCache failed", err),
|
||||
);
|
||||
}, 30 * 1000);
|
||||
|
||||
// Refresh User Defined Fields every 5 minutes
|
||||
await safeStartup("refreshUDFs", () => userDefinedFieldsCw.refresh());
|
||||
setInterval(
|
||||
() => {
|
||||
return userDefinedFieldsCw
|
||||
.refresh()
|
||||
.catch((err) => console.error("[interval] refreshUDFs failed", err));
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Refresh CW identifiers for all users every 30 minutes
|
||||
await safeStartup("refreshCwIdentifiers", refreshCwIdentifiers);
|
||||
setInterval(
|
||||
() => {
|
||||
return refreshCwIdentifiers().catch((err) =>
|
||||
console.error("[interval] refreshCwIdentifiers failed", err),
|
||||
);
|
||||
},
|
||||
30 * 60 * 1000,
|
||||
);
|
||||
|
||||
await safeStartup("syncSites", () => unifiSites.syncSites());
|
||||
setInterval(() => {
|
||||
return unifiSites
|
||||
.syncSites()
|
||||
.catch((err) => console.error("[interval] syncSites failed", err));
|
||||
}, 60 * 1000);
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ActivityController } from "../controllers/ActivityController";
|
||||
import { connectWiseApi } from "../constants";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import {
|
||||
CWCreateActivity,
|
||||
CWPatchOperation,
|
||||
} from "../modules/cw-utils/activities/activity.types";
|
||||
|
||||
export const activities = {
|
||||
/**
|
||||
* Fetch Activity
|
||||
*
|
||||
* Fetch a single activity by its ConnectWise activity ID
|
||||
* and return an ActivityController instance.
|
||||
*
|
||||
* @param cwActivityId - The ConnectWise activity ID
|
||||
* @returns {Promise<ActivityController>}
|
||||
*/
|
||||
async fetchItem(cwActivityId: number): Promise<ActivityController> {
|
||||
try {
|
||||
const cwData = await activityCw.fetch(cwActivityId);
|
||||
return new ActivityController(cwData);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "FetchActivityError",
|
||||
message: `Failed to fetch activity ${cwActivityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: (error as any).status ?? 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Activities (Paginated)
|
||||
*
|
||||
* Fetches activities from ConnectWise with optional conditions and pagination.
|
||||
*
|
||||
* @param page - Page number (1-based)
|
||||
* @param rpp - Records per page
|
||||
* @param conditions - Optional CW conditions string for filtering
|
||||
* @returns {Promise<ActivityController[]>}
|
||||
*/
|
||||
async fetchPages(
|
||||
page: number,
|
||||
rpp: number,
|
||||
conditions?: string,
|
||||
): Promise<ActivityController[]> {
|
||||
try {
|
||||
const pageNum = Math.max(page, 1);
|
||||
const conditionsParam = conditions
|
||||
? `&conditions=${encodeURIComponent(conditions)}`
|
||||
: "";
|
||||
const response = await connectWiseApi.get(
|
||||
`/sales/activities?page=${pageNum}&pageSize=${rpp}${conditionsParam}`,
|
||||
);
|
||||
const items = response.data;
|
||||
return items.map((item: any) => new ActivityController(item));
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "FetchActivitiesError",
|
||||
message: "Failed to fetch activities from ConnectWise",
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Activities by Company
|
||||
*
|
||||
* Fetches all activities for a company by its ConnectWise company ID.
|
||||
*
|
||||
* @param cwCompanyId - The ConnectWise company ID
|
||||
* @returns {Promise<ActivityController[]>}
|
||||
*/
|
||||
async fetchByCompany(cwCompanyId: number): Promise<ActivityController[]> {
|
||||
try {
|
||||
const collection = await activityCw.fetchByCompany(cwCompanyId);
|
||||
return collection.map((item) => new ActivityController(item));
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "FetchCompanyActivitiesError",
|
||||
message: `Failed to fetch activities for company ${cwCompanyId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Activities by Opportunity
|
||||
*
|
||||
* Fetches all activities for an opportunity by its ConnectWise opportunity ID.
|
||||
*
|
||||
* @param cwOpportunityId - The ConnectWise opportunity ID
|
||||
* @returns {Promise<ActivityController[]>}
|
||||
*/
|
||||
async fetchByOpportunity(
|
||||
cwOpportunityId: number,
|
||||
): Promise<ActivityController[]> {
|
||||
try {
|
||||
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
|
||||
return collection.map((item) => new ActivityController(item));
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "FetchOpportunityActivitiesError",
|
||||
message: `Failed to fetch activities for opportunity ${cwOpportunityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create Activity
|
||||
*
|
||||
* Creates a new activity in ConnectWise and returns an ActivityController.
|
||||
*
|
||||
* @param data - The activity data to create
|
||||
* @returns {Promise<ActivityController>}
|
||||
*/
|
||||
async create(data: CWCreateActivity): Promise<ActivityController> {
|
||||
try {
|
||||
return await ActivityController.create(data);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "CreateActivityError",
|
||||
message: "Failed to create activity in ConnectWise",
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update Activity
|
||||
*
|
||||
* Updates an existing activity in ConnectWise using JSON Patch operations
|
||||
* and returns an updated ActivityController.
|
||||
*
|
||||
* @param cwActivityId - The ConnectWise activity ID to update
|
||||
* @param operations - Array of JSON Patch operations to apply
|
||||
* @returns {Promise<ActivityController>}
|
||||
*/
|
||||
async update(
|
||||
cwActivityId: number,
|
||||
operations: CWPatchOperation[],
|
||||
): Promise<ActivityController> {
|
||||
try {
|
||||
const updated = await activityCw.update(cwActivityId, operations);
|
||||
return new ActivityController(updated);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "UpdateActivityError",
|
||||
message: `Failed to update activity ${cwActivityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Activity
|
||||
*
|
||||
* Deletes an activity from ConnectWise.
|
||||
*
|
||||
* @param cwActivityId - The ConnectWise activity ID to delete
|
||||
*/
|
||||
async delete(cwActivityId: number): Promise<void> {
|
||||
try {
|
||||
await activityCw.delete(cwActivityId);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "DeleteActivityError",
|
||||
message: `Failed to delete activity ${cwActivityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Count Activities
|
||||
*
|
||||
* Returns the total number of activities, optionally filtered.
|
||||
*
|
||||
* @param conditions - Optional CW conditions string for filtering
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async count(conditions?: string): Promise<number> {
|
||||
try {
|
||||
return await activityCw.countItems(conditions);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
throw new GenericError({
|
||||
name: "CountActivitiesError",
|
||||
message: "Failed to count activities in ConnectWise",
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -15,16 +15,19 @@ export const companies = {
|
||||
const freshCwData: { data: Company } = await connectWiseApi.get(
|
||||
`/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(
|
||||
`${freshCwData.data._info.contacts_href}&pageSize=1000`,
|
||||
);
|
||||
|
||||
return new CompanyController(search, {
|
||||
company: freshCwData.data,
|
||||
defaultContact: defaultContactData.data,
|
||||
defaultContact: defaultContactData?.data ?? null,
|
||||
allContacts: allContactsData.data,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import { Company } from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { ActivityController } from "../controllers/ActivityController";
|
||||
import { CompanyController } from "../controllers/CompanyController";
|
||||
import { OpportunityController } from "../controllers/OpportunityController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { activityCw } from "../modules/cw-utils/activities/activities";
|
||||
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
|
||||
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
|
||||
import {
|
||||
getCachedActivities,
|
||||
getCachedCompanyCwData,
|
||||
fetchAndCacheActivities,
|
||||
fetchAndCacheCompanyCwData,
|
||||
} from "../modules/cache/opportunityCache";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data-source hierarchy helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a CompanyController with hydrated CW data from a Prisma Company record.
|
||||
*
|
||||
* Data-source hierarchy (controlled by `strategy`):
|
||||
*
|
||||
* - `"cache-only"` — Redis cache → bare DB record (no CW call).
|
||||
* Ideal for list views where latency matters and the background
|
||||
* refresh job is responsible for keeping the cache warm.
|
||||
*
|
||||
* - `"cache-then-cw"` (default) — Redis cache → CW API → cache result.
|
||||
* On a cold cache, calls CW to ensure the caller gets full data.
|
||||
*
|
||||
* - `"cw-first"` — CW API (always) → cache result.
|
||||
* Forces a fresh fetch regardless of cache state.
|
||||
*/
|
||||
async function buildCompanyController(
|
||||
company: Company,
|
||||
opts?: {
|
||||
strategy?: "cache-only" | "cache-then-cw" | "cw-first";
|
||||
ttlMs?: number;
|
||||
},
|
||||
): Promise<CompanyController> {
|
||||
const strategy = opts?.strategy ?? "cache-then-cw";
|
||||
const ctrl = new CompanyController(company);
|
||||
|
||||
// ── cw-first: always fetch from CW ──────────────────────────────────
|
||||
if (strategy === "cw-first") {
|
||||
await ctrl.hydrateCwData();
|
||||
if (ctrl.cw_Data && opts?.ttlMs) {
|
||||
await fetchAndCacheCompanyCwData(company.cw_CompanyId, opts.ttlMs).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
// ── cache-only / cache-then-cw: try Redis first ─────────────────────
|
||||
const cached = await getCachedCompanyCwData(company.cw_CompanyId);
|
||||
if (cached) {
|
||||
ctrl.cw_Data = cached;
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
// cache-only stops here — return the bare DB-backed controller
|
||||
if (strategy === "cache-only") return ctrl;
|
||||
|
||||
// cache-then-cw: cache miss — fall through to CW
|
||||
await ctrl.hydrateCwData();
|
||||
if (ctrl.cw_Data && opts?.ttlMs) {
|
||||
await fetchAndCacheCompanyCwData(company.cw_CompanyId, opts.ttlMs).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch ActivityController[] for an opportunity.
|
||||
*
|
||||
* Same three strategies as {@link buildCompanyController}:
|
||||
*
|
||||
* - `"cache-only"` — Redis → empty array (no CW call).
|
||||
* - `"cache-then-cw"` (default) — Redis → CW API → cache result.
|
||||
* - `"cw-first"` — CW API (always) → cache result.
|
||||
*/
|
||||
async function buildActivities(
|
||||
cwOpportunityId: number,
|
||||
opts?: {
|
||||
strategy?: "cache-only" | "cache-then-cw" | "cw-first";
|
||||
ttlMs?: number;
|
||||
},
|
||||
): Promise<ActivityController[]> {
|
||||
const strategy = opts?.strategy ?? "cache-then-cw";
|
||||
|
||||
// ── cw-first: always fetch from CW ──────────────────────────────────
|
||||
if (strategy === "cw-first") {
|
||||
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
|
||||
const arr = collection.map((item) => item);
|
||||
if (opts?.ttlMs) {
|
||||
await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
return arr.map((item) => new ActivityController(item));
|
||||
}
|
||||
|
||||
// ── cache-only / cache-then-cw: try Redis first ─────────────────────
|
||||
const cached = await getCachedActivities(cwOpportunityId);
|
||||
if (cached) {
|
||||
return cached.map((item) => new ActivityController(item));
|
||||
}
|
||||
|
||||
// cache-only stops here — return empty (background job will fill it)
|
||||
if (strategy === "cache-only") return [];
|
||||
|
||||
// cache-then-cw: cache miss — fall through to CW
|
||||
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
|
||||
const arr = collection.map((item) => item);
|
||||
if (opts?.ttlMs) {
|
||||
await fetchAndCacheActivities(cwOpportunityId, opts.ttlMs).catch(() => {});
|
||||
}
|
||||
return arr.map((item) => new ActivityController(item));
|
||||
}
|
||||
|
||||
export const opportunities = {
|
||||
/**
|
||||
* Fetch Opportunity
|
||||
*
|
||||
* Fetch an opportunity by its internal ID or ConnectWise opportunity ID
|
||||
* and return an OpportunityController instance.
|
||||
*
|
||||
* **Data-source strategy:**
|
||||
* - `fresh: true` → `"cw-first"` — always fetches from CW, updates DB, caches result.
|
||||
* - `fresh: false` (default) → `"cache-then-cw"` — tries Redis first, falls back to CW on miss.
|
||||
*
|
||||
* Individual fetches always contact CW to update the DB record with
|
||||
* the latest data from ConnectWise, regardless of the cache strategy
|
||||
* for activities/company hydration.
|
||||
*
|
||||
* @param identifier - The internal ID (string) or CW opportunity ID (number)
|
||||
* @param opts - Optional flags
|
||||
* @param opts.fresh - When `true`, bypass the cache and pull directly from CW.
|
||||
* @returns {Promise<OpportunityController>}
|
||||
*/
|
||||
async fetchItem(
|
||||
identifier: string | number,
|
||||
opts?: { fresh?: boolean },
|
||||
): Promise<OpportunityController> {
|
||||
const strategy: "cache-only" | "cache-then-cw" | "cw-first" = opts?.fresh
|
||||
? "cw-first"
|
||||
: "cache-then-cw";
|
||||
const isNumeric =
|
||||
typeof identifier === "number" || /^\d+$/.test(String(identifier));
|
||||
|
||||
// Look up the existing DB record to get the cwOpportunityId
|
||||
const existing = await prisma.opportunity.findFirst({
|
||||
where: isNumeric
|
||||
? { cwOpportunityId: Number(identifier) }
|
||||
: { id: identifier as string },
|
||||
select: { id: true, cwOpportunityId: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new GenericError({
|
||||
message: "Opportunity not found",
|
||||
name: "OpportunityNotFound",
|
||||
cause: `No opportunity exists with identifier '${identifier}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch fresh data from ConnectWise
|
||||
const cwData = await opportunityCw.fetch(existing.cwOpportunityId);
|
||||
|
||||
// Map and update the DB record
|
||||
const mapped = OpportunityController.mapCwToDb(cwData);
|
||||
|
||||
// Resolve internal company link
|
||||
const companyId = cwData.company?.id
|
||||
? ((
|
||||
await prisma.company.findFirst({
|
||||
where: { cw_CompanyId: cwData.company.id },
|
||||
select: { id: true },
|
||||
})
|
||||
)?.id ?? null)
|
||||
: null;
|
||||
|
||||
const updated = await prisma.opportunity.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...mapped, companyId },
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
const ttlMs =
|
||||
computeCacheTTL({
|
||||
closedFlag: updated.closedFlag,
|
||||
closedDate: updated.closedDate,
|
||||
expectedCloseDate: updated.expectedCloseDate,
|
||||
lastUpdated: updated.cwLastUpdated,
|
||||
}) ?? undefined;
|
||||
|
||||
const activities = await buildActivities(updated.cwOpportunityId, {
|
||||
strategy,
|
||||
ttlMs,
|
||||
});
|
||||
|
||||
return new OpportunityController(updated, {
|
||||
company: updated.company
|
||||
? await buildCompanyController(updated.company, { strategy, ttlMs })
|
||||
: undefined,
|
||||
customFields: cwData.customFields ?? [],
|
||||
activities,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Opportunities (Paginated)
|
||||
*
|
||||
* Uses the **cache-only** strategy: Redis → bare DB data.
|
||||
* Activities and company hydration come from the Redis cache if
|
||||
* available; otherwise the controller is returned with DB-only data.
|
||||
* The background refresh job is responsible for keeping Redis warm.
|
||||
*
|
||||
* @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 },
|
||||
include: { company: true },
|
||||
skip,
|
||||
take: rpp,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
return new OpportunityController(item, {
|
||||
company: item.company
|
||||
? await buildCompanyController(item.company, {
|
||||
strategy: "cache-only",
|
||||
})
|
||||
: undefined,
|
||||
activities: await buildActivities(item.cwOpportunityId, {
|
||||
strategy: "cache-only",
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Search Opportunities
|
||||
*
|
||||
* Search opportunities by name, company name, contact name, notes,
|
||||
* sales rep, or status with pagination support.
|
||||
*
|
||||
* Uses the **cache-only** strategy (same as `fetchPages`).
|
||||
*
|
||||
* @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 numericQuery = /^\d+$/.test(query.trim())
|
||||
? Number(query.trim())
|
||||
: null;
|
||||
|
||||
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" } },
|
||||
...(numericQuery !== null
|
||||
? [{ cwOpportunityId: { equals: numericQuery } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
include: { company: true },
|
||||
skip,
|
||||
take: rpp,
|
||||
orderBy: { expectedCloseDate: "asc" },
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
return new OpportunityController(item, {
|
||||
company: item.company
|
||||
? await buildCompanyController(item.company, {
|
||||
strategy: "cache-only",
|
||||
})
|
||||
: undefined,
|
||||
activities: await buildActivities(item.cwOpportunityId, {
|
||||
strategy: "cache-only",
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Count Search Results
|
||||
*
|
||||
* Returns the total number of opportunities matching a search query,
|
||||
* using the same filter logic as `search()`.
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param opts - Optional filters
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async searchCount(
|
||||
query: string,
|
||||
opts?: { includeClosed?: boolean },
|
||||
): Promise<number> {
|
||||
const numericQuery = /^\d+$/.test(query.trim())
|
||||
? Number(query.trim())
|
||||
: null;
|
||||
|
||||
return prisma.opportunity.count({
|
||||
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" } },
|
||||
...(numericQuery !== null
|
||||
? [{ cwOpportunityId: { equals: numericQuery } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunities by Company
|
||||
*
|
||||
* Fetch all opportunities for a company by its internal company ID.
|
||||
*
|
||||
* Uses the **cache-only** strategy (same as `fetchPages`).
|
||||
*
|
||||
* @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 }),
|
||||
},
|
||||
include: { company: true },
|
||||
orderBy: { expectedCloseDate: "asc" },
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
return new OpportunityController(item, {
|
||||
company: item.company
|
||||
? await buildCompanyController(item.company, {
|
||||
strategy: "cache-only",
|
||||
})
|
||||
: undefined,
|
||||
activities: await buildActivities(item.cwOpportunityId, {
|
||||
strategy: "cache-only",
|
||||
}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,332 @@
|
||||
import { prisma } from "../constants";
|
||||
import { CatalogItemController } from "../controllers/CatalogItemController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import {
|
||||
getSubcategoriesForCategory,
|
||||
getSubcategoriesForGroup,
|
||||
ECOSYSTEM_TREE,
|
||||
} from "../modules/catalog-categories/catalogCategories";
|
||||
|
||||
/**
|
||||
* Standard include clause used by catalog item queries.
|
||||
* Includes one level of linked items.
|
||||
*/
|
||||
const catalogItemInclude = {
|
||||
linkedItems: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Filter options for catalog item queries.
|
||||
*/
|
||||
export interface CatalogFilterOpts {
|
||||
includeInactive?: boolean;
|
||||
category?: string;
|
||||
subcategory?: string;
|
||||
group?: string;
|
||||
manufacturer?: string;
|
||||
ecosystem?: string;
|
||||
inStock?: boolean;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Prisma `where` clause from filter options.
|
||||
*/
|
||||
function buildFilterWhere(opts: CatalogFilterOpts = {}) {
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
|
||||
if (!opts.includeInactive) {
|
||||
conditions.push({ inactive: false });
|
||||
}
|
||||
|
||||
if (opts.category) {
|
||||
conditions.push({ category: opts.category });
|
||||
}
|
||||
|
||||
if (opts.subcategory) {
|
||||
conditions.push({ subcategory: opts.subcategory });
|
||||
}
|
||||
|
||||
if (opts.group && opts.category) {
|
||||
const subcats = getSubcategoriesForGroup(opts.category, opts.group);
|
||||
if (subcats.length > 0) {
|
||||
conditions.push({ subcategory: { in: subcats } });
|
||||
}
|
||||
} else if (opts.group && !opts.category) {
|
||||
// Try to find the group in any category
|
||||
const {
|
||||
CATEGORY_TREE,
|
||||
isCategoryGroup,
|
||||
} = require("../modules/catalog-categories/catalogCategories");
|
||||
for (const cat of CATEGORY_TREE) {
|
||||
const subcats = getSubcategoriesForGroup(cat.name, opts.group);
|
||||
if (subcats.length > 0) {
|
||||
conditions.push({ category: cat.name, subcategory: { in: subcats } });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.manufacturer) {
|
||||
conditions.push({
|
||||
manufacturer: { contains: opts.manufacturer, mode: "insensitive" },
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.ecosystem) {
|
||||
const eco = ECOSYSTEM_TREE.find(
|
||||
(e) => e.name.toLowerCase() === opts.ecosystem!.toLowerCase(),
|
||||
);
|
||||
if (eco && eco.manufacturers.length > 0) {
|
||||
conditions.push({
|
||||
OR: eco.manufacturers.map((m) => ({
|
||||
manufacturer: { contains: m.name, mode: "insensitive" as const },
|
||||
subcategory: { startsWith: m.subcategoryPrefix },
|
||||
category: m.category,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.inStock) {
|
||||
conditions.push({ onHand: { gt: 0 } });
|
||||
}
|
||||
|
||||
if (opts.minPrice !== undefined) {
|
||||
conditions.push({ price: { gte: opts.minPrice } });
|
||||
}
|
||||
|
||||
if (opts.maxPrice !== undefined) {
|
||||
conditions.push({ price: { lte: opts.maxPrice } });
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? { AND: conditions } : undefined;
|
||||
}
|
||||
|
||||
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 with optional filtering.
|
||||
*
|
||||
* @param page - Page number (1-based)
|
||||
* @param rpp - Records per page
|
||||
* @param opts - Filter options
|
||||
* @returns {Promise<CatalogItemController[]>} - Array of catalog item controllers
|
||||
*/
|
||||
async fetchPages(
|
||||
page: number,
|
||||
rpp: number,
|
||||
opts?: CatalogFilterOpts,
|
||||
): Promise<CatalogItemController[]> {
|
||||
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||
const take = rpp;
|
||||
|
||||
const items = await prisma.catalogItem.findMany({
|
||||
where: buildFilterWhere(opts),
|
||||
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 and optional category/subcategory/ecosystem filters.
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param page - Page number (1-based)
|
||||
* @param rpp - Records per page
|
||||
* @param opts - Filter options
|
||||
* @returns {Promise<CatalogItemController[]>} - Array of matching catalog item controllers
|
||||
*/
|
||||
async search(
|
||||
query: string,
|
||||
page: number,
|
||||
rpp: number,
|
||||
opts?: CatalogFilterOpts,
|
||||
): Promise<CatalogItemController[]> {
|
||||
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||
const take = rpp;
|
||||
|
||||
const filterWhere = buildFilterWhere(opts) ?? {};
|
||||
|
||||
const items = await prisma.catalogItem.findMany({
|
||||
where: {
|
||||
...filterWhere,
|
||||
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 matching the given filters.
|
||||
*
|
||||
* @param opts - Filter options
|
||||
* @returns {Promise<number>} - Total count
|
||||
*/
|
||||
async count(
|
||||
opts?: CatalogFilterOpts & { activeOnly?: boolean },
|
||||
): Promise<number> {
|
||||
// Support legacy `activeOnly` flag by mapping it to `includeInactive`
|
||||
const filterOpts: CatalogFilterOpts = {
|
||||
...opts,
|
||||
includeInactive:
|
||||
opts?.includeInactive ?? (opts?.activeOnly ? false : true),
|
||||
};
|
||||
if (opts?.activeOnly) filterOpts.includeInactive = false;
|
||||
|
||||
return prisma.catalogItem.count({
|
||||
where: buildFilterWhere(filterOpts),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Count Catalog Items (with search query)
|
||||
*
|
||||
* Returns the total number of catalog items matching a search query and filters.
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param opts - Filter options
|
||||
* @returns {Promise<number>} - Total count
|
||||
*/
|
||||
async countSearch(query: string, opts?: CatalogFilterOpts): Promise<number> {
|
||||
const filterWhere = buildFilterWhere(opts) ?? {};
|
||||
|
||||
return prisma.catalogItem.count({
|
||||
where: {
|
||||
...filterWhere,
|
||||
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" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Distinct Values
|
||||
*
|
||||
* Returns the distinct values for a given field across all catalog items.
|
||||
* Useful for populating filter dropdowns in the UI.
|
||||
*
|
||||
* @param field - The field to get distinct values for
|
||||
* @param opts - Filter options to scope the distinct query
|
||||
* @returns {Promise<string[]>} - Sorted array of distinct non-null values
|
||||
*/
|
||||
async fetchDistinctValues(
|
||||
field: "category" | "subcategory" | "manufacturer",
|
||||
opts?: CatalogFilterOpts,
|
||||
): Promise<string[]> {
|
||||
const items = await prisma.catalogItem.findMany({
|
||||
where: buildFilterWhere(opts),
|
||||
select: { [field]: true },
|
||||
distinct: [field],
|
||||
orderBy: { [field]: "asc" },
|
||||
});
|
||||
|
||||
return items
|
||||
.map((item: Record<string, unknown>) => item[field] as string | null)
|
||||
.filter((v): v is string => v !== null);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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);
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { prisma } from "../constants";
|
||||
import { SessionTokensObject } from "../controllers/SessionController";
|
||||
import UserController from "../controllers/UserController";
|
||||
import { fetchMicrosoftUser } from "../modules/fetchMicrosoftUser";
|
||||
import { findCwIdentifierByEmail } from "../modules/cw-utils/members/fetchAllMembers";
|
||||
import { events } from "../modules/globalEvents";
|
||||
import { sessions } from "./sessions";
|
||||
import * as msal from "@azure/msal-node";
|
||||
@@ -90,12 +91,18 @@ export const users = {
|
||||
async createUser(token: string): Promise<UserController> {
|
||||
const msData = await fetchMicrosoftUser(token);
|
||||
|
||||
// Attempt to resolve the user's ConnectWise identifier by email
|
||||
const cwIdentifier = await findCwIdentifierByEmail(msData.mail).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
userId: msData.id,
|
||||
email: msData.mail,
|
||||
name: `${msData.givenName} ${msData.surname}`,
|
||||
login: msData.userPrincipalName,
|
||||
cwIdentifier,
|
||||
token,
|
||||
},
|
||||
include: { roles: true },
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @module computeCacheTTL
|
||||
*
|
||||
* Adaptive Cache TTL Algorithm
|
||||
* ============================
|
||||
*
|
||||
* Determines how long a cached record should live before it must be
|
||||
* re-fetched from the upstream source (e.g. ConnectWise API).
|
||||
*
|
||||
* The algorithm prioritises freshness for records that are actively
|
||||
* being worked on, while avoiding unnecessary API calls for stale or
|
||||
* inactive data.
|
||||
*
|
||||
* ## Spec
|
||||
*
|
||||
* | # | Condition | TTL (ms) | TTL (human) | Rationale |
|
||||
* |---|------------------------------------------------------------------|----------|-------------|--------------------------------------------------------------------|
|
||||
* | 1 | `closedFlag` is `true` | `null` | Do not cache| Closed records are rarely accessed; caching wastes memory. |
|
||||
* | 2 | `expectedCloseDate` OR `lastUpdated` is within the last **5 days**| 30 000 | 30 seconds | High-activity window — data changes frequently and must stay fresh.|
|
||||
* | 3 | `expectedCloseDate` OR `lastUpdated` is within the last **14 days**| 60 000 | 60 seconds | Moderate activity — still relevant, but changes less often. |
|
||||
* | 4 | Everything else (older than 14 days) | 900 000 | 15 minutes | Low activity — safe to serve from cache for longer. |
|
||||
*
|
||||
* ## Evaluation order
|
||||
*
|
||||
* Rules are evaluated **top-to-bottom**; the first matching rule wins.
|
||||
* Rule 2 (5-day window) is a subset of Rule 3 (14-day window), so it
|
||||
* must be checked first.
|
||||
*
|
||||
* ## Inputs
|
||||
*
|
||||
* | Field | Type | Description |
|
||||
* |--------------------|------------------|--------------------------------------------------------------------|
|
||||
* | `closedFlag` | `boolean` | Whether the record is closed / inactive. |
|
||||
* | `expectedCloseDate`| `Date \| null` | The projected close date (future-looking relevance signal). |
|
||||
* | `lastUpdated` | `Date \| null` | The last time the upstream record was modified (backward-looking). |
|
||||
* | `now` | `Date` (optional)| Override for the current timestamp; defaults to `new Date()`. |
|
||||
*
|
||||
* ## Output
|
||||
*
|
||||
* Returns `number | null`:
|
||||
* - A positive integer representing the TTL in **milliseconds**, or
|
||||
* - `null` when the record should **not** be cached at all.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* ```ts
|
||||
* import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
|
||||
*
|
||||
* const ttl = computeCacheTTL({
|
||||
* closedFlag: opportunity.closedFlag,
|
||||
* expectedCloseDate: opportunity.expectedCloseDate,
|
||||
* lastUpdated: opportunity.cwLastUpdated,
|
||||
* });
|
||||
*
|
||||
* if (ttl !== null) {
|
||||
* await redis.set(key, serialised, "PX", ttl);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 30 seconds – TTL for high-activity records (within 5 days). */
|
||||
export const TTL_HIGH_ACTIVITY = 30_000;
|
||||
|
||||
/** 60 seconds – TTL for moderate-activity records (within 14 days). */
|
||||
export const TTL_MODERATE_ACTIVITY = 60_000;
|
||||
|
||||
/** 15 minutes – TTL for low-activity / stale records. */
|
||||
export const TTL_LOW_ACTIVITY = 900_000;
|
||||
|
||||
/** 30 days in milliseconds. */
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** 5 days in milliseconds. */
|
||||
const FIVE_DAYS_MS = 5 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** 14 days in milliseconds. */
|
||||
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CacheTTLInput {
|
||||
/** Whether the record is closed / inactive. */
|
||||
closedFlag: boolean;
|
||||
/** When the record was closed — used for recently-closed caching (within 30 days). */
|
||||
closedDate: Date | null;
|
||||
/** The projected close date — serves as a forward-looking relevance signal. */
|
||||
expectedCloseDate: Date | null;
|
||||
/** The date the upstream record was last modified — backward-looking signal. */
|
||||
lastUpdated: Date | null;
|
||||
/**
|
||||
* Override for the current timestamp.
|
||||
* Useful for deterministic testing. Defaults to `new Date()`.
|
||||
*/
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Algorithm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the cache TTL for a record based on its activity signals.
|
||||
*
|
||||
* @param input - The record's activity signals. See {@link CacheTTLInput}.
|
||||
* @returns The TTL in milliseconds, or `null` if the record should not be cached.
|
||||
*
|
||||
* @see Module-level JSDoc for the full spec table and evaluation rules.
|
||||
*/
|
||||
export function computeCacheTTL(input: CacheTTLInput): number | null {
|
||||
const {
|
||||
closedFlag,
|
||||
closedDate,
|
||||
expectedCloseDate,
|
||||
lastUpdated,
|
||||
now = new Date(),
|
||||
} = input;
|
||||
|
||||
const nowMs = now.getTime();
|
||||
|
||||
/**
|
||||
* Check whether a date falls within a window around `now`.
|
||||
*
|
||||
* "Within" means the date is between `now - windowMs` and `now + windowMs`,
|
||||
* allowing both past updates and future-scheduled dates to qualify.
|
||||
*/
|
||||
const isWithinWindow = (date: Date | null, windowMs: number): boolean => {
|
||||
if (!date) return false;
|
||||
const diff = Math.abs(nowMs - date.getTime());
|
||||
return diff <= windowMs;
|
||||
};
|
||||
|
||||
// Rule 1 — Closed records
|
||||
if (closedFlag) {
|
||||
// Rule 1b — Recently closed (within 30 days) → cache at low-activity TTL
|
||||
if (isWithinWindow(closedDate, THIRTY_DAYS_MS)) {
|
||||
return TTL_LOW_ACTIVITY;
|
||||
}
|
||||
// Rule 1a — Closed longer than 30 days → do not cache
|
||||
return null;
|
||||
}
|
||||
|
||||
// Rule 2 — High activity (5 days)
|
||||
if (
|
||||
isWithinWindow(expectedCloseDate, FIVE_DAYS_MS) ||
|
||||
isWithinWindow(lastUpdated, FIVE_DAYS_MS)
|
||||
) {
|
||||
return TTL_HIGH_ACTIVITY;
|
||||
}
|
||||
|
||||
// Rule 3 — Moderate activity (14 days)
|
||||
if (
|
||||
isWithinWindow(expectedCloseDate, FOURTEEN_DAYS_MS) ||
|
||||
isWithinWindow(lastUpdated, FOURTEEN_DAYS_MS)
|
||||
) {
|
||||
return TTL_MODERATE_ACTIVITY;
|
||||
}
|
||||
|
||||
// Rule 4 — Low activity / stale
|
||||
return TTL_LOW_ACTIVITY;
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @module opportunityCache
|
||||
*
|
||||
* Redis-backed cache for expensive ConnectWise API data associated
|
||||
* with opportunities.
|
||||
*
|
||||
* ## What is cached
|
||||
*
|
||||
* Each non-closed opportunity may have two cached payloads keyed by
|
||||
* its `cwOpportunityId`:
|
||||
*
|
||||
* - **Activities** (`opp:activities:{cwOpportunityId}`) — the raw
|
||||
* `CWActivity[]` array fetched from `activityCw.fetchByOpportunity()`.
|
||||
* - **Company CW data** (`opp:company-cw:{cw_CompanyId}`) — the hydrated
|
||||
* company / contacts blob set by `CompanyController.hydrateCwData()`.
|
||||
*
|
||||
* TTLs are computed dynamically via {@link computeCacheTTL} so hot
|
||||
* opportunities refresh every 30 s while stale ones live for 15 min.
|
||||
*
|
||||
* ## Background refresh
|
||||
*
|
||||
* {@link refreshOpportunityCache} is designed to be called on a
|
||||
* 30-second interval from `src/index.ts`. It scans all non-closed
|
||||
* DB opportunities, checks which cache keys have expired, and
|
||||
* re-fetches only those from ConnectWise.
|
||||
*/
|
||||
|
||||
import { prisma, redis } from "../../constants";
|
||||
import { activityCw } from "../cw-utils/activities/activities";
|
||||
import { computeCacheTTL } from "../algorithms/computeCacheTTL";
|
||||
import { connectWiseApi } from "../../constants";
|
||||
import { fetchCwCompanyById } from "../cw-utils/fetchCompany";
|
||||
import { events } from "../globalEvents";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACTIVITY_PREFIX = "opp:activities:";
|
||||
const COMPANY_CW_PREFIX = "opp:company-cw:";
|
||||
|
||||
/** Redis key for cached activities by CW opportunity ID. */
|
||||
export const activityCacheKey = (cwOppId: number) =>
|
||||
`${ACTIVITY_PREFIX}${cwOppId}`;
|
||||
|
||||
/** Redis key for cached company CW hydration data by CW company ID. */
|
||||
export const companyCwCacheKey = (cwCompanyId: number) =>
|
||||
`${COMPANY_CW_PREFIX}${cwCompanyId}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Retrieve cached CW activities for an opportunity.
|
||||
*
|
||||
* @returns The parsed `CWActivity[]` or `null` on cache miss.
|
||||
*/
|
||||
export async function getCachedActivities(
|
||||
cwOpportunityId: number,
|
||||
): Promise<any[] | null> {
|
||||
const raw = await redis.get(activityCacheKey(cwOpportunityId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve cached company CW hydration data.
|
||||
*
|
||||
* @returns `{ company, defaultContact, allContacts }` or `null` on cache miss.
|
||||
*/
|
||||
export async function getCachedCompanyCwData(
|
||||
cwCompanyId: number,
|
||||
): Promise<{ company: any; defaultContact: any; allContacts: any[] } | null> {
|
||||
const raw = await redis.get(companyCwCacheKey(cwCompanyId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch activities from CW and cache them with the appropriate TTL.
|
||||
*
|
||||
* @returns The raw `CWActivity[]` collection (as plain array).
|
||||
*/
|
||||
export async function fetchAndCacheActivities(
|
||||
cwOpportunityId: number,
|
||||
ttlMs: number,
|
||||
): Promise<any[]> {
|
||||
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
|
||||
const arr = collection.map((item) => item);
|
||||
await redis.set(
|
||||
activityCacheKey(cwOpportunityId),
|
||||
JSON.stringify(arr),
|
||||
"PX",
|
||||
ttlMs,
|
||||
);
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch company CW data (company, contacts) and cache with the given TTL.
|
||||
*
|
||||
* @returns The hydration blob or `null` if the company doesn't exist in CW.
|
||||
*/
|
||||
export async function fetchAndCacheCompanyCwData(
|
||||
cwCompanyId: number,
|
||||
ttlMs: number,
|
||||
): Promise<{ company: any; defaultContact: any; allContacts: any[] } | null> {
|
||||
const cwCompany = await fetchCwCompanyById(cwCompanyId);
|
||||
if (!cwCompany) return null;
|
||||
|
||||
const contactHref = cwCompany.defaultContact?._info?.contact_href;
|
||||
const defaultContactData = contactHref
|
||||
? await connectWiseApi.get(contactHref)
|
||||
: undefined;
|
||||
|
||||
const allContactsData = await connectWiseApi.get(
|
||||
`${cwCompany._info.contacts_href}&pageSize=1000`,
|
||||
);
|
||||
|
||||
const blob = {
|
||||
company: cwCompany,
|
||||
defaultContact: defaultContactData?.data ?? null,
|
||||
allContacts: allContactsData.data,
|
||||
};
|
||||
|
||||
await redis.set(
|
||||
companyCwCacheKey(cwCompanyId),
|
||||
JSON.stringify(blob),
|
||||
"PX",
|
||||
ttlMs,
|
||||
);
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background refresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Refresh the opportunity cache.
|
||||
*
|
||||
* Scans all non-closed opportunities in the database, computes a TTL for each,
|
||||
* checks whether the cache key still exists, and re-fetches from ConnectWise
|
||||
* only for entries that have expired.
|
||||
*
|
||||
* Designed to be called every **30 seconds** from the process entry point.
|
||||
*/
|
||||
export async function refreshOpportunityCache(): Promise<void> {
|
||||
// Include non-closed AND recently-closed (within 30 days) opportunities
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const opportunities = await prisma.opportunity.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ closedFlag: false },
|
||||
{ closedFlag: true, closedDate: { gte: thirtyDaysAgo } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
cwOpportunityId: true,
|
||||
closedFlag: true,
|
||||
closedDate: true,
|
||||
expectedCloseDate: true,
|
||||
cwLastUpdated: true,
|
||||
company: { select: { cw_CompanyId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
events.emit("cache:opportunities:refresh:started", {
|
||||
totalOpportunities: opportunities.length,
|
||||
});
|
||||
|
||||
let activitiesRefreshed = 0;
|
||||
let companiesRefreshed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
// Batch-check which activity keys already exist via a pipeline
|
||||
const pipeline = redis.pipeline();
|
||||
for (const opp of opportunities) {
|
||||
pipeline.exists(activityCacheKey(opp.cwOpportunityId));
|
||||
}
|
||||
const existsResults = await pipeline.exec();
|
||||
|
||||
const refreshTasks: Promise<void>[] = [];
|
||||
|
||||
for (let i = 0; i < opportunities.length; i++) {
|
||||
const opp = opportunities[i]!;
|
||||
|
||||
const ttl = computeCacheTTL({
|
||||
closedFlag: opp.closedFlag,
|
||||
closedDate: opp.closedDate,
|
||||
expectedCloseDate: opp.expectedCloseDate,
|
||||
lastUpdated: opp.cwLastUpdated,
|
||||
});
|
||||
|
||||
// Skip closed (ttl === null) — should not happen because of the query filter,
|
||||
// but guard anyway.
|
||||
if (ttl === null) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// existsResults entries are [error, result] tuples
|
||||
const activityExists = existsResults?.[i]?.[1] === 1;
|
||||
|
||||
if (!activityExists) {
|
||||
refreshTasks.push(
|
||||
fetchAndCacheActivities(opp.cwOpportunityId, ttl).then(() => {
|
||||
activitiesRefreshed++;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Also refresh company CW data if the key is missing
|
||||
if (opp.company?.cw_CompanyId) {
|
||||
const cwCompanyId = opp.company.cw_CompanyId;
|
||||
refreshTasks.push(
|
||||
(async () => {
|
||||
const companyExists = await redis.exists(
|
||||
companyCwCacheKey(cwCompanyId),
|
||||
);
|
||||
if (!companyExists) {
|
||||
await fetchAndCacheCompanyCwData(cwCompanyId, ttl);
|
||||
companiesRefreshed++;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Run all refresh tasks concurrently with bounded concurrency
|
||||
const CONCURRENCY = 10;
|
||||
for (let i = 0; i < refreshTasks.length; i += CONCURRENCY) {
|
||||
await Promise.allSettled(refreshTasks.slice(i, i + CONCURRENCY));
|
||||
}
|
||||
|
||||
events.emit("cache:opportunities:refresh:completed", {
|
||||
totalOpportunities: opportunities.length,
|
||||
activitiesRefreshed,
|
||||
companiesRefreshed,
|
||||
skipped,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Catalog Categories & Ecosystems
|
||||
*
|
||||
* This module defines the complete category/subcategory hierarchy and
|
||||
* ecosystem decision trees used for product filtering in the UI.
|
||||
*
|
||||
* --- Terminology ---
|
||||
*
|
||||
* Category: Top-level CW category (e.g. "Technology", "Field", "General").
|
||||
* A category is NEVER a subcategory.
|
||||
*
|
||||
* Subcategory: The CW subcategory name stored on each catalog item.
|
||||
* At the second level of the tree, if there are no children
|
||||
* beneath it then the node name IS the subcategory.
|
||||
* If children exist, the second-level node is an *umbrella*
|
||||
* that groups related subcategories — the children are the
|
||||
* actual subcategory names.
|
||||
*
|
||||
* Ecosystem: A cross-cutting product grouping defined by manufacturer +
|
||||
* category + subcategory-prefix rules. Ecosystems let the UI
|
||||
* present a "Networking" or "Video Surveillance" view that
|
||||
* spans manufacturers regardless of where CW filed them.
|
||||
*
|
||||
* --- Data shapes ---
|
||||
*
|
||||
* SubcategoryNode – a leaf: `{ name, cwId? }`
|
||||
* CategoryGroup – an umbrella with children: `{ name, children[] }`
|
||||
* CategoryEntry – either a leaf OR a group at the 2nd level
|
||||
* TopLevelCategory – `{ name, cwId?, entries[] }`
|
||||
*
|
||||
* The `CATEGORY_TREE` export is the single source of truth; helpers derive
|
||||
* flat lists, lookup maps, and search predicates from it.
|
||||
*/
|
||||
|
||||
// ─── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SubcategoryNode {
|
||||
/** The exact CW subcategory name */
|
||||
name: string;
|
||||
/** CW subcategory id (optional, for reference) */
|
||||
cwId?: number;
|
||||
}
|
||||
|
||||
export interface CategoryGroup {
|
||||
/** Display name of the umbrella (e.g. "Network", "Cables", "AlarmBurg") */
|
||||
name: string;
|
||||
/** The subcategories that belong to this umbrella */
|
||||
children: SubcategoryNode[];
|
||||
}
|
||||
|
||||
/** A second-level entry is either a direct subcategory or an umbrella group */
|
||||
export type CategoryEntry = SubcategoryNode | CategoryGroup;
|
||||
|
||||
export interface TopLevelCategory {
|
||||
/** The CW category name */
|
||||
name: string;
|
||||
/** CW category id (optional, for reference) */
|
||||
cwId?: number;
|
||||
/** Second-level entries under this category */
|
||||
entries: CategoryEntry[];
|
||||
}
|
||||
|
||||
/** Helper type guard */
|
||||
export function isCategoryGroup(entry: CategoryEntry): entry is CategoryGroup {
|
||||
return "children" in entry;
|
||||
}
|
||||
|
||||
// ─── Ecosystem types ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface EcosystemManufacturer {
|
||||
/** Manufacturer name as stored in CW */
|
||||
name: string;
|
||||
/** CW manufacturer id */
|
||||
cwId?: number;
|
||||
/** Which CW category these products fall under */
|
||||
category: string;
|
||||
/** Subcategory prefix — matches any subcategory starting with this string */
|
||||
subcategoryPrefix: string;
|
||||
}
|
||||
|
||||
export interface Ecosystem {
|
||||
/** Display name (e.g. "Networking", "Video Surveillance") */
|
||||
name: string;
|
||||
/** Manufacturers that belong to this ecosystem */
|
||||
manufacturers: EcosystemManufacturer[];
|
||||
}
|
||||
|
||||
// ─── Category Tree ───────────────────────────────────────────────────────────
|
||||
|
||||
export const CATEGORY_TREE: TopLevelCategory[] = [
|
||||
{
|
||||
name: "Technology",
|
||||
cwId: 18,
|
||||
entries: [
|
||||
{ name: "GeneralEquip", cwId: 57 },
|
||||
{ name: "Home Entertainment", cwId: 114 },
|
||||
{ name: "Monitor", cwId: 115 },
|
||||
{ name: "Printers", cwId: 120 },
|
||||
{ name: "Storage", cwId: 108 },
|
||||
{
|
||||
name: "Network",
|
||||
children: [
|
||||
{ name: "Network-Other", cwId: 174 },
|
||||
{ name: "Network-Router", cwId: 119 },
|
||||
{ name: "Network-Switch", cwId: 112 },
|
||||
{ name: "Network-Wireless", cwId: 111 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Computer",
|
||||
children: [
|
||||
{ name: "Computer-Components", cwId: 109 },
|
||||
{ name: "Computer-Desktop", cwId: 106 },
|
||||
{ name: "Computer-Laptop", cwId: 107 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Recurring",
|
||||
children: [
|
||||
{ name: "Recurring - Online", cwId: 83 },
|
||||
{ name: "Recurring - Other", cwId: 84 },
|
||||
{ name: "Recurring - Protection", cwId: 81 },
|
||||
{ name: "Recurring - Telephone", cwId: 133 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Telephone",
|
||||
children: [
|
||||
{ name: "Tele-HSet-Digital", cwId: 116 },
|
||||
{ name: "Tele-HSet-IP", cwId: 206 },
|
||||
{ name: "Tele-HSet-SLT" },
|
||||
{ name: "Tele-Misc", cwId: 75 },
|
||||
{ name: "Tele-Paging", cwId: 76 },
|
||||
{ name: "Tele-SystemCards", cwId: 135 },
|
||||
{ name: "Tele-Systems", cwId: 78 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "General",
|
||||
cwId: 25,
|
||||
entries: [
|
||||
{ name: "Batteries", cwId: 80 },
|
||||
{ name: "Battery Backups", cwId: 144 },
|
||||
{ name: "BulkWire", cwId: 200 },
|
||||
{
|
||||
name: "Cables",
|
||||
children: [
|
||||
{ name: "Cables-Adapters", cwId: 182 },
|
||||
{ name: "Cables-HDMI", cwId: 176 },
|
||||
{ name: "Cables-Network", cwId: 87 },
|
||||
{ name: "Cables-Other", cwId: 177 },
|
||||
{ name: "Cables-USB", cwId: 178 },
|
||||
{ name: "Cables-VGA", cwId: 179 },
|
||||
],
|
||||
},
|
||||
{ name: "Elec Cords & Adapters", cwId: 142 },
|
||||
{ name: "Enclosures", cwId: 141 },
|
||||
{ name: "PowerSupply", cwId: 167 },
|
||||
{
|
||||
name: "RackEquip",
|
||||
children: [
|
||||
{ name: "RackEquip-Rack", cwId: 143 },
|
||||
{ name: "RackEquip-Shelves", cwId: 190 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Field",
|
||||
cwId: 28,
|
||||
entries: [
|
||||
{ name: "Conduit" },
|
||||
{ name: "Electric", cwId: 199 },
|
||||
{ name: "GateControl", cwId: 45 },
|
||||
{ name: "Locksets" },
|
||||
{ name: "Other", cwId: 46 },
|
||||
{ name: "Relays", cwId: 168 },
|
||||
{
|
||||
name: "AccessControl",
|
||||
children: [
|
||||
{ name: "AccessControl-Controllers", cwId: 137 },
|
||||
{ name: "AccessControl-Credential", cwId: 183 },
|
||||
{ name: "AccessControl-LockDevices", cwId: 138 },
|
||||
{ name: "AccessControl-Other", cwId: 44 },
|
||||
{ name: "AccessControl-Readers", cwId: 136 },
|
||||
{ name: "AccessControl-VideoEntry", cwId: 139 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "AlarmBurg",
|
||||
children: [
|
||||
{ name: "AlarmBurg-Communicators", cwId: 96 },
|
||||
{ name: "AlarmBurg-Keypads", cwId: 93 },
|
||||
{ name: "AlarmBurg-Modules", cwId: 140 },
|
||||
{ name: "AlarmBurg-Other", cwId: 92 },
|
||||
{ name: "AlarmBurg-Panels", cwId: 42 },
|
||||
{ name: "AlarmBurg-Sensors-Wireless", cwId: 147 },
|
||||
{ name: "AlarmBurg-Sensors-Wired", cwId: 146 },
|
||||
{ name: "AlarmBurg-Siren", cwId: 145 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "AlarmFire",
|
||||
children: [
|
||||
{ name: "AlarmFire-Communicators", cwId: 97 },
|
||||
{ name: "AlarmFire-Devices", cwId: 169 },
|
||||
{ name: "AlarmFire-Modules", cwId: 170 },
|
||||
{ name: "AlarmFire-Other", cwId: 98 },
|
||||
{ name: "AlarmFire-Panels", cwId: 95 },
|
||||
{ name: "AlarmFire-Sensors", cwId: 94 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Automation",
|
||||
children: [
|
||||
{ name: "Automation-General", cwId: 99 },
|
||||
{ name: "Automation-HVAC", cwId: 181 },
|
||||
{ name: "Automation-Lights", cwId: 180 },
|
||||
{ name: "Automation-Locks", cwId: 192 },
|
||||
{ name: "Automation-Thermostat" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "AV",
|
||||
children: [
|
||||
{ name: "AV-Adapters&Cables", cwId: 171 },
|
||||
{ name: "AV-Components", cwId: 172 },
|
||||
{ name: "AV-Mounts", cwId: 191 },
|
||||
{ name: "AV-Other", cwId: 184 },
|
||||
{ name: "AV-Speakers", cwId: 173 },
|
||||
{ name: "AV-Television", cwId: 175 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "StrCbl",
|
||||
children: [
|
||||
{ name: "StrCbl-Jacks", cwId: 186 },
|
||||
{ name: "StrCbl-PatchPanel", cwId: 187 },
|
||||
{ name: "StrCbl-Plates", cwId: 185 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Surveillance",
|
||||
children: [
|
||||
{ name: "Surveillance-Accs", cwId: 90 },
|
||||
{ name: "Surveillance-CamerasAnalog", cwId: 89 },
|
||||
{ name: "Surveillance-CamerasIP", cwId: 88 },
|
||||
{ name: "Surveillance-NVR", cwId: 43 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Ecosystem Tree ──────────────────────────────────────────────────────────
|
||||
|
||||
export const ECOSYSTEM_TREE: Ecosystem[] = [
|
||||
{
|
||||
name: "Networking",
|
||||
manufacturers: [
|
||||
{
|
||||
name: "Ubiquiti",
|
||||
cwId: 248,
|
||||
category: "Technology",
|
||||
subcategoryPrefix: "Network-",
|
||||
},
|
||||
{
|
||||
name: "TP-Link",
|
||||
cwId: 259,
|
||||
category: "Technology",
|
||||
subcategoryPrefix: "Network-",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Video Surveillance",
|
||||
manufacturers: [
|
||||
{
|
||||
name: "Uniview",
|
||||
cwId: 239,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "Surveillance-",
|
||||
},
|
||||
{
|
||||
name: "Hikvision",
|
||||
cwId: 299,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "Surveillance-",
|
||||
},
|
||||
{
|
||||
name: "Alarm.com",
|
||||
cwId: 294,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "Surveillance-",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Burg/Alarm",
|
||||
manufacturers: [
|
||||
{
|
||||
name: "Qolsys",
|
||||
cwId: 376,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "AlarmBurg-",
|
||||
},
|
||||
{
|
||||
name: "DSC",
|
||||
cwId: 287,
|
||||
category: "Field",
|
||||
subcategoryPrefix: "AlarmBurg-",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Derived helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a flat list of all subcategory names under a given category.
|
||||
*/
|
||||
export function getSubcategoriesForCategory(categoryName: string): string[] {
|
||||
const category = CATEGORY_TREE.find((c) => c.name === categoryName);
|
||||
if (!category) return [];
|
||||
|
||||
const subcats: string[] = [];
|
||||
for (const entry of category.entries) {
|
||||
if (isCategoryGroup(entry)) {
|
||||
for (const child of entry.children) {
|
||||
subcats.push(child.name);
|
||||
}
|
||||
} else {
|
||||
subcats.push(entry.name);
|
||||
}
|
||||
}
|
||||
return subcats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all subcategory names under a given umbrella group within a category.
|
||||
* e.g. getSubcategoriesForGroup("Field", "AlarmBurg") → ["AlarmBurg-Communicators", ...]
|
||||
*/
|
||||
export function getSubcategoriesForGroup(
|
||||
categoryName: string,
|
||||
groupName: string,
|
||||
): string[] {
|
||||
const category = CATEGORY_TREE.find((c) => c.name === categoryName);
|
||||
if (!category) return [];
|
||||
|
||||
const group = category.entries.find(
|
||||
(e) => isCategoryGroup(e) && e.name === groupName,
|
||||
);
|
||||
if (!group || !isCategoryGroup(group)) return [];
|
||||
|
||||
return group.children.map((c) => c.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all top-level category names.
|
||||
*/
|
||||
export function getCategoryNames(): string[] {
|
||||
return CATEGORY_TREE.map((c) => c.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the umbrella group name for a given subcategory, or null if it's a
|
||||
* direct entry (not under an umbrella).
|
||||
*/
|
||||
export function getGroupForSubcategory(
|
||||
subcategoryName: string,
|
||||
): { category: string; group: string } | null {
|
||||
for (const cat of CATEGORY_TREE) {
|
||||
for (const entry of cat.entries) {
|
||||
if (isCategoryGroup(entry)) {
|
||||
if (entry.children.some((c) => c.name === subcategoryName)) {
|
||||
return { category: cat.name, group: entry.name };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full tree serialized for the API / UI consumption.
|
||||
* Each top-level category includes its entries, with umbrella groups
|
||||
* expanded to show children.
|
||||
*/
|
||||
export function serializeCategoryTree() {
|
||||
return CATEGORY_TREE.map((cat) => ({
|
||||
name: cat.name,
|
||||
cwId: cat.cwId ?? null,
|
||||
entries: cat.entries.map((entry) => {
|
||||
if (isCategoryGroup(entry)) {
|
||||
return {
|
||||
type: "group" as const,
|
||||
name: entry.name,
|
||||
subcategories: entry.children.map((c) => ({
|
||||
name: c.name,
|
||||
cwId: c.cwId ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "subcategory" as const,
|
||||
name: entry.name,
|
||||
cwId: (entry as SubcategoryNode).cwId ?? null,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ecosystem tree serialized for the API / UI consumption.
|
||||
*/
|
||||
export function serializeEcosystemTree() {
|
||||
return ECOSYSTEM_TREE.map((eco) => ({
|
||||
name: eco.name,
|
||||
manufacturers: eco.manufacturers.map((m) => ({
|
||||
name: m.name,
|
||||
cwId: m.cwId ?? null,
|
||||
category: m.category,
|
||||
subcategoryPrefix: m.subcategoryPrefix,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a flat list of every known subcategory name across all categories.
|
||||
*/
|
||||
export function getAllSubcategoryNames(): string[] {
|
||||
const names: string[] = [];
|
||||
for (const cat of CATEGORY_TREE) {
|
||||
for (const entry of cat.entries) {
|
||||
if (isCategoryGroup(entry)) {
|
||||
for (const child of entry.children) {
|
||||
names.push(child.name);
|
||||
}
|
||||
} else {
|
||||
names.push(entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a CW subcategory name, resolves which top-level category it belongs to.
|
||||
*/
|
||||
export function getCategoryForSubcategory(
|
||||
subcategoryName: string,
|
||||
): string | null {
|
||||
for (const cat of CATEGORY_TREE) {
|
||||
for (const entry of cat.entries) {
|
||||
if (isCategoryGroup(entry)) {
|
||||
if (entry.children.some((c) => c.name === subcategoryName)) {
|
||||
return cat.name;
|
||||
}
|
||||
} else if (entry.name === subcategoryName) {
|
||||
return cat.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a CW manufacturer name, returns which ecosystems it belongs to.
|
||||
*/
|
||||
export function getEcosystemsForManufacturer(
|
||||
manufacturerName: string,
|
||||
): string[] {
|
||||
return ECOSYSTEM_TREE.filter((eco) =>
|
||||
eco.manufacturers.some(
|
||||
(m) => m.name.toLowerCase() === manufacturerName.toLowerCase(),
|
||||
),
|
||||
).map((eco) => eco.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a catalog item (by manufacturer + subcategory) matches a given ecosystem.
|
||||
*/
|
||||
export function matchesEcosystem(
|
||||
ecosystemName: string,
|
||||
manufacturer: string | null,
|
||||
subcategory: string | null,
|
||||
): boolean {
|
||||
const eco = ECOSYSTEM_TREE.find((e) => e.name === ecosystemName);
|
||||
if (!eco) return false;
|
||||
|
||||
return eco.manufacturers.some(
|
||||
(m) =>
|
||||
m.name.toLowerCase() === (manufacturer ?? "").toLowerCase() &&
|
||||
(subcategory ?? "").startsWith(m.subcategoryPrefix),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { connectWiseApi } from "../../../constants";
|
||||
import {
|
||||
CWActivity,
|
||||
CWActivitySummary,
|
||||
CWCreateActivity,
|
||||
CWPatchOperation,
|
||||
} from "./activity.types";
|
||||
|
||||
export const activityCw = {
|
||||
/**
|
||||
* Count Activities
|
||||
*
|
||||
* Returns the total number of activities in ConnectWise.
|
||||
* Optionally accepts CW conditions string for filtered counts.
|
||||
*/
|
||||
countItems: async (conditions?: string): Promise<number> => {
|
||||
const query = conditions
|
||||
? `/sales/activities/count?conditions=${encodeURIComponent(conditions)}`
|
||||
: "/sales/activities/count";
|
||||
const response = await connectWiseApi.get(query);
|
||||
return response.data.count;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Activity Summaries
|
||||
*
|
||||
* Lightweight fetch returning only id and _info (for lastUpdated comparison).
|
||||
* Paginates through all activities.
|
||||
*/
|
||||
fetchAllSummaries: async (): Promise<
|
||||
Collection<number, CWActivitySummary>
|
||||
> => {
|
||||
const allItems = new Collection<number, CWActivitySummary>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const count = await activityCw.countItems();
|
||||
const totalPages = Math.ceil(count / pageSize);
|
||||
|
||||
for (let page = 0; page < totalPages; page++) {
|
||||
const response = await connectWiseApi.get(
|
||||
`/sales/activities?page=${page + 1}&pageSize=${pageSize}&fields=id,_info`,
|
||||
);
|
||||
const items: CWActivitySummary[] = response.data;
|
||||
|
||||
for (const item of items) {
|
||||
allItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Activities (Full)
|
||||
*
|
||||
* Fetches all activities with complete data. Paginates through
|
||||
* the full list. Optionally accepts CW conditions string for filtering.
|
||||
*/
|
||||
fetchAll: async (
|
||||
conditions?: string,
|
||||
): Promise<Collection<number, CWActivity>> => {
|
||||
const allItems = new Collection<number, CWActivity>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const count = await activityCw.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/activities?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
|
||||
);
|
||||
const items: CWActivity[] = response.data;
|
||||
|
||||
for (const item of items) {
|
||||
allItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Single Activity
|
||||
*
|
||||
* Fetches a single activity by its ConnectWise ID.
|
||||
*/
|
||||
fetch: async (id: number): Promise<CWActivity> => {
|
||||
const response = await connectWiseApi.get(`/sales/activities/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Activities by Company
|
||||
*
|
||||
* Fetches all activities associated with a specific ConnectWise company ID.
|
||||
*/
|
||||
fetchByCompany: async (
|
||||
cwCompanyId: number,
|
||||
): Promise<Collection<number, CWActivity>> => {
|
||||
return activityCw.fetchAll(`company/id=${cwCompanyId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Activities by Opportunity
|
||||
*
|
||||
* Fetches all activities associated with a specific opportunity ID.
|
||||
*/
|
||||
fetchByOpportunity: async (
|
||||
opportunityId: number,
|
||||
): Promise<Collection<number, CWActivity>> => {
|
||||
return activityCw.fetchAll(`opportunity/id=${opportunityId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create Activity
|
||||
*
|
||||
* Creates a new activity in ConnectWise.
|
||||
*/
|
||||
create: async (activity: CWCreateActivity): Promise<CWActivity> => {
|
||||
const response = await connectWiseApi.post("/sales/activities", activity);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update Activity (PATCH)
|
||||
*
|
||||
* Updates an existing activity using JSON Patch operations.
|
||||
*/
|
||||
update: async (
|
||||
id: number,
|
||||
operations: CWPatchOperation[],
|
||||
): Promise<CWActivity> => {
|
||||
const response = await connectWiseApi.patch(
|
||||
`/sales/activities/${id}`,
|
||||
operations,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace Activity (PUT)
|
||||
*
|
||||
* Replaces an entire activity record in ConnectWise.
|
||||
*/
|
||||
replace: async (
|
||||
id: number,
|
||||
activity: CWCreateActivity,
|
||||
): Promise<CWActivity> => {
|
||||
const response = await connectWiseApi.put(
|
||||
`/sales/activities/${id}`,
|
||||
activity,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Activity
|
||||
*
|
||||
* Deletes an activity by its ConnectWise ID.
|
||||
*/
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await connectWiseApi.delete(`/sales/activities/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
export interface CWActivity {
|
||||
id: number;
|
||||
name: string;
|
||||
type: CWReference;
|
||||
company: CWCompanyReference;
|
||||
contact: CWContactReference;
|
||||
phoneNumber: string;
|
||||
email: string;
|
||||
status: CWReference;
|
||||
opportunity: CWReference;
|
||||
ticket: CWReference;
|
||||
agreement: CWReference;
|
||||
campaign: CWReference;
|
||||
notes: string;
|
||||
dateStart: string;
|
||||
dateEnd: string;
|
||||
assignTo: CWMemberReference;
|
||||
scheduleStatus: CWReference;
|
||||
reminder: CWReference;
|
||||
where: CWReference;
|
||||
notifyFlag: boolean;
|
||||
mobileGuid: string;
|
||||
currency: CWReference;
|
||||
customFields: CWActivityCustomField[];
|
||||
_info: CWActivityInfo;
|
||||
}
|
||||
|
||||
export interface CWActivityCustomField {
|
||||
id: number;
|
||||
caption: string;
|
||||
type: string;
|
||||
entryMethod: string;
|
||||
numberOfDecimals: number;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface CWActivityInfo {
|
||||
lastUpdated: string;
|
||||
updatedBy: string;
|
||||
dateEntered: string;
|
||||
enteredBy: string;
|
||||
}
|
||||
|
||||
export interface CWActivitySummary {
|
||||
id: number;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWCreateActivity {
|
||||
name: string;
|
||||
type?: { id: number };
|
||||
company?: { id: number };
|
||||
contact?: { id: number };
|
||||
phoneNumber?: string;
|
||||
email?: string;
|
||||
status?: { id: number };
|
||||
opportunity?: { id: number };
|
||||
ticket?: { id: number };
|
||||
agreement?: { id: number };
|
||||
campaign?: { id: number };
|
||||
notes?: string;
|
||||
dateStart?: string;
|
||||
dateEnd?: string;
|
||||
assignTo?: { id: number };
|
||||
scheduleStatus?: { id: number };
|
||||
reminder?: { id: number };
|
||||
where?: { id: number };
|
||||
notifyFlag?: boolean;
|
||||
}
|
||||
|
||||
export interface CWUpdateActivity {
|
||||
name?: string;
|
||||
type?: { id: number };
|
||||
company?: { id: number };
|
||||
contact?: { id: number };
|
||||
phoneNumber?: string;
|
||||
email?: string;
|
||||
status?: { id: number };
|
||||
opportunity?: { id: number };
|
||||
ticket?: { id: number };
|
||||
agreement?: { id: number };
|
||||
campaign?: { id: number };
|
||||
notes?: string;
|
||||
dateStart?: string;
|
||||
dateEnd?: string;
|
||||
assignTo?: { id: number };
|
||||
scheduleStatus?: { id: number };
|
||||
reminder?: { id: number };
|
||||
where?: { id: number };
|
||||
notifyFlag?: boolean;
|
||||
}
|
||||
|
||||
export interface CWPatchOperation {
|
||||
op: "replace" | "add" | "remove";
|
||||
path: string;
|
||||
value: unknown;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { activityCw } from "./activities";
|
||||
import { CWActivity, CWCreateActivity } from "./activity.types";
|
||||
|
||||
/**
|
||||
* Create a new activity in ConnectWise.
|
||||
*
|
||||
* @param activity - The activity data to create
|
||||
* @returns The newly created CW activity object
|
||||
* @throws GenericError if the creation fails
|
||||
*/
|
||||
export const createActivity = async (
|
||||
activity: CWCreateActivity,
|
||||
): Promise<CWActivity> => {
|
||||
try {
|
||||
return await activityCw.create(activity);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error("Error creating activity:", errBody);
|
||||
throw new GenericError({
|
||||
name: "CreateActivityError",
|
||||
message: "Failed to create activity in ConnectWise",
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { activityCw } from "./activities";
|
||||
import { CWActivity } from "./activity.types";
|
||||
|
||||
/**
|
||||
* Fetch a single activity by its ConnectWise ID.
|
||||
*
|
||||
* @param cwActivityId - The ConnectWise activity ID
|
||||
* @returns The full CW activity object
|
||||
* @throws GenericError if the fetch fails
|
||||
*/
|
||||
export const fetchActivity = async (
|
||||
cwActivityId: number,
|
||||
): Promise<CWActivity> => {
|
||||
try {
|
||||
return await activityCw.fetch(cwActivityId);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error(`Error fetching activity with ID ${cwActivityId}:`, errBody);
|
||||
throw new GenericError({
|
||||
name: "FetchActivityError",
|
||||
message: `Failed to fetch activity ${cwActivityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { activityCw } from "./activities";
|
||||
import { CWActivity } from "./activity.types";
|
||||
|
||||
/**
|
||||
* Fetch all activities from ConnectWise with optional conditions.
|
||||
*
|
||||
* @param conditions - Optional CW conditions string for filtering
|
||||
* @returns A Collection of CW activities keyed by their ID
|
||||
* @throws GenericError if the fetch fails
|
||||
*/
|
||||
export const fetchAllActivities = async (
|
||||
conditions?: string,
|
||||
): Promise<Collection<number, CWActivity>> => {
|
||||
try {
|
||||
return await activityCw.fetchAll(conditions);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error("Error fetching all activities:", errBody);
|
||||
throw new GenericError({
|
||||
name: "FetchAllActivitiesError",
|
||||
message: "Failed to fetch activities from ConnectWise",
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export { activityCw } from "./activities";
|
||||
export { fetchActivity } from "./fetchActivity";
|
||||
export { fetchAllActivities } from "./fetchAllActivities";
|
||||
export { createActivity } from "./createActivity";
|
||||
export { updateActivity } from "./updateActivity";
|
||||
|
||||
export type {
|
||||
CWActivity,
|
||||
CWActivitySummary,
|
||||
CWActivityCustomField,
|
||||
CWActivityInfo,
|
||||
CWCreateActivity,
|
||||
CWUpdateActivity,
|
||||
CWPatchOperation,
|
||||
} from "./activity.types";
|
||||
@@ -0,0 +1,29 @@
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { activityCw } from "./activities";
|
||||
import { CWActivity, CWPatchOperation } from "./activity.types";
|
||||
|
||||
/**
|
||||
* Update an existing activity in ConnectWise using JSON Patch operations.
|
||||
*
|
||||
* @param cwActivityId - The ConnectWise activity ID to update
|
||||
* @param operations - Array of JSON Patch operations to apply
|
||||
* @returns The updated CW activity object
|
||||
* @throws GenericError if the update fails
|
||||
*/
|
||||
export const updateActivity = async (
|
||||
cwActivityId: number,
|
||||
operations: CWPatchOperation[],
|
||||
): Promise<CWActivity> => {
|
||||
try {
|
||||
return await activityCw.update(cwActivityId, operations);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error(`Error updating activity with ID ${cwActivityId}:`, errBody);
|
||||
throw new GenericError({
|
||||
name: "UpdateActivityError",
|
||||
message: `Failed to update activity ${cwActivityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { connectWiseApi } from "../../../constants";
|
||||
|
||||
export interface CWMember {
|
||||
id: number;
|
||||
identifier: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
officeEmail: string;
|
||||
inactiveFlag: boolean;
|
||||
_info: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch All CW Members
|
||||
*
|
||||
* Fetches every member from ConnectWise using pagination and returns them
|
||||
* in a Collection keyed by their identifier (e.g. "jroberts").
|
||||
*
|
||||
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
|
||||
*/
|
||||
export const fetchAllCwMembers = async (): Promise<
|
||||
Collection<string, CWMember>
|
||||
> => {
|
||||
const members = new Collection<string, CWMember>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const { data: countData } = await connectWiseApi.get("/system/members/count");
|
||||
const totalPages = Math.ceil(countData.count / pageSize);
|
||||
|
||||
for (let page = 0; page < totalPages; page++) {
|
||||
const { data } = await connectWiseApi.get<CWMember[]>(
|
||||
`/system/members?page=${page + 1}&pageSize=${pageSize}`,
|
||||
);
|
||||
|
||||
for (const member of data) {
|
||||
members.set(member.identifier, member);
|
||||
}
|
||||
}
|
||||
|
||||
return members;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find CW Member Identifier by Email
|
||||
*
|
||||
* Looks up a ConnectWise member whose `officeEmail` matches the provided
|
||||
* email address (case-insensitive) and returns their `identifier` string
|
||||
* (e.g. "jroberts"). Returns `null` if no match is found.
|
||||
*
|
||||
* @param email - The email address to search for
|
||||
* @param members - Optional pre-fetched member collection to search against (avoids extra API call)
|
||||
* @returns {Promise<string | null>} The CW identifier or null
|
||||
*/
|
||||
export const findCwIdentifierByEmail = async (
|
||||
email: string,
|
||||
members?: Collection<string, CWMember>,
|
||||
): Promise<string | null> => {
|
||||
const allMembers = members ?? (await fetchAllCwMembers());
|
||||
const normalised = email.toLowerCase();
|
||||
|
||||
const match = allMembers.find(
|
||||
(m) => m.officeEmail?.toLowerCase() === normalised,
|
||||
);
|
||||
|
||||
return match?.identifier ?? null;
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { prisma } from "../../../constants";
|
||||
import { redis } from "../../../constants";
|
||||
import { CWMember } from "./fetchAllMembers";
|
||||
|
||||
const REDIS_KEY = "cw:members";
|
||||
|
||||
export interface ResolvedMember {
|
||||
/** Local database user ID (null if no matching local user) */
|
||||
id: string | null;
|
||||
/** CW member identifier (e.g. "jroberts") */
|
||||
identifier: string;
|
||||
/** Full name resolved from CW member cache, or raw identifier as fallback */
|
||||
name: string;
|
||||
/** ConnectWise member ID */
|
||||
cwMemberId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW Member Cache
|
||||
*
|
||||
* Dual-layer cache (in-memory + Redis) of ConnectWise members keyed by
|
||||
* their identifier (e.g. "jroberts"). Populated by `refreshCwIdentifiers`
|
||||
* on startup and every 30 minutes thereafter.
|
||||
*/
|
||||
let memberCache = new Collection<string, CWMember>();
|
||||
|
||||
/**
|
||||
* Set the member cache contents.
|
||||
*
|
||||
* Replaces both the in-memory Collection and the Redis snapshot.
|
||||
*
|
||||
* @param members - Collection of CW members keyed by identifier
|
||||
*/
|
||||
export const setMemberCache = async (members: Collection<string, CWMember>) => {
|
||||
memberCache = members;
|
||||
await redis.set(REDIS_KEY, JSON.stringify([...members.values()]));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current member cache.
|
||||
*
|
||||
* Returns the in-memory Collection. If empty, attempts to hydrate from Redis
|
||||
* first. Returns whatever is available (may be empty if Redis is also cold).
|
||||
*/
|
||||
export const getMemberCache = async (): Promise<
|
||||
Collection<string, CWMember>
|
||||
> => {
|
||||
if (memberCache.size > 0) return memberCache;
|
||||
|
||||
const stored = await redis.get(REDIS_KEY);
|
||||
if (stored) {
|
||||
const parsed: CWMember[] = JSON.parse(stored);
|
||||
memberCache = new Collection(parsed.map((m) => [m.identifier, m]));
|
||||
}
|
||||
|
||||
return memberCache;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve CW Identifier to Full Name
|
||||
*
|
||||
* Looks up a ConnectWise member by their identifier in the in-memory cache
|
||||
* and returns their full name. Falls back to the raw identifier if not found.
|
||||
*
|
||||
* @param identifier - The CW member identifier (e.g. "jroberts")
|
||||
* @returns The member's full name (e.g. "John Roberts") or the raw identifier
|
||||
*/
|
||||
export const resolveMemberName = (identifier: string): string => {
|
||||
const member = memberCache.get(identifier);
|
||||
if (!member) return identifier;
|
||||
return `${member.firstName} ${member.lastName}`.trim() || identifier;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve CW Identifier to Full Member Info
|
||||
*
|
||||
* Looks up a ConnectWise member by their identifier in the in-memory cache
|
||||
* and cross-references with the local database to return a complete member
|
||||
* reference including local user ID, CW identifier, full name, and CW member ID.
|
||||
*
|
||||
* @param identifier - The CW member identifier (e.g. "jroberts")
|
||||
* @returns {Promise<ResolvedMember>} Resolved member info
|
||||
*/
|
||||
export const resolveMember = async (
|
||||
identifier: string,
|
||||
): Promise<ResolvedMember> => {
|
||||
const cwMember = memberCache.get(identifier);
|
||||
const name = cwMember
|
||||
? `${cwMember.firstName} ${cwMember.lastName}`.trim() || identifier
|
||||
: identifier;
|
||||
|
||||
const localUser = await prisma.user.findFirst({
|
||||
where: { cwIdentifier: identifier },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return {
|
||||
id: localUser?.id ?? null,
|
||||
identifier,
|
||||
name,
|
||||
cwMemberId: cwMember?.id ?? null,
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user