Compare commits

...

10 Commits

Author SHA1 Message Date
HoloPanio 7411310083 fix: add migration for missing columns (cwIdentifier, catalog categories, productSequence) 2026-03-01 18:28:05 -06:00
HoloPanio 30b408e0db feat: add product to opportunity route, local product sequencing
- Add POST /v1/sales/opportunities/:identifier/products with field-level permission gating
- Add CWForecastItemCreate type for forecast item creation
- Store product display order locally (productSequence Int[] on Opportunity)
- Rewrite resequenceProducts to be local-only (no CW PUT, stable IDs)
- Remove reorderProducts CW util (PUT regenerated IDs & broke procurement)
- Update fetchProducts to apply local ordering with CW sequenceNumber fallback
- Add productSequence to OpportunityController.toJson()
- Update API_ROUTES.md, PERMISSIONS.md, PermissionNodes.ts
2026-03-01 18:01:02 -06:00
HoloPanio d7b374f8ab feat: sales activities, forecast products, catalog categories, member cache, procurement filters, and comprehensive tests
New features:
- ActivityController and manager for CW sales activities (CRUD)
- ForecastProductController for opportunity forecast/product lines
- CW member cache with dual-layer (in-memory + Redis) resolution
- Catalog category/subcategory/ecosystem taxonomy module
- Quote statuses type definitions with CW mapping
- User-defined fields (UDF) module with cache and event refresh
- Company sites CW module with serialization
- Procurement manager filters (category, ecosystem, manufacturer, price, stock)
- Opportunity notes CRUD and product line management via CW API
- Opportunity type definitions endpoint

Updates:
- OpportunityController: CW refresh, company hydration, activities, custom fields
- UserController: cwIdentifier field for CW member linking
- CatalogItemController: category/subcategory fields from CW
- PermissionNodes: sales note/product CRUD nodes, subCategories, collectPermissions
- API routes: procurement categories/filters, sales notes/products, opportunity types
- Global events: UDF and member refresh intervals on startup

Tests (414 passing):
- ActivityController, ForecastProductController, OpportunityController unit tests
- UserController cwIdentifier tests
- catalogCategories, companySites, memberCache, procurement module tests
- activityTypes, opportunityTypes, quoteStatuses type tests
- permissionNodes subCategories and getAllPermissionNodes tests
- Updated test setup with redis mock, API method mocks, and builder helpers
2026-03-01 13:19:00 -06:00
HoloPanio 883b648d5e fix: add identifier column migration and fix entrypoint resolve logic
- Add explicit migration for CatalogItem.identifier column
- Fix entrypoint script: resolve only migrations on 'Failed' lines (not all)
- Remove auto-diff generation (use committed migration files instead)
- Remove 2>/dev/null that swallowed migration errors
2026-02-27 17:44:08 -06:00
HoloPanio b787120461 fix: start HTTP server before background init to prevent bad gateway 2026-02-27 17:06:36 -06:00
HoloPanio 1326725995 fix: resolve failed migrations before deploying 2026-02-27 16:26:31 -06:00
HoloPanio 508fa39835 fix: crash loop recovery, auto-migrations, CI test pipeline
- Wrap startup syncs in safeStartup() to prevent crash on external service failure
- Add migrate-entrypoint.sh for auto-generating migrations from schema diff
- Update Dockerfile migration stage to use entrypoint script
- Add test job to build-and-publish workflow (runs before build)
- Add tests.yaml workflow to run tests on every push
- Fix test setup to use real RSA key pair instead of plain strings
- Add test script to package.json
2026-02-27 16:11:28 -06:00
HoloPanio b1f6462ac3 Fix UserController permission serialization and include current updates 2026-02-27 14:38:22 -06:00
HoloPanio 51eb36f4a6 fix: resolve type errors across test suite 2026-02-26 12:49:04 -06:00
HoloPanio 827b018f25 auto-create admin role on startup, use API_BASE_URL for auth redirects 2026-02-25 23:00:51 -06:00
152 changed files with 17720 additions and 105 deletions
+11 -9
View File
@@ -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
+20
View File
@@ -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
+25
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
{
"chat.tools.terminal.autoApprove": {
"bun": true
}
}
+1487
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -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"]
+148
View File
@@ -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
View File
@@ -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:
+17
View File
@@ -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=="],
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./tests/setup.ts"]
+5
View File
@@ -47,6 +47,11 @@ export type Company = Prisma.CompanyModel
*
*/
export type CatalogItem = Prisma.CatalogItemModel
/**
* Model Opportunity
*
*/
export type Opportunity = Prisma.OpportunityModel
/**
* Model CredentialType
*
+5
View File
@@ -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
+136 -1
View File
@@ -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',
+1
View File
@@ -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'
+209 -2
View File
@@ -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'>
+128
View File
@@ -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
+39 -1
View File
@@ -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
View File
@@ -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",
+23
View File
@@ -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,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[];
+80
View File
@@ -34,6 +34,8 @@ model User {
emailVerified DateTime?
image String?
cwIdentifier String?
userId String @unique
token String?
@@ -77,6 +79,7 @@ model Company {
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
+2 -2
View File
@@ -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
View File
@@ -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,
+12 -4
View File
@@ -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 response = apiResponse.successful(
"Company Fetched Successfully!",
company.toJson({
const companyData = company.toJson({
includeAddress,
includePrimaryContact,
includeAllContacts,
}),
});
const gatedData = await processObjectValuePerms(
companyData,
"obj.company",
c.get("user"),
);
const response = apiResponse.successful(
"Company Fetched Successfully!",
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+9 -1
View File
@@ -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);
},
+8 -1
View File
@@ -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
+8 -1
View File
@@ -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);
},
+12 -3
View File
@@ -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);
},
+8 -1
View File
@@ -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);
},
+7 -1
View File
@@ -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);
},
+8 -1
View File
@@ -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);
},
+8 -1
View File
@@ -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);
},
+32
View File
@@ -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"] }),
);
+32
View File
@@ -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"] }),
);
+28
View File
@@ -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"] }),
);
+28
View File
@@ -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"] }),
);
+26
View File
@@ -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"] }),
);
+24
View File
@@ -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"] }),
);
+80
View File
@@ -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"] }),
);
+32
View File
@@ -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"] }),
);
+21
View File
@@ -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,
};
+8 -1
View File
@@ -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);
},
+9 -2
View File
@@ -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) =>
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);
},
+7 -2
View File
@@ -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);
},
+7
View File
@@ -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;
+7
View File
@@ -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;
+69
View File
@@ -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"] }),
);
+25
View File
@@ -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"] }),
);
+47
View File
@@ -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"] }),
);
+33
View File
@@ -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"] }),
);
+34
View File
@@ -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"] }),
);
+34
View File
@@ -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"] }),
);
+25
View File
@@ -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"] }),
);
+25
View File
@@ -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"] }),
);
+25
View File
@@ -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"] }),
);
+37
View File
@@ -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"] }),
);
+57
View File
@@ -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"] }),
);
+24
View File
@@ -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"] }),
);
+54
View File
@@ -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"] }),
);
+20
View File
@@ -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"] }),
);
+31
View File
@@ -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,
};
+2
View File
@@ -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;
+9 -1
View File
@@ -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);
},
+9 -1
View File
@@ -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);
},
+7 -3
View File
@@ -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"] }),
+8 -1
View File
@@ -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);
},
+8 -2
View File
@@ -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);
},
+12 -2
View File
@@ -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);
},
+7
View File
@@ -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";
+252
View File
@@ -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,
};
}
}
+231
View File
@@ -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,
};
}
}
+76 -12
View File
@@ -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,
: 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)
if (!this.cw_Data?.defaultContact?.communicationItems)
return null;
return (
this.cw_Data?.defaultContact.communicationItems.find(
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,
};
}
}
+801
View File
@@ -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()) ?? [],
};
}
}
+54 -1
View File
@@ -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,
+125 -26
View File
@@ -1,41 +1,44 @@
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 { 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,
websocket: engine.handler().websocket,
@@ -49,3 +52,99 @@ 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 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);
+211
View File
@@ -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,
});
}
},
};
+7 -4
View File
@@ -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,
});
},
+272
View File
@@ -0,0 +1,272 @@
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";
/**
* Build a CompanyController with hydrated CW data from a Prisma Company record.
*/
async function buildCompanyController(
company: Company,
): Promise<CompanyController> {
const ctrl = new CompanyController(company);
await ctrl.hydrateCwData();
return ctrl;
}
/**
* Fetch ActivityController[] for an opportunity from ConnectWise.
*/
async function buildActivities(
cwOpportunityId: number,
): Promise<ActivityController[]> {
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
return collection.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.
*
* @param identifier - The internal ID (string) or CW opportunity ID (number)
* @returns {Promise<OpportunityController>}
*/
async fetchItem(identifier: string | number): Promise<OpportunityController> {
const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier));
// 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 activities = await buildActivities(updated.cwOpportunityId);
return new OpportunityController(updated, {
company: updated.company
? await buildCompanyController(updated.company)
: undefined,
customFields: cwData.customFields ?? [],
activities,
});
},
/**
* Fetch All Opportunities (Paginated)
*
* @param page - Page number (1-based)
* @param rpp - Records per page
* @param opts - Optional filters
* @returns {Promise<OpportunityController[]>}
*/
async fetchPages(
page: number,
rpp: number,
opts?: { includeClosed?: boolean },
): Promise<OpportunityController[]> {
const skip = (Math.max(page, 1) - 1) * rpp;
const items = await prisma.opportunity.findMany({
where: opts?.includeClosed ? undefined : { closedFlag: false },
include: { company: true },
skip,
take: rpp,
orderBy: { createdAt: "desc" },
});
return Promise.all(
items.map(
async (item) =>
new OpportunityController(item, {
company: item.company
? await buildCompanyController(item.company)
: undefined,
activities: await buildActivities(item.cwOpportunityId),
}),
),
);
},
/**
* Search Opportunities
*
* Search opportunities by name, company name, contact name, notes,
* sales rep, or status with pagination support.
*
* @param query - Search query string
* @param page - Page number (1-based)
* @param rpp - Records per page
* @param opts - Optional filters
* @returns {Promise<OpportunityController[]>}
*/
async search(
query: string,
page: number,
rpp: number,
opts?: { includeClosed?: boolean },
): Promise<OpportunityController[]> {
const skip = (Math.max(page, 1) - 1) * rpp;
const 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) =>
new OpportunityController(item, {
company: item.company
? await buildCompanyController(item.company)
: undefined,
activities: await buildActivities(item.cwOpportunityId),
}),
),
);
},
/**
* 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.
*
* @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) =>
new OpportunityController(item, {
company: item.company
? await buildCompanyController(item.company)
: undefined,
activities: await buildActivities(item.cwOpportunityId),
}),
),
);
},
};
+332
View File
@@ -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);
},
};
+7
View File
@@ -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,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,
});
}
};
+15
View File
@@ -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;
};
+104
View File
@@ -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,
};
};
@@ -0,0 +1,46 @@
import { connectWiseApi, prisma } from "../../../constants";
import { events } from "../../globalEvents";
import { fetchAllCwMembers, findCwIdentifierByEmail } from "./fetchAllMembers";
import { setMemberCache } from "./memberCache";
/**
* Refresh CW Identifiers
*
* Fetches all CW members and all users from the database, then updates
* each user's `cwIdentifier` field by matching their email to a CW member's
* `officeEmail`. Only users whose identifier has changed (or was previously
* null) are updated to avoid unnecessary writes.
*
* Also refreshes the in-memory member cache used for name resolution.
*/
export const refreshCwIdentifiers = async () => {
events.emit("cw:members:refresh:started");
const allMembers = await fetchAllCwMembers();
await setMemberCache(allMembers);
const allUsers = await prisma.user.findMany({
select: { id: true, email: true, cwIdentifier: true },
});
let updatedCount = 0;
await Promise.all(
allUsers.map(async (user) => {
const identifier = await findCwIdentifierByEmail(user.email, allMembers);
if (identifier !== user.cwIdentifier) {
await prisma.user.update({
where: { id: user.id },
data: { cwIdentifier: identifier },
});
updatedCount++;
}
}),
);
events.emit("cw:members:refresh:completed", {
totalMembers: allMembers.size,
totalUsers: allUsers.length,
usersUpdated: updatedCount,
});
};
@@ -0,0 +1,28 @@
import { Collection } from "@discordjs/collection";
import GenericError from "../../../Errors/GenericError";
import { opportunityCw } from "./opportunities";
import { CWOpportunity } from "./opportunity.types";
/**
* Fetch all opportunities from ConnectWise with optional conditions.
*
* @param conditions - Optional CW conditions string for filtering
* @returns A Collection of CW opportunities keyed by their ID
* @throws GenericError if the fetch fails
*/
export const fetchAllOpportunities = async (
conditions?: string,
): Promise<Collection<number, CWOpportunity>> => {
try {
return await opportunityCw.fetchAll(conditions);
} catch (error) {
const errBody = (error as any).response?.data || error;
console.error("Error fetching all opportunities:", errBody);
throw new GenericError({
name: "FetchAllOpportunitiesError",
message: "Failed to fetch opportunities from ConnectWise",
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
};
@@ -0,0 +1,31 @@
import { Collection } from "@discordjs/collection";
import GenericError from "../../../Errors/GenericError";
import { opportunityCw } from "./opportunities";
import { CWOpportunity } from "./opportunity.types";
/**
* Fetch all opportunities for a specific company from ConnectWise.
*
* @param cwCompanyId - The ConnectWise company ID
* @returns A Collection of CW opportunities for the company keyed by their ID
* @throws GenericError if the fetch fails
*/
export const fetchCompanyOpportunities = async (
cwCompanyId: number,
): Promise<Collection<number, CWOpportunity>> => {
try {
return await opportunityCw.fetchByCompany(cwCompanyId);
} catch (error) {
const errBody = (error as any).response?.data || error;
console.error(
`Error fetching opportunities for company ${cwCompanyId}:`,
errBody,
);
throw new GenericError({
name: "FetchCompanyOpportunitiesError",
message: `Failed to fetch opportunities for company ${cwCompanyId}`,
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
};

Some files were not shown because too many files have changed in this diff Show More