all the haul

This commit is contained in:
2026-04-07 23:56:31 +00:00
parent 87cce83030
commit 24f303355b
244 changed files with 33743 additions and 11249 deletions
@@ -26,7 +26,7 @@ services:
image: adminer
restart: unless-stopped
ports:
- 8080:8080
- 8081:8080
depends_on:
- pgsql
redisinsight:
-2
View File
@@ -1,2 +0,0 @@
node_modules
daemon
@@ -1,4 +1,4 @@
name: Build and Publish
name: API - Build and Publish
on:
release:
@@ -8,6 +8,9 @@ jobs:
test:
name: Test
runs-on: ubuntu-latest
defaults:
run:
working-directory: api
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -47,6 +50,7 @@ jobs:
- name: Build and push the Docker image
uses: docker/build-push-action@v6
with:
context: ./api
push: true
target: runtime
tags: |
@@ -56,6 +60,7 @@ jobs:
- name: Build and push the migration image
uses: docker/build-push-action@v6
with:
context: ./api
push: true
target: migration
tags: |
@@ -66,6 +71,9 @@ jobs:
name: Run Migrations
needs: [build]
runs-on: ubuntu-latest
defaults:
run:
working-directory: api
steps:
- name: Set the Kubernetes context
uses: azure/k8s-set-context@v2
@@ -108,8 +116,8 @@ jobs:
with:
lintType: dryrun
manifests: |
kubernetes/deployment.yaml
kubernetes/ingress.yaml
api/kubernetes/deployment.yaml
api/kubernetes/ingress.yaml
namespace: optima
- name: Deploy to the Kubernetes cluster
@@ -119,7 +127,7 @@ jobs:
force: true
skip-tls-verify: true
manifests: |
kubernetes/deployment.yaml
kubernetes/ingress.yaml
api/kubernetes/deployment.yaml
api/kubernetes/ingress.yaml
images: |
ghcr.io/project-optima/ttscm-api:${{ github.event.release.tag_name }}
@@ -1,4 +1,4 @@
name: Tests
name: API - Tests
on:
push:
@@ -8,6 +8,9 @@ jobs:
test:
name: Test
runs-on: ubuntu-latest
defaults:
run:
working-directory: api
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -1,4 +1,4 @@
name: Build and Publish
name: UI - Build and Publish
on:
release:
@@ -28,7 +28,7 @@ jobs:
- name: Build and push the Docker image
uses: docker/build-push-action@v6
with:
context: .
context: ./ui
push: true
build-args: |
PUBLIC_API_URL=https://opt-api.osdci.net
@@ -41,6 +41,9 @@ jobs:
runs-on: macos-latest
permissions:
contents: write
defaults:
run:
working-directory: ui
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -68,14 +71,17 @@ jobs:
uses: softprops/action-gh-release@v2
with:
files: |
out/make/**/*.dmg
out/make/**/*.zip
ui/out/make/**/*.dmg
ui/out/make/**/*.zip
build-desktop-windows:
name: Build Desktop (Windows)
runs-on: windows-latest
permissions:
contents: write
defaults:
run:
working-directory: ui
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -97,41 +103,4 @@ jobs:
uses: softprops/action-gh-release@v2
with:
files: |
out/make/**/*.exe
out/make/**/*.nupkg
out/make/**/*.msi
deploy:
name: Deploy
needs: [build-server]
runs-on: ubuntu-latest
steps:
- name: Set the Kubernetes context
uses: azure/k8s-set-context@v2
with:
method: kubeconfig
kubeconfig: ${{ secrets.KUBECONFIG }}
- name: Checkout source code
uses: actions/checkout@v4
- name: Lint Kubernetes manifests
uses: azure/k8s-lint@v3
with:
lintType: dryrun
manifests: |
kubernetes/deployment.yaml
kubernetes/ingress.yaml
namespace: optima
- name: Deploy to the Kubernetes cluster
uses: azure/k8s-deploy@v5
with:
namespace: optima
force: true
skip-tls-verify: true
manifests: |
kubernetes/deployment.yaml
kubernetes/ingress.yaml
images: |
ghcr.io/project-optima/ttscm-ui:${{ github.event.release.tag_name }}
ui/out/make/**/*.exe
+53 -123
View File
@@ -1,139 +1,69 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# macOS metadata
__MACOSX/
.DS_Store
.AppleDouble
.LSOverride
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
# Dependencies
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
# Environment variables
.env
.env.*
!.env.example
!.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Logs
*.log
*.jsonl
logs/
# Next.js build output
.next
out
# TypeScript
*.tsbuildinfo
# Nuxt.js build / generate output
.nuxt
dist
# Bun
.bun/
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# Build outputs
dist/
build/
out/
.output/
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
# SvelteKit
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite logs files
# Vite
.vite/
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Test coverage
coverage/
.nyc_output/
# Local Docker data volumes
.docker/postgres/
.docker/redis/dump.rdb
# Generated Prisma client
api/generated/
dalpuri/generated/
# Secret key files
api/.permissions.key
api/.refreshToken.key
api/.secureKeys.key
api/.accessToken.key
api/.secureValues.key
api/production-keys/
api/public-keys/
# Temporary / scratch files
git-backup.zip
configurations-top-150-and-stats.json
-1
View File
@@ -1 +0,0 @@
_
-2
View File
@@ -1,2 +0,0 @@
node_modules/
daemon
-8
View File
@@ -1,8 +0,0 @@
module.exports = {
trailingComma: "es5",
tabWidth: 2,
semi: true,
singleQuote: false,
arrowParens: "always",
useTabs: false,
};
-9
View File
@@ -1,9 +0,0 @@
nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
yarnPath: .yarn/releases/yarn-3.2.0.cjs
+165
View File
@@ -17,6 +17,16 @@ import * as Prisma from './internal/prismaNamespaceBrowser.ts'
export { Prisma }
export * as $Enums from './enums.ts'
export * from './enums.ts';
/**
* Model SyncJobRun
*
*/
export type SyncJobRun = Prisma.SyncJobRunModel
/**
* Model SyncStepLog
*
*/
export type SyncStepLog = Prisma.SyncStepLogModel
/**
* Model Session
*
@@ -32,6 +42,16 @@ export type User = Prisma.UserModel
*
*/
export type Role = Prisma.RoleModel
/**
* Model CorporateLocation
*
*/
export type CorporateLocation = Prisma.CorporateLocationModel
/**
* Model InternalDepartment
*
*/
export type InternalDepartment = Prisma.InternalDepartmentModel
/**
* Model UnifiSite
*
@@ -42,16 +62,156 @@ export type UnifiSite = Prisma.UnifiSiteModel
*
*/
export type Company = Prisma.CompanyModel
/**
* Model CompanyAddress
*
*/
export type CompanyAddress = Prisma.CompanyAddressModel
/**
* Model Contact
*
*/
export type Contact = Prisma.ContactModel
/**
* Model CatalogItemType
*
*/
export type CatalogItemType = Prisma.CatalogItemTypeModel
/**
* Model CatalogCategory
*
*/
export type CatalogCategory = Prisma.CatalogCategoryModel
/**
* Model CatalogSubcategory
*
*/
export type CatalogSubcategory = Prisma.CatalogSubcategoryModel
/**
* Model CatalogManufacturer
*
*/
export type CatalogManufacturer = Prisma.CatalogManufacturerModel
/**
* Model WarehouseBin
*
*/
export type WarehouseBin = Prisma.WarehouseBinModel
/**
* Model ProductInventory
*
*/
export type ProductInventory = Prisma.ProductInventoryModel
/**
* Model Warehouse
*
*/
export type Warehouse = Prisma.WarehouseModel
/**
* Model MinimumStockByWarehouse
*
*/
export type MinimumStockByWarehouse = Prisma.MinimumStockByWarehouseModel
/**
* Model CatalogItem
*
*/
export type CatalogItem = Prisma.CatalogItemModel
/**
* Model ProductData
*
*/
export type ProductData = Prisma.ProductDataModel
/**
* Model ServiceTicket
*
*/
export type ServiceTicket = Prisma.ServiceTicketModel
/**
* Model ServiceTicketNote
*
*/
export type ServiceTicketNote = Prisma.ServiceTicketNoteModel
/**
* Model ServiceTicketType
*
*/
export type ServiceTicketType = Prisma.ServiceTicketTypeModel
/**
* Model ServiceTicketBoard
*
*/
export type ServiceTicketBoard = Prisma.ServiceTicketBoardModel
/**
* Model ServiceTicketLocation
*
*/
export type ServiceTicketLocation = Prisma.ServiceTicketLocationModel
/**
* Model ServiceTicketSource
*
*/
export type ServiceTicketSource = Prisma.ServiceTicketSourceModel
/**
* Model ServiceTicketImpact
*
*/
export type ServiceTicketImpact = Prisma.ServiceTicketImpactModel
/**
* Model ServiceTicketPriority
*
*/
export type ServiceTicketPriority = Prisma.ServiceTicketPriorityModel
/**
* Model ServiceTicketSeverity
*
*/
export type ServiceTicketSeverity = Prisma.ServiceTicketSeverityModel
/**
* Model ServiceTicketFinalData
*
*/
export type ServiceTicketFinalData = Prisma.ServiceTicketFinalDataModel
/**
* Model OpportunityStage
*
*/
export type OpportunityStage = Prisma.OpportunityStageModel
/**
* Model OpportunityType
*
*/
export type OpportunityType = Prisma.OpportunityTypeModel
/**
* Model OpportunityStatus
*
*/
export type OpportunityStatus = Prisma.OpportunityStatusModel
/**
* Model Opportunity
*
*/
export type Opportunity = Prisma.OpportunityModel
/**
* Model ScheduleStatus
*
*/
export type ScheduleStatus = Prisma.ScheduleStatusModel
/**
* Model ScheduleType
*
*/
export type ScheduleType = Prisma.ScheduleTypeModel
/**
* Model ScheduleSpan
*
*/
export type ScheduleSpan = Prisma.ScheduleSpanModel
/**
* Model Schedule
*
*/
export type Schedule = Prisma.ScheduleModel
/**
* Model CredentialType
*
@@ -72,6 +232,11 @@ export type Credential = Prisma.CredentialModel
*
*/
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
/**
* Model TaxCode
*
*/
export type TaxCode = Prisma.TaxCodeModel
/**
* Model CwMember
*
+170 -3
View File
@@ -28,9 +28,11 @@ export * from "./enums.ts"
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient()
* // Fetch zero or more Sessions
* const sessions = await prisma.session.findMany()
* const prisma = new PrismaClient({
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
* })
* // Fetch zero or more SyncJobRuns
* const syncJobRuns = await prisma.syncJobRun.findMany()
* ```
*
* Read more in our [docs](https://pris.ly/d/client).
@@ -39,6 +41,16 @@ export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
/**
* Model SyncJobRun
*
*/
export type SyncJobRun = Prisma.SyncJobRunModel
/**
* Model SyncStepLog
*
*/
export type SyncStepLog = Prisma.SyncStepLogModel
/**
* Model Session
*
@@ -54,6 +66,16 @@ export type User = Prisma.UserModel
*
*/
export type Role = Prisma.RoleModel
/**
* Model CorporateLocation
*
*/
export type CorporateLocation = Prisma.CorporateLocationModel
/**
* Model InternalDepartment
*
*/
export type InternalDepartment = Prisma.InternalDepartmentModel
/**
* Model UnifiSite
*
@@ -64,16 +86,156 @@ export type UnifiSite = Prisma.UnifiSiteModel
*
*/
export type Company = Prisma.CompanyModel
/**
* Model CompanyAddress
*
*/
export type CompanyAddress = Prisma.CompanyAddressModel
/**
* Model Contact
*
*/
export type Contact = Prisma.ContactModel
/**
* Model CatalogItemType
*
*/
export type CatalogItemType = Prisma.CatalogItemTypeModel
/**
* Model CatalogCategory
*
*/
export type CatalogCategory = Prisma.CatalogCategoryModel
/**
* Model CatalogSubcategory
*
*/
export type CatalogSubcategory = Prisma.CatalogSubcategoryModel
/**
* Model CatalogManufacturer
*
*/
export type CatalogManufacturer = Prisma.CatalogManufacturerModel
/**
* Model WarehouseBin
*
*/
export type WarehouseBin = Prisma.WarehouseBinModel
/**
* Model ProductInventory
*
*/
export type ProductInventory = Prisma.ProductInventoryModel
/**
* Model Warehouse
*
*/
export type Warehouse = Prisma.WarehouseModel
/**
* Model MinimumStockByWarehouse
*
*/
export type MinimumStockByWarehouse = Prisma.MinimumStockByWarehouseModel
/**
* Model CatalogItem
*
*/
export type CatalogItem = Prisma.CatalogItemModel
/**
* Model ProductData
*
*/
export type ProductData = Prisma.ProductDataModel
/**
* Model ServiceTicket
*
*/
export type ServiceTicket = Prisma.ServiceTicketModel
/**
* Model ServiceTicketNote
*
*/
export type ServiceTicketNote = Prisma.ServiceTicketNoteModel
/**
* Model ServiceTicketType
*
*/
export type ServiceTicketType = Prisma.ServiceTicketTypeModel
/**
* Model ServiceTicketBoard
*
*/
export type ServiceTicketBoard = Prisma.ServiceTicketBoardModel
/**
* Model ServiceTicketLocation
*
*/
export type ServiceTicketLocation = Prisma.ServiceTicketLocationModel
/**
* Model ServiceTicketSource
*
*/
export type ServiceTicketSource = Prisma.ServiceTicketSourceModel
/**
* Model ServiceTicketImpact
*
*/
export type ServiceTicketImpact = Prisma.ServiceTicketImpactModel
/**
* Model ServiceTicketPriority
*
*/
export type ServiceTicketPriority = Prisma.ServiceTicketPriorityModel
/**
* Model ServiceTicketSeverity
*
*/
export type ServiceTicketSeverity = Prisma.ServiceTicketSeverityModel
/**
* Model ServiceTicketFinalData
*
*/
export type ServiceTicketFinalData = Prisma.ServiceTicketFinalDataModel
/**
* Model OpportunityStage
*
*/
export type OpportunityStage = Prisma.OpportunityStageModel
/**
* Model OpportunityType
*
*/
export type OpportunityType = Prisma.OpportunityTypeModel
/**
* Model OpportunityStatus
*
*/
export type OpportunityStatus = Prisma.OpportunityStatusModel
/**
* Model Opportunity
*
*/
export type Opportunity = Prisma.OpportunityModel
/**
* Model ScheduleStatus
*
*/
export type ScheduleStatus = Prisma.ScheduleStatusModel
/**
* Model ScheduleType
*
*/
export type ScheduleType = Prisma.ScheduleTypeModel
/**
* Model ScheduleSpan
*
*/
export type ScheduleSpan = Prisma.ScheduleSpanModel
/**
* Model Schedule
*
*/
export type Schedule = Prisma.ScheduleModel
/**
* Model CredentialType
*
@@ -94,6 +256,11 @@ export type Credential = Prisma.CredentialModel
*
*/
export type GeneratedQuotes = Prisma.GeneratedQuotesModel
/**
* Model TaxCode
*
*/
export type TaxCode = Prisma.TaxCodeModel
/**
* Model CwMember
*
+525 -176
View File
@@ -29,20 +29,18 @@ export type StringFilter<$PrismaModel = never> = {
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
export type EnumSyncJobTypeFilter<$PrismaModel = never> = {
equals?: $Enums.SyncJobType | Prisma.EnumSyncJobTypeFieldRefInput<$PrismaModel>
in?: $Enums.SyncJobType[] | Prisma.ListEnumSyncJobTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.SyncJobType[] | Prisma.ListEnumSyncJobTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumSyncJobTypeFilter<$PrismaModel> | $Enums.SyncJobType
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
export type EnumSyncJobStatusFilter<$PrismaModel = never> = {
equals?: $Enums.SyncJobStatus | Prisma.EnumSyncJobStatusFieldRefInput<$PrismaModel>
in?: $Enums.SyncJobStatus[] | Prisma.ListEnumSyncJobStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.SyncJobStatus[] | Prisma.ListEnumSyncJobStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumSyncJobStatusFilter<$PrismaModel> | $Enums.SyncJobStatus
}
export type DateTimeNullableFilter<$PrismaModel = never> = {
@@ -56,6 +54,32 @@ export type DateTimeNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
@@ -79,26 +103,24 @@ export type StringWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
export type EnumSyncJobTypeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.SyncJobType | Prisma.EnumSyncJobTypeFieldRefInput<$PrismaModel>
in?: $Enums.SyncJobType[] | Prisma.ListEnumSyncJobTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.SyncJobType[] | Prisma.ListEnumSyncJobTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumSyncJobTypeWithAggregatesFilter<$PrismaModel> | $Enums.SyncJobType
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
_min?: Prisma.NestedEnumSyncJobTypeFilter<$PrismaModel>
_max?: Prisma.NestedEnumSyncJobTypeFilter<$PrismaModel>
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
export type EnumSyncJobStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.SyncJobStatus | Prisma.EnumSyncJobStatusFieldRefInput<$PrismaModel>
in?: $Enums.SyncJobStatus[] | Prisma.ListEnumSyncJobStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.SyncJobStatus[] | Prisma.ListEnumSyncJobStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumSyncJobStatusWithAggregatesFilter<$PrismaModel> | $Enums.SyncJobStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
_min?: Prisma.NestedEnumSyncJobStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumSyncJobStatusFilter<$PrismaModel>
}
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
@@ -115,21 +137,6 @@ export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
@@ -148,6 +155,20 @@ export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
@@ -159,76 +180,6 @@ export type IntFilter<$PrismaModel = never> = {
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type IntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type FloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
export type JsonFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonFilterBase<$PrismaModel>>, 'path'>>,
@@ -253,6 +204,22 @@ export type JsonFilterBase<$PrismaModel = never> = {
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type JsonWithAggregatesFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
@@ -280,6 +247,219 @@ export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
_max?: Prisma.NestedJsonFilter<$PrismaModel>
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type IntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}
export type EnumUSStateNullableFilter<$PrismaModel = never> = {
equals?: $Enums.USState | Prisma.EnumUSStateFieldRefInput<$PrismaModel> | null
in?: $Enums.USState[] | Prisma.ListEnumUSStateFieldRefInput<$PrismaModel> | null
notIn?: $Enums.USState[] | Prisma.ListEnumUSStateFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumUSStateNullableFilter<$PrismaModel> | $Enums.USState | null
}
export type EnumCountryNullableFilter<$PrismaModel = never> = {
equals?: $Enums.Country | Prisma.EnumCountryFieldRefInput<$PrismaModel> | null
in?: $Enums.Country[] | Prisma.ListEnumCountryFieldRefInput<$PrismaModel> | null
notIn?: $Enums.Country[] | Prisma.ListEnumCountryFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumCountryNullableFilter<$PrismaModel> | $Enums.Country | null
}
export type EnumUSStateNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.USState | Prisma.EnumUSStateFieldRefInput<$PrismaModel> | null
in?: $Enums.USState[] | Prisma.ListEnumUSStateFieldRefInput<$PrismaModel> | null
notIn?: $Enums.USState[] | Prisma.ListEnumUSStateFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumUSStateNullableWithAggregatesFilter<$PrismaModel> | $Enums.USState | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumUSStateNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumUSStateNullableFilter<$PrismaModel>
}
export type EnumCountryNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.Country | Prisma.EnumCountryFieldRefInput<$PrismaModel> | null
in?: $Enums.Country[] | Prisma.ListEnumCountryFieldRefInput<$PrismaModel> | null
notIn?: $Enums.Country[] | Prisma.ListEnumCountryFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumCountryNullableWithAggregatesFilter<$PrismaModel> | $Enums.Country | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumCountryNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumCountryNullableFilter<$PrismaModel>
}
export type EnumGenderTypeNullableFilter<$PrismaModel = never> = {
equals?: $Enums.GenderType | Prisma.EnumGenderTypeFieldRefInput<$PrismaModel> | null
in?: $Enums.GenderType[] | Prisma.ListEnumGenderTypeFieldRefInput<$PrismaModel> | null
notIn?: $Enums.GenderType[] | Prisma.ListEnumGenderTypeFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumGenderTypeNullableFilter<$PrismaModel> | $Enums.GenderType | null
}
export type EnumPhoneTypeNullableFilter<$PrismaModel = never> = {
equals?: $Enums.PhoneType | Prisma.EnumPhoneTypeFieldRefInput<$PrismaModel> | null
in?: $Enums.PhoneType[] | Prisma.ListEnumPhoneTypeFieldRefInput<$PrismaModel> | null
notIn?: $Enums.PhoneType[] | Prisma.ListEnumPhoneTypeFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumPhoneTypeNullableFilter<$PrismaModel> | $Enums.PhoneType | null
}
export type EnumGenderTypeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.GenderType | Prisma.EnumGenderTypeFieldRefInput<$PrismaModel> | null
in?: $Enums.GenderType[] | Prisma.ListEnumGenderTypeFieldRefInput<$PrismaModel> | null
notIn?: $Enums.GenderType[] | Prisma.ListEnumGenderTypeFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumGenderTypeNullableWithAggregatesFilter<$PrismaModel> | $Enums.GenderType | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumGenderTypeNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumGenderTypeNullableFilter<$PrismaModel>
}
export type EnumPhoneTypeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.PhoneType | Prisma.EnumPhoneTypeFieldRefInput<$PrismaModel> | null
in?: $Enums.PhoneType[] | Prisma.ListEnumPhoneTypeFieldRefInput<$PrismaModel> | null
notIn?: $Enums.PhoneType[] | Prisma.ListEnumPhoneTypeFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumPhoneTypeNullableWithAggregatesFilter<$PrismaModel> | $Enums.PhoneType | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumPhoneTypeNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumPhoneTypeNullableFilter<$PrismaModel>
}
export type FloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
export type FloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
}
export type EnumBillingMethodFilter<$PrismaModel = never> = {
equals?: $Enums.BillingMethod | Prisma.EnumBillingMethodFieldRefInput<$PrismaModel>
in?: $Enums.BillingMethod[] | Prisma.ListEnumBillingMethodFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingMethod[] | Prisma.ListEnumBillingMethodFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingMethodFilter<$PrismaModel> | $Enums.BillingMethod
}
export type EnumBillingTypeFilter<$PrismaModel = never> = {
equals?: $Enums.BillingType | Prisma.EnumBillingTypeFieldRefInput<$PrismaModel>
in?: $Enums.BillingType[] | Prisma.ListEnumBillingTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingType[] | Prisma.ListEnumBillingTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingTypeFilter<$PrismaModel> | $Enums.BillingType
}
export type EnumBillingMethodWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.BillingMethod | Prisma.EnumBillingMethodFieldRefInput<$PrismaModel>
in?: $Enums.BillingMethod[] | Prisma.ListEnumBillingMethodFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingMethod[] | Prisma.ListEnumBillingMethodFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingMethodWithAggregatesFilter<$PrismaModel> | $Enums.BillingMethod
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumBillingMethodFilter<$PrismaModel>
_max?: Prisma.NestedEnumBillingMethodFilter<$PrismaModel>
}
export type EnumBillingTypeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.BillingType | Prisma.EnumBillingTypeFieldRefInput<$PrismaModel>
in?: $Enums.BillingType[] | Prisma.ListEnumBillingTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingType[] | Prisma.ListEnumBillingTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingTypeWithAggregatesFilter<$PrismaModel> | $Enums.BillingType
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumBillingTypeFilter<$PrismaModel>
_max?: Prisma.NestedEnumBillingTypeFilter<$PrismaModel>
}
export type EnumOpportunityInterestNullableFilter<$PrismaModel = never> = {
equals?: $Enums.OpportunityInterest | Prisma.EnumOpportunityInterestFieldRefInput<$PrismaModel> | null
in?: $Enums.OpportunityInterest[] | Prisma.ListEnumOpportunityInterestFieldRefInput<$PrismaModel> | null
notIn?: $Enums.OpportunityInterest[] | Prisma.ListEnumOpportunityInterestFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumOpportunityInterestNullableFilter<$PrismaModel> | $Enums.OpportunityInterest | null
}
export type EnumOpportunityInterestNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.OpportunityInterest | Prisma.EnumOpportunityInterestFieldRefInput<$PrismaModel> | null
in?: $Enums.OpportunityInterest[] | Prisma.ListEnumOpportunityInterestFieldRefInput<$PrismaModel> | null
notIn?: $Enums.OpportunityInterest[] | Prisma.ListEnumOpportunityInterestFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumOpportunityInterestNullableWithAggregatesFilter<$PrismaModel> | $Enums.OpportunityInterest | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumOpportunityInterestNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumOpportunityInterestNullableFilter<$PrismaModel>
}
export type BytesFilter<$PrismaModel = never> = {
equals?: runtime.Bytes | Prisma.BytesFieldRefInput<$PrismaModel>
in?: runtime.Bytes[] | Prisma.ListBytesFieldRefInput<$PrismaModel>
@@ -311,20 +491,18 @@ export type NestedStringFilter<$PrismaModel = never> = {
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
export type NestedEnumSyncJobTypeFilter<$PrismaModel = never> = {
equals?: $Enums.SyncJobType | Prisma.EnumSyncJobTypeFieldRefInput<$PrismaModel>
in?: $Enums.SyncJobType[] | Prisma.ListEnumSyncJobTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.SyncJobType[] | Prisma.ListEnumSyncJobTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumSyncJobTypeFilter<$PrismaModel> | $Enums.SyncJobType
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
export type NestedEnumSyncJobStatusFilter<$PrismaModel = never> = {
equals?: $Enums.SyncJobStatus | Prisma.EnumSyncJobStatusFieldRefInput<$PrismaModel>
in?: $Enums.SyncJobStatus[] | Prisma.ListEnumSyncJobStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.SyncJobStatus[] | Prisma.ListEnumSyncJobStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumSyncJobStatusFilter<$PrismaModel> | $Enums.SyncJobStatus
}
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
@@ -338,6 +516,31 @@ export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
@@ -366,26 +569,24 @@ export type NestedIntFilter<$PrismaModel = never> = {
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
export type NestedEnumSyncJobTypeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.SyncJobType | Prisma.EnumSyncJobTypeFieldRefInput<$PrismaModel>
in?: $Enums.SyncJobType[] | Prisma.ListEnumSyncJobTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.SyncJobType[] | Prisma.ListEnumSyncJobTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumSyncJobTypeWithAggregatesFilter<$PrismaModel> | $Enums.SyncJobType
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
_min?: Prisma.NestedEnumSyncJobTypeFilter<$PrismaModel>
_max?: Prisma.NestedEnumSyncJobTypeFilter<$PrismaModel>
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
export type NestedEnumSyncJobStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.SyncJobStatus | Prisma.EnumSyncJobStatusFieldRefInput<$PrismaModel>
in?: $Enums.SyncJobStatus[] | Prisma.ListEnumSyncJobStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.SyncJobStatus[] | Prisma.ListEnumSyncJobStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumSyncJobStatusWithAggregatesFilter<$PrismaModel> | $Enums.SyncJobStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
_min?: Prisma.NestedEnumSyncJobStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumSyncJobStatusFilter<$PrismaModel>
}
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
@@ -413,20 +614,6 @@ export type NestedIntNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
@@ -444,6 +631,20 @@ export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
@@ -471,6 +672,43 @@ export type NestedFloatFilter<$PrismaModel = never> = {
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type NestedJsonFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
Required<NestedJsonFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
export type NestedJsonFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
@@ -498,6 +736,74 @@ export type NestedFloatNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
export type NestedEnumUSStateNullableFilter<$PrismaModel = never> = {
equals?: $Enums.USState | Prisma.EnumUSStateFieldRefInput<$PrismaModel> | null
in?: $Enums.USState[] | Prisma.ListEnumUSStateFieldRefInput<$PrismaModel> | null
notIn?: $Enums.USState[] | Prisma.ListEnumUSStateFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumUSStateNullableFilter<$PrismaModel> | $Enums.USState | null
}
export type NestedEnumCountryNullableFilter<$PrismaModel = never> = {
equals?: $Enums.Country | Prisma.EnumCountryFieldRefInput<$PrismaModel> | null
in?: $Enums.Country[] | Prisma.ListEnumCountryFieldRefInput<$PrismaModel> | null
notIn?: $Enums.Country[] | Prisma.ListEnumCountryFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumCountryNullableFilter<$PrismaModel> | $Enums.Country | null
}
export type NestedEnumUSStateNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.USState | Prisma.EnumUSStateFieldRefInput<$PrismaModel> | null
in?: $Enums.USState[] | Prisma.ListEnumUSStateFieldRefInput<$PrismaModel> | null
notIn?: $Enums.USState[] | Prisma.ListEnumUSStateFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumUSStateNullableWithAggregatesFilter<$PrismaModel> | $Enums.USState | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumUSStateNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumUSStateNullableFilter<$PrismaModel>
}
export type NestedEnumCountryNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.Country | Prisma.EnumCountryFieldRefInput<$PrismaModel> | null
in?: $Enums.Country[] | Prisma.ListEnumCountryFieldRefInput<$PrismaModel> | null
notIn?: $Enums.Country[] | Prisma.ListEnumCountryFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumCountryNullableWithAggregatesFilter<$PrismaModel> | $Enums.Country | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumCountryNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumCountryNullableFilter<$PrismaModel>
}
export type NestedEnumGenderTypeNullableFilter<$PrismaModel = never> = {
equals?: $Enums.GenderType | Prisma.EnumGenderTypeFieldRefInput<$PrismaModel> | null
in?: $Enums.GenderType[] | Prisma.ListEnumGenderTypeFieldRefInput<$PrismaModel> | null
notIn?: $Enums.GenderType[] | Prisma.ListEnumGenderTypeFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumGenderTypeNullableFilter<$PrismaModel> | $Enums.GenderType | null
}
export type NestedEnumPhoneTypeNullableFilter<$PrismaModel = never> = {
equals?: $Enums.PhoneType | Prisma.EnumPhoneTypeFieldRefInput<$PrismaModel> | null
in?: $Enums.PhoneType[] | Prisma.ListEnumPhoneTypeFieldRefInput<$PrismaModel> | null
notIn?: $Enums.PhoneType[] | Prisma.ListEnumPhoneTypeFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumPhoneTypeNullableFilter<$PrismaModel> | $Enums.PhoneType | null
}
export type NestedEnumGenderTypeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.GenderType | Prisma.EnumGenderTypeFieldRefInput<$PrismaModel> | null
in?: $Enums.GenderType[] | Prisma.ListEnumGenderTypeFieldRefInput<$PrismaModel> | null
notIn?: $Enums.GenderType[] | Prisma.ListEnumGenderTypeFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumGenderTypeNullableWithAggregatesFilter<$PrismaModel> | $Enums.GenderType | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumGenderTypeNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumGenderTypeNullableFilter<$PrismaModel>
}
export type NestedEnumPhoneTypeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.PhoneType | Prisma.EnumPhoneTypeFieldRefInput<$PrismaModel> | null
in?: $Enums.PhoneType[] | Prisma.ListEnumPhoneTypeFieldRefInput<$PrismaModel> | null
notIn?: $Enums.PhoneType[] | Prisma.ListEnumPhoneTypeFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumPhoneTypeNullableWithAggregatesFilter<$PrismaModel> | $Enums.PhoneType | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumPhoneTypeNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumPhoneTypeNullableFilter<$PrismaModel>
}
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
@@ -514,28 +820,71 @@ export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
export type NestedJsonFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
Required<NestedJsonFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
}
export type NestedJsonFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
export type NestedEnumBillingMethodFilter<$PrismaModel = never> = {
equals?: $Enums.BillingMethod | Prisma.EnumBillingMethodFieldRefInput<$PrismaModel>
in?: $Enums.BillingMethod[] | Prisma.ListEnumBillingMethodFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingMethod[] | Prisma.ListEnumBillingMethodFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingMethodFilter<$PrismaModel> | $Enums.BillingMethod
}
export type NestedEnumBillingTypeFilter<$PrismaModel = never> = {
equals?: $Enums.BillingType | Prisma.EnumBillingTypeFieldRefInput<$PrismaModel>
in?: $Enums.BillingType[] | Prisma.ListEnumBillingTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingType[] | Prisma.ListEnumBillingTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingTypeFilter<$PrismaModel> | $Enums.BillingType
}
export type NestedEnumBillingMethodWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.BillingMethod | Prisma.EnumBillingMethodFieldRefInput<$PrismaModel>
in?: $Enums.BillingMethod[] | Prisma.ListEnumBillingMethodFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingMethod[] | Prisma.ListEnumBillingMethodFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingMethodWithAggregatesFilter<$PrismaModel> | $Enums.BillingMethod
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumBillingMethodFilter<$PrismaModel>
_max?: Prisma.NestedEnumBillingMethodFilter<$PrismaModel>
}
export type NestedEnumBillingTypeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.BillingType | Prisma.EnumBillingTypeFieldRefInput<$PrismaModel>
in?: $Enums.BillingType[] | Prisma.ListEnumBillingTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.BillingType[] | Prisma.ListEnumBillingTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumBillingTypeWithAggregatesFilter<$PrismaModel> | $Enums.BillingType
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumBillingTypeFilter<$PrismaModel>
_max?: Prisma.NestedEnumBillingTypeFilter<$PrismaModel>
}
export type NestedEnumOpportunityInterestNullableFilter<$PrismaModel = never> = {
equals?: $Enums.OpportunityInterest | Prisma.EnumOpportunityInterestFieldRefInput<$PrismaModel> | null
in?: $Enums.OpportunityInterest[] | Prisma.ListEnumOpportunityInterestFieldRefInput<$PrismaModel> | null
notIn?: $Enums.OpportunityInterest[] | Prisma.ListEnumOpportunityInterestFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumOpportunityInterestNullableFilter<$PrismaModel> | $Enums.OpportunityInterest | null
}
export type NestedEnumOpportunityInterestNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.OpportunityInterest | Prisma.EnumOpportunityInterestFieldRefInput<$PrismaModel> | null
in?: $Enums.OpportunityInterest[] | Prisma.ListEnumOpportunityInterestFieldRefInput<$PrismaModel> | null
notIn?: $Enums.OpportunityInterest[] | Prisma.ListEnumOpportunityInterestFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumOpportunityInterestNullableWithAggregatesFilter<$PrismaModel> | $Enums.OpportunityInterest | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumOpportunityInterestNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumOpportunityInterestNullableFilter<$PrismaModel>
}
export type NestedBytesFilter<$PrismaModel = never> = {
+133 -2
View File
@@ -9,7 +9,138 @@
* 🟢 You can import this file directly.
*/
export const PhoneType = {
DIRECT: 'DIRECT',
MOBILE: 'MOBILE',
HOME: 'HOME',
COMPANY: 'COMPANY',
SITE: 'SITE'
} as const
export type PhoneType = (typeof PhoneType)[keyof typeof PhoneType]
// This file is empty because there are no enums in the schema.
export {}
export const FaxType = {
FAX: 'FAX',
COMPANY: 'COMPANY',
SITE: 'SITE'
} as const
export type FaxType = (typeof FaxType)[keyof typeof FaxType]
export const BillingMethod = {
ACTUAL_RATES: 'ACTUAL_RATES',
FIXED_FEE: 'FIXED_FEE',
NOT_TO_EXCEED: 'NOT_TO_EXCEED',
OVERRIDE_RATE: 'OVERRIDE_RATE'
} as const
export type BillingMethod = (typeof BillingMethod)[keyof typeof BillingMethod]
export const BillingType = {
STANDARD: 'STANDARD',
PROJECT: 'PROJECT'
} as const
export type BillingType = (typeof BillingType)[keyof typeof BillingType]
export const GenderType = {
MALE: 'MALE',
FEMALE: 'FEMALE'
} as const
export type GenderType = (typeof GenderType)[keyof typeof GenderType]
export const USState = {
AL: 'AL',
AK: 'AK',
AZ: 'AZ',
AR: 'AR',
CA: 'CA',
CO: 'CO',
CT: 'CT',
DE: 'DE',
FL: 'FL',
GA: 'GA',
HI: 'HI',
ID: 'ID',
IL: 'IL',
IN: 'IN',
IA: 'IA',
KS: 'KS',
KY: 'KY',
LA: 'LA',
ME: 'ME',
MD: 'MD',
MA: 'MA',
MI: 'MI',
MN: 'MN',
MS: 'MS',
MO: 'MO',
MT: 'MT',
NE: 'NE',
NV: 'NV',
NH: 'NH',
NJ: 'NJ',
NM: 'NM',
NY: 'NY',
NC: 'NC',
ND: 'ND',
OH: 'OH',
OK: 'OK',
OR: 'OR',
PA: 'PA',
RI: 'RI',
SC: 'SC',
SD: 'SD',
TN: 'TN',
TX: 'TX',
UT: 'UT',
VT: 'VT',
VA: 'VA',
WA: 'WA',
WV: 'WV',
WI: 'WI',
WY: 'WY'
} as const
export type USState = (typeof USState)[keyof typeof USState]
export const Country = {
US: 'US'
} as const
export type Country = (typeof Country)[keyof typeof Country]
export const OpportunityInterest = {
HOT: 'HOT',
WARM: 'WARM',
COLD: 'COLD'
} as const
export type OpportunityInterest = (typeof OpportunityInterest)[keyof typeof OpportunityInterest]
export const SyncJobType = {
FULL_SYNC: 'FULL_SYNC',
INCREMENTAL_SYNC: 'INCREMENTAL_SYNC'
} as const
export type SyncJobType = (typeof SyncJobType)[keyof typeof SyncJobType]
export const SyncJobStatus = {
QUEUED: 'QUEUED',
RUNNING: 'RUNNING',
COMPLETED: 'COMPLETED',
FAILED: 'FAILED',
TIMED_OUT: 'TIMED_OUT'
} as const
export type SyncJobStatus = (typeof SyncJobStatus)[keyof typeof SyncJobStatus]
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -51,17 +51,50 @@ export const AnyNull = runtime.AnyNull
export const ModelName = {
SyncJobRun: 'SyncJobRun',
SyncStepLog: 'SyncStepLog',
Session: 'Session',
User: 'User',
Role: 'Role',
CorporateLocation: 'CorporateLocation',
InternalDepartment: 'InternalDepartment',
UnifiSite: 'UnifiSite',
Company: 'Company',
CompanyAddress: 'CompanyAddress',
Contact: 'Contact',
CatalogItemType: 'CatalogItemType',
CatalogCategory: 'CatalogCategory',
CatalogSubcategory: 'CatalogSubcategory',
CatalogManufacturer: 'CatalogManufacturer',
WarehouseBin: 'WarehouseBin',
ProductInventory: 'ProductInventory',
Warehouse: 'Warehouse',
MinimumStockByWarehouse: 'MinimumStockByWarehouse',
CatalogItem: 'CatalogItem',
ProductData: 'ProductData',
ServiceTicket: 'ServiceTicket',
ServiceTicketNote: 'ServiceTicketNote',
ServiceTicketType: 'ServiceTicketType',
ServiceTicketBoard: 'ServiceTicketBoard',
ServiceTicketLocation: 'ServiceTicketLocation',
ServiceTicketSource: 'ServiceTicketSource',
ServiceTicketImpact: 'ServiceTicketImpact',
ServiceTicketPriority: 'ServiceTicketPriority',
ServiceTicketSeverity: 'ServiceTicketSeverity',
ServiceTicketFinalData: 'ServiceTicketFinalData',
OpportunityStage: 'OpportunityStage',
OpportunityType: 'OpportunityType',
OpportunityStatus: 'OpportunityStatus',
Opportunity: 'Opportunity',
ScheduleStatus: 'ScheduleStatus',
ScheduleType: 'ScheduleType',
ScheduleSpan: 'ScheduleSpan',
Schedule: 'Schedule',
CredentialType: 'CredentialType',
SecureValue: 'SecureValue',
Credential: 'Credential',
GeneratedQuotes: 'GeneratedQuotes',
TaxCode: 'TaxCode',
CwMember: 'CwMember'
} as const
@@ -81,6 +114,39 @@ export const TransactionIsolationLevel = runtime.makeStrictEnum({
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export const SyncJobRunScalarFieldEnum = {
id: 'id',
jobType: 'jobType',
status: 'status',
triggeredBy: 'triggeredBy',
startedAt: 'startedAt',
completedAt: 'completedAt',
errorSummary: 'errorSummary',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SyncJobRunScalarFieldEnum = (typeof SyncJobRunScalarFieldEnum)[keyof typeof SyncJobRunScalarFieldEnum]
export const SyncStepLogScalarFieldEnum = {
id: 'id',
syncJobRunId: 'syncJobRunId',
tableName: 'tableName',
syncMode: 'syncMode',
recordsProcessed: 'recordsProcessed',
recordsInserted: 'recordsInserted',
recordsSkipped: 'recordsSkipped',
recordsFailed: 'recordsFailed',
recordsDeleted: 'recordsDeleted',
sampleErrors: 'sampleErrors',
durationMs: 'durationMs',
createdAt: 'createdAt'
} as const
export type SyncStepLogScalarFieldEnum = (typeof SyncStepLogScalarFieldEnum)[keyof typeof SyncStepLogScalarFieldEnum]
export const SessionScalarFieldEnum = {
id: 'id',
sessionKey: 'sessionKey',
@@ -98,13 +164,18 @@ export const UserScalarFieldEnum = {
id: 'id',
permissions: 'permissions',
login: 'login',
name: 'name',
firstName: 'firstName',
lastName: 'lastName',
email: 'email',
emailVerified: 'emailVerified',
image: 'image',
title: 'title',
active: 'active',
hidden: 'hidden',
cwIdentifier: 'cwIdentifier',
cwMemberId: 'cwMemberId',
userId: 'userId',
token: 'token',
updatedBy: 'updatedBy',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -124,6 +195,40 @@ export const RoleScalarFieldEnum = {
export type RoleScalarFieldEnum = (typeof RoleScalarFieldEnum)[keyof typeof RoleScalarFieldEnum]
export const CorporateLocationScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
updatedById: 'updatedById',
addressLine1: 'addressLine1',
addressLine2: 'addressLine2',
city: 'city',
state: 'state',
zipCode: 'zipCode',
country: 'country',
inactiveFlag: 'inactiveFlag',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CorporateLocationScalarFieldEnum = (typeof CorporateLocationScalarFieldEnum)[keyof typeof CorporateLocationScalarFieldEnum]
export const InternalDepartmentScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
createdById: 'createdById',
updatedById: 'updatedById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type InternalDepartmentScalarFieldEnum = (typeof InternalDepartmentScalarFieldEnum)[keyof typeof InternalDepartmentScalarFieldEnum]
export const UnifiSiteScalarFieldEnum = {
id: 'id',
name: 'name',
@@ -138,9 +243,17 @@ export type UnifiSiteScalarFieldEnum = (typeof UnifiSiteScalarFieldEnum)[keyof t
export const CompanyScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
cw_CompanyId: 'cw_CompanyId',
cw_Identifier: 'cw_Identifier',
phone: 'phone',
website: 'website',
deleteFlag: 'deleteFlag',
dateDeleted: 'dateDeleted',
taxId: 'taxId',
taxExempt: 'taxExempt',
enteredById: 'enteredById',
deletedById: 'deletedById',
deletedAt: 'deletedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -148,20 +261,195 @@ export const CompanyScalarFieldEnum = {
export type CompanyScalarFieldEnum = (typeof CompanyScalarFieldEnum)[keyof typeof CompanyScalarFieldEnum]
export const CompanyAddressScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
addressLine1: 'addressLine1',
addressLine2: 'addressLine2',
city: 'city',
state: 'state',
zipCode: 'zipCode',
country: 'country',
phone: 'phone',
fax: 'fax',
inactiveFlag: 'inactiveFlag',
defaultFlag: 'defaultFlag',
defaultMailFlag: 'defaultMailFlag',
defaultBillFlag: 'defaultBillFlag',
defaultShipFlag: 'defaultShipFlag',
updatedById: 'updatedById',
companyId: 'companyId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CompanyAddressScalarFieldEnum = (typeof CompanyAddressScalarFieldEnum)[keyof typeof CompanyAddressScalarFieldEnum]
export const ContactScalarFieldEnum = {
id: 'id',
uid: 'uid',
active: 'active',
default: 'default',
firstName: 'firstName',
lastName: 'lastName',
nickname: 'nickname',
title: 'title',
gender: 'gender',
birthday: 'birthday',
email: 'email',
phone: 'phone',
phoneExtension: 'phoneExtension',
phoneType: 'phoneType',
companyAddressId: 'companyAddressId',
memberId: 'memberId',
companyId: 'companyId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ContactScalarFieldEnum = (typeof ContactScalarFieldEnum)[keyof typeof ContactScalarFieldEnum]
export const CatalogItemTypeScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
inactiveFlag: 'inactiveFlag',
defaultFlag: 'defaultFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CatalogItemTypeScalarFieldEnum = (typeof CatalogItemTypeScalarFieldEnum)[keyof typeof CatalogItemTypeScalarFieldEnum]
export const CatalogCategoryScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
inactiveFlag: 'inactiveFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CatalogCategoryScalarFieldEnum = (typeof CatalogCategoryScalarFieldEnum)[keyof typeof CatalogCategoryScalarFieldEnum]
export const CatalogSubcategoryScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
categoryId: 'categoryId',
inactiveFlag: 'inactiveFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CatalogSubcategoryScalarFieldEnum = (typeof CatalogSubcategoryScalarFieldEnum)[keyof typeof CatalogSubcategoryScalarFieldEnum]
export const CatalogManufacturerScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
inactiveFlag: 'inactiveFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CatalogManufacturerScalarFieldEnum = (typeof CatalogManufacturerScalarFieldEnum)[keyof typeof CatalogManufacturerScalarFieldEnum]
export const WarehouseBinScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
minQuantity: 'minQuantity',
maxQuantity: 'maxQuantity',
inactiveFlag: 'inactiveFlag',
defaultFlag: 'defaultFlag',
warehouseId: 'warehouseId',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type WarehouseBinScalarFieldEnum = (typeof WarehouseBinScalarFieldEnum)[keyof typeof WarehouseBinScalarFieldEnum]
export const ProductInventoryScalarFieldEnum = {
id: 'id',
uid: 'uid',
qtyOnHand: 'qtyOnHand',
warehouseBinId: 'warehouseBinId',
itemId: 'itemId',
warehouseId: 'warehouseId',
updatedById: 'updatedById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ProductInventoryScalarFieldEnum = (typeof ProductInventoryScalarFieldEnum)[keyof typeof ProductInventoryScalarFieldEnum]
export const WarehouseScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
inactiveFlag: 'inactiveFlag',
lockedFlag: 'lockedFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type WarehouseScalarFieldEnum = (typeof WarehouseScalarFieldEnum)[keyof typeof WarehouseScalarFieldEnum]
export const MinimumStockByWarehouseScalarFieldEnum = {
id: 'id',
uid: 'uid',
minQty: 'minQty',
warehouseId: 'warehouseId',
itemId: 'itemId',
updatedById: 'updatedById',
enteredById: 'enteredById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type MinimumStockByWarehouseScalarFieldEnum = (typeof MinimumStockByWarehouseScalarFieldEnum)[keyof typeof MinimumStockByWarehouseScalarFieldEnum]
export const CatalogItemScalarFieldEnum = {
id: 'id',
cwCatalogId: 'cwCatalogId',
uid: 'uid',
identifier: 'identifier',
name: 'name',
description: 'description',
customerDescription: 'customerDescription',
internalNotes: 'internalNotes',
category: 'category',
categoryCwId: 'categoryCwId',
subcategory: 'subcategory',
subcategoryCwId: 'subcategoryCwId',
manufacturer: 'manufacturer',
manufactureCwId: 'manufactureCwId',
subcategoryId: 'subcategoryId',
manufacturerId: 'manufacturerId',
partNumber: 'partNumber',
vendorName: 'vendorName',
vendorSku: 'vendorSku',
@@ -172,6 +460,7 @@ export const CatalogItemScalarFieldEnum = {
salesTaxable: 'salesTaxable',
onHand: 'onHand',
cwLastUpdated: 'cwLastUpdated',
classId: 'classId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -179,54 +468,338 @@ export const CatalogItemScalarFieldEnum = {
export type CatalogItemScalarFieldEnum = (typeof CatalogItemScalarFieldEnum)[keyof typeof CatalogItemScalarFieldEnum]
export const ProductDataScalarFieldEnum = {
id: 'id',
uid: 'uid',
qty: 'qty',
internalNote: 'internalNote',
shortDescription: 'shortDescription',
description: 'description',
sequenceNumber: 'sequenceNumber',
procurementNotes: 'procurementNotes',
productNarrative: 'productNarrative',
unitPrice: 'unitPrice',
unitCost: 'unitCost',
listPrice: 'listPrice',
discount: 'discount',
recurringRevenue: 'recurringRevenue',
recurringCost: 'recurringCost',
qtyPicked: 'qtyPicked',
qtyShipped: 'qtyShipped',
cancelReason: 'cancelReason',
cancelQty: 'cancelQty',
billableFlag: 'billableFlag',
taxableFlag: 'taxableFlag',
invoiceFlag: 'invoiceFlag',
recurringFlag: 'recurringFlag',
poApprovedFlag: 'poApprovedFlag',
calcPriceFlag: 'calcPriceFlag',
calcCostFlag: 'calcCostFlag',
cancelFlag: 'cancelFlag',
catalogItemId: 'catalogItemId',
corporateLocationId: 'corporateLocationId',
serviceTicketId: 'serviceTicketId',
opportunityId: 'opportunityId',
updatedById: 'updatedById',
createdById: 'createdById',
closedById: 'closedById',
cancelById: 'cancelById',
closedAt: 'closedAt',
cancelledAt: 'cancelledAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ProductDataScalarFieldEnum = (typeof ProductDataScalarFieldEnum)[keyof typeof ProductDataScalarFieldEnum]
export const ServiceTicketScalarFieldEnum = {
id: 'id',
uid: 'uid',
summary: 'summary',
addressLine1: 'addressLine1',
addressLine2: 'addressLine2',
city: 'city',
state: 'state',
zipCode: 'zipCode',
country: 'country',
contactName: 'contactName',
phone: 'phone',
phoneExtension: 'phoneExtension',
phoneType: 'phoneType',
email: 'email',
poNumber: 'poNumber',
billCompleteFlag: 'billCompleteFlag',
billUnapprovedFlag: 'billUnapprovedFlag',
billingAmount: 'billingAmount',
billingMethod: 'billingMethod',
timeBillableFlag: 'timeBillableFlag',
expenseBillableFlag: 'expenseBillableFlag',
productBillableFlag: 'productBillableFlag',
timeInvoiceableFlag: 'timeInvoiceableFlag',
expenseInvoiceableFlag: 'expenseInvoiceableFlag',
productInvoiceableFlag: 'productInvoiceableFlag',
dateRequested: 'dateRequested',
billingType: 'billingType',
billingInstructions: 'billingInstructions',
rejectedFlag: 'rejectedFlag',
closedFlag: 'closedFlag',
redFlag: 'redFlag',
publishFlag: 'publishFlag',
ticketOwnerId: 'ticketOwnerId',
serviceTicketBoardId: 'serviceTicketBoardId',
severityId: 'severityId',
impactId: 'impactId',
priorityId: 'priorityId',
sourceId: 'sourceId',
locationId: 'locationId',
parentId: 'parentId',
companyId: 'companyId',
contactId: 'contactId',
companyAddressId: 'companyAddressId',
billingCompanyId: 'billingCompanyId',
billingAddressId: 'billingAddressId',
createdById: 'createdById',
updatedById: 'updatedById',
closedById: 'closedById',
rejectedAt: 'rejectedAt',
closedAt: 'closedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServiceTicketScalarFieldEnum = (typeof ServiceTicketScalarFieldEnum)[keyof typeof ServiceTicketScalarFieldEnum]
export const ServiceTicketNoteScalarFieldEnum = {
id: 'id',
uid: 'uid',
notes: 'notes',
notesMd: 'notesMd',
authorId: 'authorId',
problemFlag: 'problemFlag',
resolutionFlag: 'resolutionFlag',
internalAnalysisFlag: 'internalAnalysisFlag',
internalMemberFlag: 'internalMemberFlag',
createdByParentFlag: 'createdByParentFlag',
mergedFlag: 'mergedFlag',
bundledFlag: 'bundledFlag',
serviceTicketId: 'serviceTicketId',
createdById: 'createdById',
updatedById: 'updatedById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServiceTicketNoteScalarFieldEnum = (typeof ServiceTicketNoteScalarFieldEnum)[keyof typeof ServiceTicketNoteScalarFieldEnum]
export const ServiceTicketTypeScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
inactiveFlag: 'inactiveFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServiceTicketTypeScalarFieldEnum = (typeof ServiceTicketTypeScalarFieldEnum)[keyof typeof ServiceTicketTypeScalarFieldEnum]
export const ServiceTicketBoardScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
timeBillableFlag: 'timeBillableFlag',
expenseBillableFlag: 'expenseBillableFlag',
productBillableFlag: 'productBillableFlag',
timeInvoiceableFlag: 'timeInvoiceableFlag',
expenseInvoiceableFlag: 'expenseInvoiceableFlag',
productInvoiceableFlag: 'productInvoiceableFlag',
autoAssignNewFlag: 'autoAssignNewFlag',
autoAssignEmailCreatedFlag: 'autoAssignEmailCreatedFlag',
autoAssignPortalCreatedFlag: 'autoAssignPortalCreatedFlag',
projectFlag: 'projectFlag',
lockDescriptionFlag: 'lockDescriptionFlag',
emailContactFlag: 'emailContactFlag',
emailResourceFlag: 'emailResourceFlag',
resolutionSortOrder: 'resolutionSortOrder',
internalAnalysisSortOrder: 'internalAnalysisSortOrder',
locationId: 'locationId',
createdById: 'createdById',
updatedById: 'updatedById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServiceTicketBoardScalarFieldEnum = (typeof ServiceTicketBoardScalarFieldEnum)[keyof typeof ServiceTicketBoardScalarFieldEnum]
export const ServiceTicketLocationScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
defaultFlag: 'defaultFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServiceTicketLocationScalarFieldEnum = (typeof ServiceTicketLocationScalarFieldEnum)[keyof typeof ServiceTicketLocationScalarFieldEnum]
export const ServiceTicketSourceScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
inactiveFlag: 'inactiveFlag',
defaultFlag: 'defaultFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServiceTicketSourceScalarFieldEnum = (typeof ServiceTicketSourceScalarFieldEnum)[keyof typeof ServiceTicketSourceScalarFieldEnum]
export const ServiceTicketImpactScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
defaultFlag: 'defaultFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServiceTicketImpactScalarFieldEnum = (typeof ServiceTicketImpactScalarFieldEnum)[keyof typeof ServiceTicketImpactScalarFieldEnum]
export const ServiceTicketPriorityScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
color: 'color',
description: 'description',
defaultFlag: 'defaultFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServiceTicketPriorityScalarFieldEnum = (typeof ServiceTicketPriorityScalarFieldEnum)[keyof typeof ServiceTicketPriorityScalarFieldEnum]
export const ServiceTicketSeverityScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
defaultFlag: 'defaultFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServiceTicketSeverityScalarFieldEnum = (typeof ServiceTicketSeverityScalarFieldEnum)[keyof typeof ServiceTicketSeverityScalarFieldEnum]
export const ServiceTicketFinalDataScalarFieldEnum = {
id: 'id'
} as const
export type ServiceTicketFinalDataScalarFieldEnum = (typeof ServiceTicketFinalDataScalarFieldEnum)[keyof typeof ServiceTicketFinalDataScalarFieldEnum]
export const OpportunityStageScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
seqNbr: 'seqNbr',
funnelColor: 'funnelColor',
updatedById: 'updatedById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type OpportunityStageScalarFieldEnum = (typeof OpportunityStageScalarFieldEnum)[keyof typeof OpportunityStageScalarFieldEnum]
export const OpportunityTypeScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
inactiveFlag: 'inactiveFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type OpportunityTypeScalarFieldEnum = (typeof OpportunityTypeScalarFieldEnum)[keyof typeof OpportunityTypeScalarFieldEnum]
export const OpportunityStatusScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
inactiveFlag: 'inactiveFlag',
defaultFlag: 'defaultFlag',
wonFlag: 'wonFlag',
lostFlag: 'lostFlag',
closeFlag: 'closeFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type OpportunityStatusScalarFieldEnum = (typeof OpportunityStatusScalarFieldEnum)[keyof typeof OpportunityStatusScalarFieldEnum]
export const OpportunityScalarFieldEnum = {
id: 'id',
cwOpportunityId: 'cwOpportunityId',
uid: 'uid',
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',
oppNarrative: 'oppNarrative',
typeId: 'typeId',
stageId: 'stageId',
statusId: 'statusId',
taxCodeId: 'taxCodeId',
interest: 'interest',
probability: 'probability',
locationName: 'locationName',
locationCwId: 'locationCwId',
departmentName: 'departmentName',
departmentCwId: 'departmentCwId',
source: 'source',
primarySalesRepId: 'primarySalesRepId',
secondarySalesRepId: 'secondarySalesRepId',
companyId: 'companyId',
contactId: 'contactId',
siteId: 'siteId',
customerPO: 'customerPO',
locationId: 'locationId',
departmentId: 'departmentId',
expectedCloseDate: 'expectedCloseDate',
pipelineChangeDate: 'pipelineChangeDate',
dateBecameLead: 'dateBecameLead',
closedDate: 'closedDate',
closedFlag: 'closedFlag',
closedByName: 'closedByName',
closedByCwId: 'closedByCwId',
companyId: 'companyId',
closedById: 'closedById',
productSequence: 'productSequence',
cwLastUpdated: 'cwLastUpdated',
cwDateEntered: 'cwDateEntered',
updatedBy: 'updatedBy',
eneteredBy: 'eneteredBy',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -234,6 +807,87 @@ export const OpportunityScalarFieldEnum = {
export type OpportunityScalarFieldEnum = (typeof OpportunityScalarFieldEnum)[keyof typeof OpportunityScalarFieldEnum]
export const ScheduleStatusScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
color: 'color',
softFlag: 'softFlag',
defaultFlag: 'defaultFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ScheduleStatusScalarFieldEnum = (typeof ScheduleStatusScalarFieldEnum)[keyof typeof ScheduleStatusScalarFieldEnum]
export const ScheduleTypeScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
displayColor: 'displayColor',
tableReference: 'tableReference',
moduleId: 'moduleId',
scheduleTypeId: 'scheduleTypeId',
systemFlag: 'systemFlag',
displayFlag: 'displayFlag',
updatedById: 'updatedById',
createdById: 'createdById',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ScheduleTypeScalarFieldEnum = (typeof ScheduleTypeScalarFieldEnum)[keyof typeof ScheduleTypeScalarFieldEnum]
export const ScheduleSpanScalarFieldEnum = {
id: 'id',
scheduleSpanId: 'scheduleSpanId',
spanDesc: 'spanDesc'
} as const
export type ScheduleSpanScalarFieldEnum = (typeof ScheduleSpanScalarFieldEnum)[keyof typeof ScheduleSpanScalarFieldEnum]
export const ScheduleScalarFieldEnum = {
id: 'id',
uid: 'uid',
name: 'name',
description: 'description',
memberId: 'memberId',
closedFlag: 'closedFlag',
reminderFlag: 'reminderFlag',
allDayFlag: 'allDayFlag',
acknowledgementFlag: 'acknowledgementFlag',
meetingFlag: 'meetingFlag',
recurringFlag: 'recurringFlag',
billableFlag: 'billableFlag',
acknowledgedById: 'acknowledgedById',
acknowledgedAt: 'acknowledgedAt',
startDate: 'startDate',
endDate: 'endDate',
hoursScheduled: 'hoursScheduled',
duration: 'duration',
hoursPerDay: 'hoursPerDay',
reminderMinutes: 'reminderMinutes',
statusId: 'statusId',
typeId: 'typeId',
scheduleSpanId: 'scheduleSpanId',
updatedById: 'updatedById',
createdById: 'createdById',
closedById: 'closedById',
closedAt: 'closedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ScheduleScalarFieldEnum = (typeof ScheduleScalarFieldEnum)[keyof typeof ScheduleScalarFieldEnum]
export const CredentialTypeScalarFieldEnum = {
id: 'id',
name: 'name',
@@ -292,6 +946,23 @@ export const GeneratedQuotesScalarFieldEnum = {
export type GeneratedQuotesScalarFieldEnum = (typeof GeneratedQuotesScalarFieldEnum)[keyof typeof GeneratedQuotesScalarFieldEnum]
export const TaxCodeScalarFieldEnum = {
id: 'id',
uid: 'uid',
code: 'code',
codeCaption: 'codeCaption',
description: 'description',
rate: 'rate',
defaultFlag: 'defaultFlag',
createdBy: 'createdBy',
updatedBy: 'updatedBy',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type TaxCodeScalarFieldEnum = (typeof TaxCodeScalarFieldEnum)[keyof typeof TaxCodeScalarFieldEnum]
export const CwMemberScalarFieldEnum = {
id: 'id',
cwMemberId: 'cwMemberId',
+33
View File
@@ -8,16 +8,49 @@
*
* 🟢 You can import this file directly.
*/
export type * from './models/SyncJobRun.ts'
export type * from './models/SyncStepLog.ts'
export type * from './models/Session.ts'
export type * from './models/User.ts'
export type * from './models/Role.ts'
export type * from './models/CorporateLocation.ts'
export type * from './models/InternalDepartment.ts'
export type * from './models/UnifiSite.ts'
export type * from './models/Company.ts'
export type * from './models/CompanyAddress.ts'
export type * from './models/Contact.ts'
export type * from './models/CatalogItemType.ts'
export type * from './models/CatalogCategory.ts'
export type * from './models/CatalogSubcategory.ts'
export type * from './models/CatalogManufacturer.ts'
export type * from './models/WarehouseBin.ts'
export type * from './models/ProductInventory.ts'
export type * from './models/Warehouse.ts'
export type * from './models/MinimumStockByWarehouse.ts'
export type * from './models/CatalogItem.ts'
export type * from './models/ProductData.ts'
export type * from './models/ServiceTicket.ts'
export type * from './models/ServiceTicketNote.ts'
export type * from './models/ServiceTicketType.ts'
export type * from './models/ServiceTicketBoard.ts'
export type * from './models/ServiceTicketLocation.ts'
export type * from './models/ServiceTicketSource.ts'
export type * from './models/ServiceTicketImpact.ts'
export type * from './models/ServiceTicketPriority.ts'
export type * from './models/ServiceTicketSeverity.ts'
export type * from './models/ServiceTicketFinalData.ts'
export type * from './models/OpportunityStage.ts'
export type * from './models/OpportunityType.ts'
export type * from './models/OpportunityStatus.ts'
export type * from './models/Opportunity.ts'
export type * from './models/ScheduleStatus.ts'
export type * from './models/ScheduleType.ts'
export type * from './models/ScheduleSpan.ts'
export type * from './models/Schedule.ts'
export type * from './models/CredentialType.ts'
export type * from './models/SecureValue.ts'
export type * from './models/Credential.ts'
export type * from './models/GeneratedQuotes.ts'
export type * from './models/TaxCode.ts'
export type * from './models/CwMember.ts'
export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1806,6 +1806,11 @@ export type CredentialFindManyArgs<ExtArgs extends runtime.Types.Extensions.Inte
* Skip the first `n` Credentials.
*/
skip?: number
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/distinct Distinct Docs}
*
* Filter by unique combinations of Credentials.
*/
distinct?: Prisma.CredentialScalarFieldEnum | Prisma.CredentialScalarFieldEnum[]
}
@@ -1146,6 +1146,11 @@ export type CredentialTypeFindManyArgs<ExtArgs extends runtime.Types.Extensions.
* Skip the first `n` CredentialTypes.
*/
skip?: number
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/distinct Distinct Docs}
*
* Filter by unique combinations of CredentialTypes.
*/
distinct?: Prisma.CredentialTypeScalarFieldEnum | Prisma.CredentialTypeScalarFieldEnum[]
}
+174 -1
View File
@@ -264,6 +264,7 @@ export type CwMemberWhereInput = {
cwLastUpdated?: Prisma.DateTimeNullableFilter<"CwMember"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"CwMember"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CwMember"> | Date | string
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
}
export type CwMemberOrderByWithRelationInput = {
@@ -278,6 +279,7 @@ export type CwMemberOrderByWithRelationInput = {
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
user?: Prisma.UserOrderByWithRelationInput
}
export type CwMemberWhereUniqueInput = Prisma.AtLeast<{
@@ -295,6 +297,7 @@ export type CwMemberWhereUniqueInput = Prisma.AtLeast<{
cwLastUpdated?: Prisma.DateTimeNullableFilter<"CwMember"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"CwMember"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CwMember"> | Date | string
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
}, "id" | "cwMemberId" | "identifier">
export type CwMemberOrderByWithAggregationInput = {
@@ -345,6 +348,7 @@ export type CwMemberCreateInput = {
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
user?: Prisma.UserCreateNestedOneWithoutCwMemberInput
}
export type CwMemberUncheckedCreateInput = {
@@ -359,6 +363,7 @@ export type CwMemberUncheckedCreateInput = {
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
user?: Prisma.UserUncheckedCreateNestedOneWithoutCwMemberInput
}
export type CwMemberUpdateInput = {
@@ -373,6 +378,7 @@ export type CwMemberUpdateInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
user?: Prisma.UserUpdateOneWithoutCwMemberNestedInput
}
export type CwMemberUncheckedUpdateInput = {
@@ -387,6 +393,7 @@ export type CwMemberUncheckedUpdateInput = {
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
user?: Prisma.UserUncheckedUpdateOneWithoutCwMemberNestedInput
}
export type CwMemberCreateManyInput = {
@@ -431,6 +438,11 @@ export type CwMemberUncheckedUpdateManyInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
export type CwMemberNullableScalarRelationFilter = {
is?: Prisma.CwMemberWhereInput | null
isNot?: Prisma.CwMemberWhereInput | null
}
export type CwMemberCountOrderByAggregateInput = {
id?: Prisma.SortOrder
cwMemberId?: Prisma.SortOrder
@@ -481,6 +493,94 @@ export type CwMemberSumOrderByAggregateInput = {
cwMemberId?: Prisma.SortOrder
}
export type CwMemberCreateNestedOneWithoutUserInput = {
create?: Prisma.XOR<Prisma.CwMemberCreateWithoutUserInput, Prisma.CwMemberUncheckedCreateWithoutUserInput>
connectOrCreate?: Prisma.CwMemberCreateOrConnectWithoutUserInput
connect?: Prisma.CwMemberWhereUniqueInput
}
export type CwMemberUpdateOneWithoutUserNestedInput = {
create?: Prisma.XOR<Prisma.CwMemberCreateWithoutUserInput, Prisma.CwMemberUncheckedCreateWithoutUserInput>
connectOrCreate?: Prisma.CwMemberCreateOrConnectWithoutUserInput
upsert?: Prisma.CwMemberUpsertWithoutUserInput
disconnect?: Prisma.CwMemberWhereInput | boolean
delete?: Prisma.CwMemberWhereInput | boolean
connect?: Prisma.CwMemberWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.CwMemberUpdateToOneWithWhereWithoutUserInput, Prisma.CwMemberUpdateWithoutUserInput>, Prisma.CwMemberUncheckedUpdateWithoutUserInput>
}
export type CwMemberCreateWithoutUserInput = {
id?: string
cwMemberId: number
identifier: string
firstName: string
lastName: string
officeEmail?: string | null
inactiveFlag?: boolean
apiKey?: string | null
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
}
export type CwMemberUncheckedCreateWithoutUserInput = {
id?: string
cwMemberId: number
identifier: string
firstName: string
lastName: string
officeEmail?: string | null
inactiveFlag?: boolean
apiKey?: string | null
cwLastUpdated?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
}
export type CwMemberCreateOrConnectWithoutUserInput = {
where: Prisma.CwMemberWhereUniqueInput
create: Prisma.XOR<Prisma.CwMemberCreateWithoutUserInput, Prisma.CwMemberUncheckedCreateWithoutUserInput>
}
export type CwMemberUpsertWithoutUserInput = {
update: Prisma.XOR<Prisma.CwMemberUpdateWithoutUserInput, Prisma.CwMemberUncheckedUpdateWithoutUserInput>
create: Prisma.XOR<Prisma.CwMemberCreateWithoutUserInput, Prisma.CwMemberUncheckedCreateWithoutUserInput>
where?: Prisma.CwMemberWhereInput
}
export type CwMemberUpdateToOneWithWhereWithoutUserInput = {
where?: Prisma.CwMemberWhereInput
data: Prisma.XOR<Prisma.CwMemberUpdateWithoutUserInput, Prisma.CwMemberUncheckedUpdateWithoutUserInput>
}
export type CwMemberUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
cwMemberId?: Prisma.IntFieldUpdateOperationsInput | number
identifier?: Prisma.StringFieldUpdateOperationsInput | string
firstName?: Prisma.StringFieldUpdateOperationsInput | string
lastName?: Prisma.StringFieldUpdateOperationsInput | string
officeEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
inactiveFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
apiKey?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
export type CwMemberUncheckedUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
cwMemberId?: Prisma.IntFieldUpdateOperationsInput | number
identifier?: Prisma.StringFieldUpdateOperationsInput | string
firstName?: Prisma.StringFieldUpdateOperationsInput | string
lastName?: Prisma.StringFieldUpdateOperationsInput | string
officeEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
inactiveFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean
apiKey?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
export type CwMemberSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@@ -495,6 +595,7 @@ export type CwMemberSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
cwLastUpdated?: boolean
createdAt?: boolean
updatedAt?: boolean
user?: boolean | Prisma.CwMember$userArgs<ExtArgs>
}, ExtArgs["result"]["cwMember"]>
export type CwMemberSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@@ -540,10 +641,17 @@ export type CwMemberSelectScalar = {
}
export type CwMemberOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwMemberId" | "identifier" | "firstName" | "lastName" | "officeEmail" | "inactiveFlag" | "apiKey" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["cwMember"]>
export type CwMemberInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.CwMember$userArgs<ExtArgs>
}
export type CwMemberIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
export type CwMemberIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
export type $CwMemberPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "CwMember"
objects: {}
objects: {
user: Prisma.$UserPayload<ExtArgs> | null
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
cwMemberId: number
@@ -950,6 +1058,7 @@ readonly fields: CwMemberFieldRefs;
*/
export interface Prisma__CwMemberClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
user<T extends Prisma.CwMember$userArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.CwMember$userArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1006,6 +1115,10 @@ export type CwMemberFindUniqueArgs<ExtArgs extends runtime.Types.Extensions.Inte
* Omit specific fields from the CwMember
*/
omit?: Prisma.CwMemberOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CwMemberInclude<ExtArgs> | null
/**
* Filter, which CwMember to fetch.
*/
@@ -1024,6 +1137,10 @@ export type CwMemberFindUniqueOrThrowArgs<ExtArgs extends runtime.Types.Extensio
* Omit specific fields from the CwMember
*/
omit?: Prisma.CwMemberOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CwMemberInclude<ExtArgs> | null
/**
* Filter, which CwMember to fetch.
*/
@@ -1042,6 +1159,10 @@ export type CwMemberFindFirstArgs<ExtArgs extends runtime.Types.Extensions.Inter
* Omit specific fields from the CwMember
*/
omit?: Prisma.CwMemberOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CwMemberInclude<ExtArgs> | null
/**
* Filter, which CwMember to fetch.
*/
@@ -1090,6 +1211,10 @@ export type CwMemberFindFirstOrThrowArgs<ExtArgs extends runtime.Types.Extension
* Omit specific fields from the CwMember
*/
omit?: Prisma.CwMemberOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CwMemberInclude<ExtArgs> | null
/**
* Filter, which CwMember to fetch.
*/
@@ -1138,6 +1263,10 @@ export type CwMemberFindManyArgs<ExtArgs extends runtime.Types.Extensions.Intern
* Omit specific fields from the CwMember
*/
omit?: Prisma.CwMemberOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CwMemberInclude<ExtArgs> | null
/**
* Filter, which CwMembers to fetch.
*/
@@ -1166,6 +1295,11 @@ export type CwMemberFindManyArgs<ExtArgs extends runtime.Types.Extensions.Intern
* Skip the first `n` CwMembers.
*/
skip?: number
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/distinct Distinct Docs}
*
* Filter by unique combinations of CwMembers.
*/
distinct?: Prisma.CwMemberScalarFieldEnum | Prisma.CwMemberScalarFieldEnum[]
}
@@ -1181,6 +1315,10 @@ export type CwMemberCreateArgs<ExtArgs extends runtime.Types.Extensions.Internal
* Omit specific fields from the CwMember
*/
omit?: Prisma.CwMemberOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CwMemberInclude<ExtArgs> | null
/**
* The data needed to create a CwMember.
*/
@@ -1229,6 +1367,10 @@ export type CwMemberUpdateArgs<ExtArgs extends runtime.Types.Extensions.Internal
* Omit specific fields from the CwMember
*/
omit?: Prisma.CwMemberOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CwMemberInclude<ExtArgs> | null
/**
* The data needed to update a CwMember.
*/
@@ -1295,6 +1437,10 @@ export type CwMemberUpsertArgs<ExtArgs extends runtime.Types.Extensions.Internal
* Omit specific fields from the CwMember
*/
omit?: Prisma.CwMemberOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CwMemberInclude<ExtArgs> | null
/**
* The filter to search for the CwMember to update in case it exists.
*/
@@ -1321,6 +1467,10 @@ export type CwMemberDeleteArgs<ExtArgs extends runtime.Types.Extensions.Internal
* Omit specific fields from the CwMember
*/
omit?: Prisma.CwMemberOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CwMemberInclude<ExtArgs> | null
/**
* Filter which CwMember to delete.
*/
@@ -1341,6 +1491,25 @@ export type CwMemberDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.Inte
limit?: number
}
/**
* CwMember.user
*/
export type CwMember$userArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the User
*/
select?: Prisma.UserSelect<ExtArgs> | null
/**
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
where?: Prisma.UserWhereInput
}
/**
* CwMember without action
*/
@@ -1353,4 +1522,8 @@ export type CwMemberDefaultArgs<ExtArgs extends runtime.Types.Extensions.Interna
* Omit specific fields from the CwMember
*/
omit?: Prisma.CwMemberOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CwMemberInclude<ExtArgs> | null
}
@@ -1474,6 +1474,11 @@ export type GeneratedQuotesFindManyArgs<ExtArgs extends runtime.Types.Extensions
* Skip the first `n` GeneratedQuotes.
*/
skip?: number
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/distinct Distinct Docs}
*
* Filter by unique combinations of GeneratedQuotes.
*/
distinct?: Prisma.GeneratedQuotesScalarFieldEnum | Prisma.GeneratedQuotesScalarFieldEnum[]
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -1175,6 +1175,11 @@ export type RoleFindManyArgs<ExtArgs extends runtime.Types.Extensions.InternalAr
* Skip the first `n` Roles.
*/
skip?: number
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/distinct Distinct Docs}
*
* Filter by unique combinations of Roles.
*/
distinct?: Prisma.RoleScalarFieldEnum | Prisma.RoleScalarFieldEnum[]
}
@@ -1192,6 +1192,11 @@ export type SecureValueFindManyArgs<ExtArgs extends runtime.Types.Extensions.Int
* Skip the first `n` SecureValues.
*/
skip?: number
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/distinct Distinct Docs}
*
* Filter by unique combinations of SecureValues.
*/
distinct?: Prisma.SecureValueScalarFieldEnum | Prisma.SecureValueScalarFieldEnum[]
}
+5 -12
View File
@@ -361,22 +361,10 @@ export type SessionOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder
}
export type StringFieldUpdateOperationsInput = {
set?: string
}
export type DateTimeFieldUpdateOperationsInput = {
set?: Date | string
}
export type BoolFieldUpdateOperationsInput = {
set?: boolean
}
export type NullableDateTimeFieldUpdateOperationsInput = {
set?: Date | string | null
}
export type SessionCreateNestedManyWithoutUserInput = {
create?: Prisma.XOR<Prisma.SessionCreateWithoutUserInput, Prisma.SessionUncheckedCreateWithoutUserInput> | Prisma.SessionCreateWithoutUserInput[] | Prisma.SessionUncheckedCreateWithoutUserInput[]
connectOrCreate?: Prisma.SessionCreateOrConnectWithoutUserInput | Prisma.SessionCreateOrConnectWithoutUserInput[]
@@ -1208,6 +1196,11 @@ export type SessionFindManyArgs<ExtArgs extends runtime.Types.Extensions.Interna
* Skip the first `n` Sessions.
*/
skip?: number
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/distinct Distinct Docs}
*
* Filter by unique combinations of Sessions.
*/
distinct?: Prisma.SessionScalarFieldEnum | Prisma.SessionScalarFieldEnum[]
}
+62 -13
View File
@@ -20,15 +20,25 @@ export type UnifiSiteModel = runtime.Types.Result.DefaultSelection<Prisma.$Unifi
export type AggregateUnifiSite = {
_count: UnifiSiteCountAggregateOutputType | null
_avg: UnifiSiteAvgAggregateOutputType | null
_sum: UnifiSiteSumAggregateOutputType | null
_min: UnifiSiteMinAggregateOutputType | null
_max: UnifiSiteMaxAggregateOutputType | null
}
export type UnifiSiteAvgAggregateOutputType = {
companyId: number | null
}
export type UnifiSiteSumAggregateOutputType = {
companyId: number | null
}
export type UnifiSiteMinAggregateOutputType = {
id: string | null
name: string | null
siteId: string | null
companyId: string | null
companyId: number | null
createdAt: Date | null
updatedAt: Date | null
}
@@ -37,7 +47,7 @@ export type UnifiSiteMaxAggregateOutputType = {
id: string | null
name: string | null
siteId: string | null
companyId: string | null
companyId: number | null
createdAt: Date | null
updatedAt: Date | null
}
@@ -53,6 +63,14 @@ export type UnifiSiteCountAggregateOutputType = {
}
export type UnifiSiteAvgAggregateInputType = {
companyId?: true
}
export type UnifiSiteSumAggregateInputType = {
companyId?: true
}
export type UnifiSiteMinAggregateInputType = {
id?: true
name?: true
@@ -116,6 +134,18 @@ export type UnifiSiteAggregateArgs<ExtArgs extends runtime.Types.Extensions.Inte
* Count returned UnifiSites
**/
_count?: true | UnifiSiteCountAggregateInputType
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
*
* Select which fields to average
**/
_avg?: UnifiSiteAvgAggregateInputType
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
*
* Select which fields to sum
**/
_sum?: UnifiSiteSumAggregateInputType
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
*
@@ -149,6 +179,8 @@ export type UnifiSiteGroupByArgs<ExtArgs extends runtime.Types.Extensions.Intern
take?: number
skip?: number
_count?: UnifiSiteCountAggregateInputType | true
_avg?: UnifiSiteAvgAggregateInputType
_sum?: UnifiSiteSumAggregateInputType
_min?: UnifiSiteMinAggregateInputType
_max?: UnifiSiteMaxAggregateInputType
}
@@ -157,10 +189,12 @@ export type UnifiSiteGroupByOutputType = {
id: string
name: string
siteId: string
companyId: string | null
companyId: number | null
createdAt: Date
updatedAt: Date
_count: UnifiSiteCountAggregateOutputType | null
_avg: UnifiSiteAvgAggregateOutputType | null
_sum: UnifiSiteSumAggregateOutputType | null
_min: UnifiSiteMinAggregateOutputType | null
_max: UnifiSiteMaxAggregateOutputType | null
}
@@ -187,7 +221,7 @@ export type UnifiSiteWhereInput = {
id?: Prisma.StringFilter<"UnifiSite"> | string
name?: Prisma.StringFilter<"UnifiSite"> | string
siteId?: Prisma.StringFilter<"UnifiSite"> | string
companyId?: Prisma.StringNullableFilter<"UnifiSite"> | string | null
companyId?: Prisma.IntNullableFilter<"UnifiSite"> | number | null
createdAt?: Prisma.DateTimeFilter<"UnifiSite"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"UnifiSite"> | Date | string
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
@@ -210,7 +244,7 @@ export type UnifiSiteWhereUniqueInput = Prisma.AtLeast<{
OR?: Prisma.UnifiSiteWhereInput[]
NOT?: Prisma.UnifiSiteWhereInput | Prisma.UnifiSiteWhereInput[]
name?: Prisma.StringFilter<"UnifiSite"> | string
companyId?: Prisma.StringNullableFilter<"UnifiSite"> | string | null
companyId?: Prisma.IntNullableFilter<"UnifiSite"> | number | null
createdAt?: Prisma.DateTimeFilter<"UnifiSite"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"UnifiSite"> | Date | string
company?: Prisma.XOR<Prisma.CompanyNullableScalarRelationFilter, Prisma.CompanyWhereInput> | null
@@ -224,8 +258,10 @@ export type UnifiSiteOrderByWithAggregationInput = {
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
_count?: Prisma.UnifiSiteCountOrderByAggregateInput
_avg?: Prisma.UnifiSiteAvgOrderByAggregateInput
_max?: Prisma.UnifiSiteMaxOrderByAggregateInput
_min?: Prisma.UnifiSiteMinOrderByAggregateInput
_sum?: Prisma.UnifiSiteSumOrderByAggregateInput
}
export type UnifiSiteScalarWhereWithAggregatesInput = {
@@ -235,7 +271,7 @@ export type UnifiSiteScalarWhereWithAggregatesInput = {
id?: Prisma.StringWithAggregatesFilter<"UnifiSite"> | string
name?: Prisma.StringWithAggregatesFilter<"UnifiSite"> | string
siteId?: Prisma.StringWithAggregatesFilter<"UnifiSite"> | string
companyId?: Prisma.StringNullableWithAggregatesFilter<"UnifiSite"> | string | null
companyId?: Prisma.IntNullableWithAggregatesFilter<"UnifiSite"> | number | null
createdAt?: Prisma.DateTimeWithAggregatesFilter<"UnifiSite"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"UnifiSite"> | Date | string
}
@@ -253,7 +289,7 @@ export type UnifiSiteUncheckedCreateInput = {
id?: string
name: string
siteId: string
companyId?: string | null
companyId?: number | null
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -271,7 +307,7 @@ export type UnifiSiteUncheckedUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
siteId?: Prisma.StringFieldUpdateOperationsInput | string
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
companyId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -280,7 +316,7 @@ export type UnifiSiteCreateManyInput = {
id?: string
name: string
siteId: string
companyId?: string | null
companyId?: number | null
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -297,7 +333,7 @@ export type UnifiSiteUncheckedUpdateManyInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
siteId?: Prisma.StringFieldUpdateOperationsInput | string
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
companyId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -311,6 +347,10 @@ export type UnifiSiteCountOrderByAggregateInput = {
updatedAt?: Prisma.SortOrder
}
export type UnifiSiteAvgOrderByAggregateInput = {
companyId?: Prisma.SortOrder
}
export type UnifiSiteMaxOrderByAggregateInput = {
id?: Prisma.SortOrder
name?: Prisma.SortOrder
@@ -329,6 +369,10 @@ export type UnifiSiteMinOrderByAggregateInput = {
updatedAt?: Prisma.SortOrder
}
export type UnifiSiteSumOrderByAggregateInput = {
companyId?: Prisma.SortOrder
}
export type UnifiSiteListRelationFilter = {
every?: Prisma.UnifiSiteWhereInput
some?: Prisma.UnifiSiteWhereInput
@@ -430,7 +474,7 @@ export type UnifiSiteScalarWhereInput = {
id?: Prisma.StringFilter<"UnifiSite"> | string
name?: Prisma.StringFilter<"UnifiSite"> | string
siteId?: Prisma.StringFilter<"UnifiSite"> | string
companyId?: Prisma.StringNullableFilter<"UnifiSite"> | string | null
companyId?: Prisma.IntNullableFilter<"UnifiSite"> | number | null
createdAt?: Prisma.DateTimeFilter<"UnifiSite"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"UnifiSite"> | Date | string
}
@@ -528,7 +572,7 @@ export type $UnifiSitePayload<ExtArgs extends runtime.Types.Extensions.InternalA
id: string
name: string
siteId: string
companyId: string | null
companyId: number | null
createdAt: Date
updatedAt: Date
}, ExtArgs["result"]["unifiSite"]>
@@ -958,7 +1002,7 @@ export interface UnifiSiteFieldRefs {
readonly id: Prisma.FieldRef<"UnifiSite", 'String'>
readonly name: Prisma.FieldRef<"UnifiSite", 'String'>
readonly siteId: Prisma.FieldRef<"UnifiSite", 'String'>
readonly companyId: Prisma.FieldRef<"UnifiSite", 'String'>
readonly companyId: Prisma.FieldRef<"UnifiSite", 'Int'>
readonly createdAt: Prisma.FieldRef<"UnifiSite", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"UnifiSite", 'DateTime'>
}
@@ -1157,6 +1201,11 @@ export type UnifiSiteFindManyArgs<ExtArgs extends runtime.Types.Extensions.Inter
* Skip the first `n` UnifiSites.
*/
skip?: number
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/distinct Distinct Docs}
*
* Filter by unique combinations of UnifiSites.
*/
distinct?: Prisma.UnifiSiteScalarFieldEnum | Prisma.UnifiSiteScalarFieldEnum[]
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -29,6 +29,7 @@
"utils:gen_private_keys": "bun ./utils/genPrivateKeys",
"utils:create_admin_role": "bun ./utils/createAdminRole",
"utils:assign_user_role": "bun ./utils/assignUserRole",
"utils:gen_access_token": "bun ./utils/generate24HourAccessToken.ts",
"utils:test_webserver": "bun ./utils/testWebserver.ts",
"utils:test_adjustments_poll": "bun ./utils/testAdjustmentsPoll.ts",
"utils:analyze_cw": "python3 debug-scripts/analyze-cw-calls.py",
@@ -45,6 +46,7 @@
"blakets": "^0.1.12",
"cors": "^2.8.6",
"cuid": "^3.0.0",
"dalpuri": "workspace:*",
"hono": "^4.11.5",
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.3",
@@ -54,7 +56,6 @@
"pg-boss": "^12.14.0",
"prisma": "^7.3.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"zod": "^4.3.6",
"zon": "^1.0.3"
}
@@ -0,0 +1,2 @@
-- Rename the misspelled column serverityId -> severityId on ServiceTicket
ALTER TABLE "ServiceTicket" RENAME COLUMN "serverityId" TO "severityId";
@@ -0,0 +1,41 @@
-- CreateEnum
CREATE TYPE "SyncJobType" AS ENUM ('FULL_SYNC', 'INCREMENTAL_SYNC');
-- CreateEnum
CREATE TYPE "SyncJobStatus" AS ENUM ('QUEUED', 'RUNNING', 'COMPLETED', 'FAILED', 'TIMED_OUT');
-- CreateTable
CREATE TABLE "SyncJobRun" (
"id" TEXT NOT NULL,
"jobType" "SyncJobType" NOT NULL,
"status" "SyncJobStatus" NOT NULL DEFAULT 'QUEUED',
"triggeredBy" TEXT NOT NULL DEFAULT 'system',
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"errorSummary" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SyncJobRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SyncStepLog" (
"id" TEXT NOT NULL,
"syncJobRunId" TEXT NOT NULL,
"tableName" TEXT NOT NULL,
"syncMode" TEXT NOT NULL,
"recordsProcessed" INTEGER NOT NULL DEFAULT 0,
"recordsInserted" INTEGER NOT NULL DEFAULT 0,
"recordsSkipped" INTEGER NOT NULL DEFAULT 0,
"recordsFailed" INTEGER NOT NULL DEFAULT 0,
"recordsDeleted" INTEGER NOT NULL DEFAULT 0,
"sampleErrors" JSONB NOT NULL DEFAULT '[]',
"durationMs" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SyncStepLog_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "SyncStepLog" ADD CONSTRAINT "SyncStepLog_syncJobRunId_fkey" FOREIGN KEY ("syncJobRunId") REFERENCES "SyncJobRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,47 @@
-- CreateTable (idempotent)
CREATE TABLE IF NOT EXISTS "OpportunityStage" (
"uid" TEXT NOT NULL,
"id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"seqNbr" INTEGER,
"funnelColor" TEXT,
"updatedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OpportunityStage_pkey" PRIMARY KEY ("uid")
);
-- CreateIndex (idempotent)
CREATE UNIQUE INDEX IF NOT EXISTS "OpportunityStage_id_key" ON "OpportunityStage"("id");
-- AlterTable: drop old columns if they exist, add stageId if it doesn't
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'Opportunity' AND column_name = 'stageName') THEN
ALTER TABLE "Opportunity" DROP COLUMN "stageName";
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'Opportunity' AND column_name = 'stageCwId') THEN
ALTER TABLE "Opportunity" DROP COLUMN "stageCwId";
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'Opportunity' AND column_name = 'stageId') THEN
ALTER TABLE "Opportunity" ADD COLUMN "stageId" INTEGER;
END IF;
END $$;
-- Nullify any stageId values that reference non-existent OpportunityStage rows
UPDATE "Opportunity" SET "stageId" = NULL
WHERE "stageId" IS NOT NULL
AND "stageId" NOT IN (SELECT "id" FROM "OpportunityStage");
-- AddForeignKey (skip if already exists)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'Opportunity_stageId_fkey'
) THEN
ALTER TABLE "Opportunity" ADD CONSTRAINT "Opportunity_stageId_fkey"
FOREIGN KEY ("stageId") REFERENCES "OpportunityStage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
+793 -25
View File
@@ -27,6 +27,18 @@ enum FaxType {
SITE // Main site fax line, not direct to contact
}
enum BillingMethod {
ACTUAL_RATES
FIXED_FEE
NOT_TO_EXCEED // Requires "Bill Ticket Seperately"
OVERRIDE_RATE // Shows hourly rate field
}
enum BillingType {
STANDARD
PROJECT
}
// By human nature, there are only two genders.
enum GenderType {
MALE
@@ -96,6 +108,59 @@ enum OpportunityInterest {
COLD
}
// ---- Sync Job Tracking ----
enum SyncJobType {
FULL_SYNC
INCREMENTAL_SYNC
}
enum SyncJobStatus {
QUEUED
RUNNING
COMPLETED
FAILED
TIMED_OUT
}
model SyncJobRun {
id String @id @default(uuid())
jobType SyncJobType
status SyncJobStatus @default(QUEUED)
triggeredBy String @default("system")
startedAt DateTime?
completedAt DateTime?
errorSummary String?
steps SyncStepLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SyncStepLog {
id String @id @default(uuid())
syncJobRunId String
syncJobRun SyncJobRun @relation(fields: [syncJobRunId], references: [id], onDelete: Cascade)
tableName String
syncMode String // "full" or "incremental"
recordsProcessed Int @default(0)
recordsInserted Int @default(0)
recordsSkipped Int @default(0)
recordsFailed Int @default(0)
recordsDeleted Int @default(0)
sampleErrors Json @default("[]")
durationMs Int @default(0)
createdAt DateTime @default(now())
}
model Session {
id String @id @default(uuid())
sessionKey String @unique @default(cuid())
@@ -108,21 +173,25 @@ model Session {
}
model User {
id String @id @default(cuid())
roles Role[]
permissions String?
login String @unique
name String?
email String @unique
emailVerified DateTime?
image String?
id String @id @default(uuid())
roles Role[]
permissions String?
login String @unique
firstName String?
lastName String?
email String @unique
image String?
title String?
active Boolean @default(true)
hidden Boolean @default(false)
cwIdentifier String? @unique
cwIdentifier String? @unique
cwMemberId Int? @unique
cwMember CwMember? @relation(fields: [cwMemberId], references: [cwMemberId])
userId String @unique
userId String? @unique
token String?
sessions Session[]
@@ -136,8 +205,16 @@ model User {
generatedQuotes GeneratedQuotes[]
companyAddresses CompanyAddress[]
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
serviceTicketsOwned ServiceTicket[] @relation("ServiceTicketOwner")
serviceTicketsClosed ServiceTicket[] @relation("ServiceTicketClosedBy")
serviceTicketsCreated ServiceTicket[] @relation("ServiceTicketCreatedBy")
serviceTicketsUpdated ServiceTicket[] @relation("ServiceTicketUpdatedBy")
serviceTicketNotes ServiceTicketNote[] @relation("ServiceTicketNoteAuthor")
}
model Role {
@@ -170,7 +247,9 @@ model CorporateLocation {
inactiveFlag Boolean @default(false) // Optima Only field, not synced to CW
opportunities Opportunity[]
opportunities Opportunity[]
serviceBoards ServiceTicketBoard[]
productDataRecords ProductData[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -232,11 +311,13 @@ model Company {
deletedBy User? @relation("DeletedBy", fields: [deletedById], references: [cwIdentifier])
enteredBy User? @relation("EnteredBy", fields: [enteredById], references: [cwIdentifier])
enteredAt DateTime @default(now())
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
serviceTickets ServiceTicket[]
billingServiceTickets ServiceTicket[] @relation("BillingCompany")
}
model CompanyAddress {
@@ -270,6 +351,8 @@ model CompanyAddress {
contacts Contact[]
oppportunities Opportunity[]
serviceTickets ServiceTicket[]
billingTickets ServiceTicket[] @relation("BillingAddress")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -304,29 +387,190 @@ model Contact {
company Company? @relation(fields: [companyId], references: [id])
opportunities Opportunity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
serviceTickets ServiceTicket[]
}
model CatalogItemType {
id Int @unique
uid String @id @default(uuid())
name String
description String? // Optima Only field, not synced to CW
inactiveFlag Boolean @default(false)
defaultFlag Boolean @default(false)
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CatalogCategory {
id Int @unique
uid String @id @default(uuid())
name String
description String? // Optima Only field, not synced to CW
inactiveFlag Boolean @default(false)
catalogSubcategories CatalogSubcategory[]
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CatalogSubcategory {
id Int @unique
uid String @id @default(uuid())
name String
description String? // Optima Only field, not synced to CW
categoryId Int
category CatalogCategory @relation(fields: [categoryId], references: [id])
items CatalogItem[]
inactiveFlag Boolean @default(false)
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CatalogManufacturer {
id Int @unique
uid String @id @default(uuid())
name String
description String? // Optima Only field, not synced to CW
inactiveFlag Boolean @default(false)
items CatalogItem[]
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model WarehouseBin {
id Int @unique
uid String @id @default(uuid())
name String
description String? // Optima Only field, not synced to CW
minQuantity Int
maxQuantity Int
inactiveFlag Boolean @default(false)
defaultFlag Boolean @default(false)
proeductInventories ProductInventory[]
warehouse Warehouse? @relation(fields: [warehouseId], references: [id])
warehouseId Int?
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ProductInventory {
id Int @unique
uid String @id @default(uuid())
qtyOnHand Int @default(0)
warehouseBinId Int
warehouseBin WarehouseBin @relation(fields: [warehouseBinId], references: [id])
itemId Int?
item CatalogItem? @relation(fields: [itemId], references: [id])
warehouse Warehouse? @relation(fields: [warehouseId], references: [id])
warehouseId Int?
updatedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Warehouse {
id Int @unique
uid String @id @default(uuid())
name String
description String? // Optima Only field, not synced to CW
inactiveFlag Boolean @default(false)
lockedFlag Boolean @default(false)
minimumStock MinimumStockByWarehouse[]
inventory ProductInventory[]
bins WarehouseBin[]
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model MinimumStockByWarehouse {
id Int @unique
uid String @id @default(uuid())
minQty Int @default(0)
warehouseId Int
warehouse Warehouse @relation(fields: [warehouseId], references: [id])
itemId Int?
item CatalogItem? @relation(fields: [itemId], references: [id])
updatedById String?
enteredById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CatalogItem {
id String @id @default(cuid())
cwCatalogId Int @unique
identifier String? @unique
name String
description String?
id Int @unique
uid String @id @default(uuid())
identifier String? @unique
name String
description String?
customerDescription String?
internalNotes String?
linkedItems CatalogItem[] @relation("LinkedItems")
linkedTo CatalogItem[] @relation("LinkedItems")
category String?
categoryCwId Int?
subcategory String?
subcategoryCwId Int?
subcategoryId Int
subcategory CatalogSubcategory @relation(fields: [subcategoryId], references: [id])
manufacturer String?
manufactureCwId Int?
manufacturerId Int?
manufacturer CatalogManufacturer? @relation(fields: [manufacturerId], references: [id])
partNumber String?
@@ -337,12 +581,403 @@ model CatalogItem {
price Float
cost Float
inventory ProductInventory[]
minimumStockByWarehouses MinimumStockByWarehouse[]
productDataRecords ProductData[]
inactive Boolean @default(false)
salesTaxable Boolean @default(true)
onHand Int @default(0)
cwLastUpdated DateTime?
// IV_Class_ID from CW: 'S' = Service/Labor, 'I' = Inventory, 'N' = Non-Inventory
classId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ProductData {
id Int @unique
uid String @id @default(uuid())
qty Float @default(1)
internalNote String?
shortDescription String?
description String?
sequenceNumber Int? // This is the sequence number of the product on the ticket, which may be different from the sequence number of the product in the catalog, and is important for maintaining the order of products on the ticket as they were added.
procurementNotes String?
productNarrative String?
unitPrice Float @default(0)
unitCost Float @default(0)
listPrice Float @default(0) // The original price of the product before any discounts or promotions are applied, which may be different from the unit price if there are any overrides or promotions applied at the ticket level.
discount Float @default(0) // The discount amount applied to this product, which may be different from the discount on the catalog item if there are any overrides or promotions applied at the ticket level.
recurringRevenue Float @default(0) // For subscription products, the recurring revenue amount, which may be different from the unit price if there are any discounts or promotions applied.
recurringCost Float @default(0) // For subscription products, the recurring cost amount, which may be different from the unit cost if there are any discounts or promotions applied.
qtyPicked Int @default(0) // How many of this product have been taken out of inventory
qtyShipped Int @default(0) // How many of this product have been recieved by the customer
cancelReason String? // If this product was canceled or removed from the ticket after being added, what was the reason for that?
cancelQty Float? // If this product was canceled or removed from the ticket after being added, how many were canceled or removed?
// ------ Flag Fields ------
billableFlag Boolean @default(true)
taxableFlag Boolean @default(true)
invoiceFlag Boolean @default(true)
recurringFlag Boolean @default(false) // Is this product a subscription or recurring revenue product?
poApprovedFlag Boolean @default(false) // Was the purchase of this product approved as part of a purchase order process?
calcPriceFlag Boolean @default(true) // Should the price of this product be calculated based on the catalog item price and any overrides, or should it use the unitPrice as is?
calcCostFlag Boolean @default(true) // Should the cost of this product be calculated based on the catalog item cost and any overrides, or should it use the unitCost as is?
cancelFlag Boolean @default(false) // Has this product been canceled or removed from the ticket after being added, but we want to keep the record of it for historical and reporting purposes?
// ------ Relational Fields ------
catalogItemId Int
corporateLocationId Int
serviceTicketId Int?
opportunityId Int?
serviceTicket ServiceTicket? @relation(fields: [serviceTicketId], references: [id])
opportunity Opportunity? @relation(fields: [opportunityId], references: [id])
catalogItem CatalogItem @relation(fields: [catalogItemId], references: [id])
corporateLocation CorporateLocation @relation(fields: [corporateLocationId], references: [id])
updatedById String?
createdById String?
closedById String?
cancelById String
closedAt DateTime?
cancelledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
//
// SERVICE TICKETS
//
model ServiceTicket {
id Int @unique
uid String @id @default(uuid())
// ------ Core ticket fields ------
summary String
notes ServiceTicketNote[]
addressLine1 String?
addressLine2 String?
city String?
state USState?
zipCode String?
country Country?
contactName String?
phone String?
phoneExtension String?
phoneType PhoneType?
email String?
// ------ Billing and invoicing fields ------
products ProductData[] // The products used on this ticket, which may be important for billing and invoicing, as well as reporting and analytics.
poNumber String?
billCompleteFlag Boolean @default(false) // Bill after ticket is closed, not allowing any billing while open.
billUnapprovedFlag Boolean @default(false) // Can this ticket bill unapproved work or expenses?
billingAmount Float @default(0.00)
billingMethod BillingMethod @default(ACTUAL_RATES)
timeBillableFlag Boolean @default(true) // Is the time spent on this ticket billable?
expenseBillableFlag Boolean @default(true) // Are the expenses incurred on this ticket billable?
productBillableFlag Boolean @default(true) // Are the products used on this ticket billable?
timeInvoiceableFlag Boolean @default(true) // Should the billable time on this ticket be included on invoices?
expenseInvoiceableFlag Boolean @default(true) // Should the billable expenses on this ticket be included on invoices?
productInvoiceableFlag Boolean @default(true) // Should the billable products on this ticket be included on invoices?
dateRequested DateTime? // The date the customer requested service, which may be different from the date the ticket was created in the system.
billingType BillingType @default(STANDARD) // (CUSTOM FIELD) Standard billing or project billing, which may have different rules for how the ticket is billed.
billingInstructions String? // (CUSTOM FIELD) Any special instructions for billing this ticket, which may be important for project billing or non-standard billing arrangements.
// ------ Flag Fields ------
rejectedFlag Boolean @default(false) // More used for denoting if a ticket was rejected by some automation.
closedFlag Boolean @default(false)
redFlag Boolean @default(false) // Carry over from CW, used for visibility and filtering.
publishFlag Boolean @default(false) // Should this ticket be visible to the customer in the portal or any other means?
// ------ Relational Fields ------
ticketOwnerId String?
serviceTicketBoardId Int?
severityId Int
impactId Int
priorityId Int
sourceId Int
locationId Int
parentId Int?
companyId Int?
contactId Int?
companyAddressId Int?
billingCompanyId Int?
billingAddressId Int?
severity ServiceTicketSeverity @relation(fields: [severityId], references: [id])
impact ServiceTicketImpact @relation(fields: [impactId], references: [id])
priority ServiceTicketPriority @relation(fields: [priorityId], references: [id])
source ServiceTicketSource @relation(fields: [sourceId], references: [id])
location ServiceTicketLocation @relation(fields: [locationId], references: [id])
serviceTicketBoard ServiceTicketBoard? @relation(fields: [serviceTicketBoardId], references: [id])
ticketOwner User? @relation("ServiceTicketOwner", fields: [ticketOwnerId], references: [cwIdentifier])
company Company? @relation(fields: [companyId], references: [id])
contact Contact? @relation(fields: [contactId], references: [id])
companyAddress CompanyAddress? @relation(fields: [companyAddressId], references: [id])
billingCompany Company? @relation("BillingCompany", fields: [billingCompanyId], references: [id])
billingAddress CompanyAddress? @relation("BillingAddress", fields: [billingAddressId], references: [id])
// ------ Audit Fields ------
createdById String?
updatedById String?
closedById String?
closedBy User? @relation("ServiceTicketClosedBy", fields: [closedById], references: [id])
createdBy User? @relation("ServiceTicketCreatedBy", fields: [createdById], references: [id])
updatedBy User? @relation("ServiceTicketUpdatedBy", fields: [updatedById], references: [id])
rejectedAt DateTime?
closedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ServiceTicketNote {
id Int @unique
uid String @id @default(uuid())
notes String
notesMd String
authorId String
author User @relation("ServiceTicketNoteAuthor", fields: [authorId], references: [id])
problemFlag Boolean @default(false) // Is this note describing the problem?
resolutionFlag Boolean @default(false) // Is this note describing the resolution?
internalAnalysisFlag Boolean @default(false) // Is this note describing the internal analysis of the issue, such as root cause analysis or technical details that may not be relevant to the customer?
internalMemberFlag Boolean @default(false) // Is this note meant to be seen by internal team members only, not visible to the customer?
createdByParentFlag Boolean @default(false) // Is this note created by the parent entity.
mergedFlag Boolean @default(false)
bundledFlag Boolean @default(false)
serviceTicketId Int
serviceTicket ServiceTicket @relation(fields: [serviceTicketId], references: [id])
createdById String?
updatedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ServiceTicketType {
id Int @unique
uid String @id @default(uuid())
name String
description String? // Optima Only field, not synced to CW
inactiveFlag Boolean @default(false)
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ServiceTicketBoard {
id Int @unique
uid String @id @default(uuid())
name String
// Does this generate revenue for the company?
timeBillableFlag Boolean @default(false)
expenseBillableFlag Boolean @default(false)
productBillableFlag Boolean @default(false)
// Can the customer see this on their invoice?
timeInvoiceableFlag Boolean @default(false)
expenseInvoiceableFlag Boolean @default(false)
productInvoiceableFlag Boolean @default(false)
// These are auto assignment rule flags
// If/When I implement a system for auto assignement,
// these flags will determine which fields are considered
// for auto assignment when a ticket is created without an assignee.
// These are a carry over from CW.
autoAssignNewFlag Boolean @default(false)
autoAssignEmailCreatedFlag Boolean @default(false)
autoAssignPortalCreatedFlag Boolean @default(false)
projectFlag Boolean @default(false)
lockDescriptionFlag Boolean @default(false) // Should we lock the description field on tickets in this board after creation?
emailContactFlag Boolean @default(false) // Should we email the contact when a ticket is updated?
emailResourceFlag Boolean @default(false) // Should we email the assigned resource(s) when a ticket is updated?
resolutionSortOrder String @default("D") @db.Char(1)
internalAnalysisSortOrder String @default("D") @db.Char(1)
locationId Int
location CorporateLocation @relation(fields: [locationId], references: [id])
serviceTickets ServiceTicket[]
createdById String?
updatedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ServiceTicketLocation {
id Int @unique
uid String @id @default(uuid())
name String
description String? // Optima Only field, not synced to CW
defaultFlag Boolean @default(false)
serviceTickets ServiceTicket[]
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ServiceTicketSource {
id Int @unique
uid String @id @default(uuid())
name String
description String? // Optima Only field, not synced to CW
inactiveFlag Boolean @default(false)
defaultFlag Boolean @default(false)
serviceTickets ServiceTicket[]
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// How much does the issue affect the customer or their business operations
model ServiceTicketImpact {
id Int @unique
uid String @id @default(uuid())
name String
description String?
defaultFlag Boolean @default(false)
serviceTickets ServiceTicket[]
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// How soon does the issue need to be addressed/resolved
model ServiceTicketPriority {
id Int @unique
uid String @id @default(uuid())
name String
color String? // Hex color code for priority, e.g. "#FF0000" for red
description String? // Optima Only field, not synced to CW
defaultFlag Boolean @default(false)
serviceTickets ServiceTicket[]
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// How bad is the Technical Issue
model ServiceTicketSeverity {
id Int @unique
uid String @id @default(uuid())
name String
description String?
defaultFlag Boolean @default(false)
serviceTickets ServiceTicket[]
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("ServiceTicketServerity")
}
// This data is populated asynchronously after a ticket is closed,
// so we can keep the critical path of closing a ticket fast and not
// dependent on any additional data processing. This will also allow
// us to be able to store the data AS IT was at the time of closing,
// without worrying about any additional updates that may come in after the fact.
model ServiceTicketFinalData {
id String @id @default(uuid())
}
model OpportunityStage {
id Int @unique
uid String @id @default(uuid())
name String
seqNbr Int?
funnelColor String?
updatedById String?
opportunities Opportunity[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -393,17 +1028,22 @@ model Opportunity {
name String
notes String?
oppNarrative String? // A long form text field for the story of the opportunity, which may include details about the customer's needs, the proposed solution, and any other relevant information that doesn't fit into the structured fields.
generatedQuotes GeneratedQuotes[]
typeId Int
type OpportunityType @relation(fields: [typeId], references: [id])
stageName String?
stageCwId Int?
stageId Int?
stage OpportunityStage? @relation(fields: [stageId], references: [id])
statusId Int?
status OpportunityStatus? @relation(fields: [statusId], references: [id])
taxCodeId Int?
taxCode TaxCode? @relation(fields: [taxCodeId], references: [id])
interest OpportunityInterest?
probability Float @default(0)
@@ -447,6 +1087,111 @@ model Opportunity {
// When present, fetchProducts() uses this order instead of CW sequenceNumber.
productSequence Int[] @default([])
products ProductData[]
updatedBy String
eneteredBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ------ Schedule / Calendar --------
model ScheduleStatus {
id Int @unique
uid String @id @default(uuid())
name String
description String? // Optima Only field, not synced to CW
color String?
softFlag Boolean @default(false)
defaultFlag Boolean @default(false)
schedules Schedule[]
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ScheduleType {
id Int @unique
uid String @id @default(uuid())
name String
description String? // Optima Only field, not synced to CW
displayColor String?
tableReference String?
moduleId String? @db.Char(2)
scheduleTypeId String? @db.Char(1)
systemFlag Boolean @default(false)
displayFlag Boolean @default(false)
schedules Schedule[]
updatedById String?
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ScheduleSpan {
id Int @id @default(autoincrement())
scheduleSpanId String? @db.Char(1)
spanDesc String? @db.Char(20)
schedules Schedule[]
}
model Schedule {
id Int @unique
uid String @id @default(uuid())
name String
description String?
memberId String?
closedFlag Boolean @default(false)
reminderFlag Boolean @default(false)
allDayFlag Boolean @default(false)
acknowledgementFlag Boolean @default(false)
meetingFlag Boolean @default(false)
recurringFlag Boolean @default(false)
billableFlag Boolean @default(false)
acknowledgedById String?
acknowledgedAt DateTime?
startDate DateTime?
endDate DateTime?
hoursScheduled Float?
duration Int? // The number of days in between the start and end date.
hoursPerDay Float?
reminderMinutes Int? @default(15)
statusId Int?
status ScheduleStatus? @relation(fields: [statusId], references: [id])
typeId Int?
type ScheduleType? @relation(fields: [typeId], references: [id])
scheduleSpanId Int?
scheduleSpan ScheduleSpan? @relation(fields: [scheduleSpanId], references: [id])
updatedById String?
createdById String?
closedById String?
closedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -523,6 +1268,27 @@ model GeneratedQuotes {
updatedAt DateTime @updatedAt
}
model TaxCode {
id Int @unique
uid String @id @default(uuid())
opportunities Opportunity[]
code String @unique
codeCaption String
description String?
rate Float?
defaultFlag Boolean @default(false)
createdBy String
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CwMember {
id String @id @default(cuid())
@@ -535,6 +1301,8 @@ model CwMember {
apiKey String?
user User?
cwLastUpdated DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+24
View File
@@ -0,0 +1,24 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { redis } from "../../constants";
/* /v1/auth/callback/:callbackKey */
export default createRoute("get", ["/callback/:callbackKey"], async (c) => {
const callbackKey = c.req.param("callbackKey");
if (!callbackKey) {
c.status(400);
return c.json({ status: 400, message: "Missing callbackKey", successful: false });
}
const redisKey = `auth:cb:${callbackKey}`;
const raw = await redis.get(redisKey);
if (!raw) {
c.status(202);
return c.json({ status: 202, message: "Pending", successful: false });
}
// Delete immediately so tokens can't be replayed
await redis.del(redisKey);
const tokens = JSON.parse(raw) as { accessToken: string; refreshToken: string };
return c.json({ status: 200, message: "Auth callback resolved", data: tokens, successful: true });
});
+1
View File
@@ -1,3 +1,4 @@
export { default as redirect } from "./redirect";
export { default as refresh } from "./refresh";
export { default as uri } from "./uri";
export { default as callback } from "./callback";
+10 -12
View File
@@ -1,9 +1,11 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../../modules/api-utils/createRoute";
import * as msal from "@azure/msal-node";
import { API_BASE_URL, io, msalClient } from "../../constants";
import { API_BASE_URL, msalClient, redis } from "../../constants";
import { users } from "../../managers/users";
const AUTH_CALLBACK_TTL_SECONDS = 300; // 5 minutes
/* /v1/auth/redirect */
export default createRoute("get", ["/redirect"], async (c) => {
c.status(200);
@@ -18,10 +20,13 @@ export default createRoute("get", ["/redirect"], async (c) => {
const callbackKey = c.req.query().state as string;
const tokens = await users.authenticate(authResult);
io.of(`/auth_callback`).emit(`auth:login:callback:${callbackKey}`, {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
});
// Store tokens in Redis so the UI can poll for them
await redis.set(
`auth:cb:${callbackKey}`,
JSON.stringify({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }),
"EX",
AUTH_CALLBACK_TTL_SECONDS,
);
// Close the window because duh
return c.html(
@@ -29,11 +34,4 @@ export default createRoute("get", ["/redirect"], async (c) => {
window.close();
</script>`,
);
return c.json({
status: 200,
message: "Auth Redirect Endpoint",
data: authResult,
successful: true,
});
});
+16 -27
View File
@@ -4,7 +4,6 @@ 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 GenericError from "../../../Errors/GenericError";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* /v1/company/companies/[id] */
@@ -14,34 +13,17 @@ export default createRoute(
async (c) => {
const company = await companies.fetch(c.req.param("identifier"));
const includeAddress = c.req.query("includeAddress") === "true";
const user = c.get("user");
const includeAddress =
c.req.query("includeAddress") === "true" &&
!!user &&
(await user.hasPermission("company.fetch.address"));
const includePrimaryContact =
c.req.query("includePrimaryContact") === "true";
const includeAllContacts = c.req.query("includeAllContacts") === "true";
// Check for address-specific permission if includeAddress is requested
if (includeAddress) {
const user = c.get("user");
if (!user || !(await user.hasPermission("company.fetch.address"))) {
throw new GenericError({
name: "InsufficientPermission",
message: "You do not have permission to view company addresses.",
status: 403,
});
}
}
// Check for contacts permission if includeAllContacts is requested
if (includeAllContacts) {
const user = c.get("user");
if (!user || !(await user.hasPermission("company.fetch.contacts"))) {
throw new GenericError({
name: "InsufficientPermission",
message: "You do not have permission to view company contacts.",
status: 403,
});
}
}
const includeAllContacts =
c.req.query("includeAllContacts") === "true" &&
!!user &&
(await user.hasPermission("company.fetch.contacts"));
const companyData = company.toJson({
includeAddress,
@@ -54,6 +36,13 @@ export default createRoute(
c.get("user"),
);
// cw_Data fields were already gated by the explicit permission checks above
// (company.fetch.contacts / company.fetch.address). Re-attach them so they
// are not silently dropped by field-level gating on obj.company.cw_Data.
if (companyData.cw_Data && Object.keys(companyData.cw_Data).length > 0) {
(gatedData as any).cw_Data = companyData.cw_Data;
}
const response = apiResponse.successful(
"Company Fetched Successfully!",
gatedData,
+16 -17
View File
@@ -2,36 +2,35 @@ 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 { getMemberCache } from "../../modules/cw-utils/members/memberCache";
import { prisma } from "../../constants";
/* GET /v1/cw/members */
export default createRoute(
"get",
["/members"],
async (c) => {
const cache = await getMemberCache();
const activeOnly = c.req.query("active") !== "false";
const members = cache
.filter((m) => (activeOnly ? !m.inactiveFlag : true))
.map((m) => ({
id: m.id,
identifier: m.identifier,
firstName: m.firstName,
lastName: m.lastName,
name: `${m.firstName} ${m.lastName}`.trim(),
officeEmail: m.officeEmail,
inactive: m.inactiveFlag,
}));
const dbMembers = await prisma.cwMember.findMany({
where: activeOnly ? { inactiveFlag: false } : undefined,
orderBy: [{ firstName: "asc" }, { lastName: "asc" }],
});
const sorted = members.sort((a, b) => a.name.localeCompare(b.name));
const members = dbMembers.map((m) => ({
id: m.cwMemberId,
identifier: m.identifier,
firstName: m.firstName,
lastName: m.lastName,
name: `${m.firstName} ${m.lastName}`.trim(),
officeEmail: m.officeEmail,
inactive: m.inactiveFlag,
}));
const response = apiResponse.successful(
"CW members fetched successfully!",
sorted,
members
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware(),
authMiddleware()
);
+3 -1
View File
@@ -1,4 +1,6 @@
import { default as callback } from "./callback";
import { default as fetchMembers } from "./fetchMembers";
import { default as syncFull } from "./sync";
import { syncStatus, syncStatusById, syncHistory } from "./sync-status";
export { callback, fetchMembers };
export { callback, fetchMembers, syncFull, syncStatus, syncStatusById, syncHistory };
+84
View File
@@ -0,0 +1,84 @@
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 { prisma } from "../../constants";
/* GET /v1/cw/sync/status — latest sync job run with step logs */
export const syncStatus = createRoute(
"get",
["/sync/status"],
async (c) => {
const latest = await prisma.syncJobRun.findFirst({
orderBy: { createdAt: "desc" },
include: {
steps: {
orderBy: { createdAt: "asc" },
},
},
});
if (!latest) {
const response = apiResponse.successful("No sync runs found", null);
return c.json(response, response.status as ContentfulStatusCode);
}
const response = apiResponse.successful("Latest sync run", latest);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware()
);
/* GET /v1/cw/sync/status/:jobId — specific job details */
export const syncStatusById = createRoute(
"get",
["/sync/status/:jobId"],
async (c) => {
const jobId = c.req.param("jobId");
const run = await prisma.syncJobRun.findUnique({
where: { id: jobId },
include: {
steps: {
orderBy: { createdAt: "asc" },
},
},
});
if (!run) {
const response = apiResponse.notFound("Sync run not found");
return c.json(response, response.status as ContentfulStatusCode);
}
const response = apiResponse.successful("Sync run details", run);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware()
);
/* GET /v1/cw/sync/history — last N sync runs */
export const syncHistory = createRoute(
"get",
["/sync/history"],
async (c) => {
const limitParam = c.req.query("limit");
const limit = Math.min(
Math.max(Number.parseInt(limitParam || "20", 10) || 20, 1),
100
);
const runs = await prisma.syncJobRun.findMany({
orderBy: { createdAt: "desc" },
take: limit,
include: {
_count: {
select: { steps: true },
},
},
});
const response = apiResponse.successful("Sync history", runs);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware()
);
+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 { getBoss } from "../../workert";
import { WorkerQueue } from "../../modules/workers/queues";
/* POST /v1/cw/sync/full */
export default createRoute(
"post",
["/sync/full"],
async (c) => {
const jobId = await getBoss().send(WorkerQueue.DALPURI_FULL_SYNC, {});
if (!jobId) {
throw new Error("Failed to enqueue dalpuri full sync job");
}
const response = apiResponse.successful(
"Full sync enqueued successfully",
{ jobId }
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware()
);
+7
View File
@@ -0,0 +1,7 @@
import { Hono } from "hono";
import * as scheduleRoutes from "../schedules";
const scheduleRouter = new Hono();
Object.values(scheduleRoutes).map((r) => scheduleRouter.route("/", r));
export default scheduleRouter;
+7 -110
View File
@@ -4,20 +4,6 @@ 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 GenericError from "../../../Errors/GenericError";
import { prisma } from "../../../constants";
import { computeSubResourceCacheTTL } from "../../../modules/algorithms/computeSubResourceCacheTTL";
import { computeProductsCacheTTL } from "../../../modules/algorithms/computeProductsCacheTTL";
import {
getCachedSite,
getCachedNotes,
getCachedContacts,
getCachedProducts,
fetchAndCacheNotes,
fetchAndCacheContacts,
fetchAndCacheProducts,
fetchAndCacheSite,
} from "../../../modules/cache/opportunityCache";
import { generatedQuotes } from "../../../managers/generatedQuotes";
/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */
@@ -31,102 +17,13 @@ export default createRoute(
includeParam
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
.filter(Boolean)
);
// ── Quick DB lookup (≈3ms) to get cwOpportunityId for pre-warming ──
const isNumeric = /^\d+$/.test(identifier);
const dbRecord = await prisma.opportunity.findFirst({
where: isNumeric
? { cwOpportunityId: Number(identifier) }
: { id: identifier },
select: {
cwOpportunityId: true,
companyCwId: true,
siteCwId: true,
closedFlag: true,
closedDate: true,
expectedCloseDate: true,
cwLastUpdated: true,
statusCwId: true,
},
});
// Fetch the opportunity from local DB
const item = await opportunities.fetchItem(identifier);
if (!dbRecord) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
// Compute TTLs from DB state
const subTtl = computeSubResourceCacheTTL({
closedFlag: dbRecord.closedFlag,
closedDate: dbRecord.closedDate,
expectedCloseDate: dbRecord.expectedCloseDate,
lastUpdated: dbRecord.cwLastUpdated,
});
const prodTtl = computeProductsCacheTTL({
closedFlag: dbRecord.closedFlag,
closedDate: dbRecord.closedDate,
expectedCloseDate: dbRecord.expectedCloseDate,
lastUpdated: dbRecord.cwLastUpdated,
statusCwId: dbRecord.statusCwId,
});
// ── Pre-warm sub-resources only on cache miss ───────────────────────
// Check Redis first — if the background refresh has kept the keys warm,
// skip the CW calls entirely. Only fetch-and-cache on a miss.
const cwOppId = dbRecord.cwOpportunityId;
const _ignoreErrors = (p: Promise<any>) => p.catch(() => {});
const prewarmPromises: Promise<any>[] = [];
if (dbRecord.companyCwId && dbRecord.siteCwId) {
const compId = dbRecord.companyCwId,
siteId = dbRecord.siteCwId;
prewarmPromises.push(
_ignoreErrors(
getCachedSite(compId, siteId).then(
(c) => c ?? fetchAndCacheSite(compId, siteId),
),
),
);
}
if (includes.has("notes") && subTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedNotes(cwOppId).then(
(c) => c ?? fetchAndCacheNotes(cwOppId, subTtl),
),
),
);
if (includes.has("contacts") && subTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedContacts(cwOppId).then(
(c) => c ?? fetchAndCacheContacts(cwOppId, subTtl),
),
),
);
if (includes.has("products") && prodTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedProducts(cwOppId).then(
(c) => c ?? fetchAndCacheProducts(cwOppId, prodTtl),
),
),
);
// fetchItem runs its own CW calls (opp, activities, company) —
// these execute concurrently with the sub-resource pre-warming above.
const [item] = await Promise.all([
opportunities.fetchItem(identifier),
...prewarmPromises,
]);
// Sub-resources now hit warm Redis cache (near-instant)
// Fetch sub-resources as requested
const subResourcePromises: Record<string, Promise<any>> = {
_site: item.fetchSite(),
};
@@ -154,7 +51,7 @@ export default createRoute(
const gatedData = await processObjectValuePerms(
item.toJson(),
"obj.opportunity",
c.get("user"),
c.get("user")
);
const originalOpportunityNoteText = (gatedData as any).notes;
@@ -175,9 +72,9 @@ export default createRoute(
const response = apiResponse.successful(
"Opportunity fetched successfully!",
gatedData,
gatedData
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
authMiddleware({ permissions: ["sales.opportunity.fetch"] })
);
+7 -2
View File
@@ -2,7 +2,7 @@ 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";
import { prisma } from "../../constants";
/* GET /v1/sales/opportunity-types */
export default createRoute(
@@ -10,9 +10,14 @@ export default createRoute(
["/opportunity-types"],
async (c) => {
const types = await prisma.opportunityType.findMany({
where: { inactiveFlag: false },
orderBy: { name: "asc" },
select: { id: true, name: true, inactiveFlag: true },
});
const response = apiResponse.successful(
"Opportunity Types Fetched Successfully!",
QUOTE_STATUSES,
types,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+4
View File
@@ -27,6 +27,8 @@ import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll";
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
import { default as fetchNarrative } from "./opportunities/[id]/quotes/fetchNarrative";
import { default as updateNarrative } from "./opportunities/[id]/quotes/updateNarrative";
import { default as fetchByUser } from "./opportunities/fetchByUser";
import { default as fetchByUserId } from "./opportunities/fetchByUserId";
import { default as workflowDispatch } from "./opportunities/[id]/workflow/dispatch";
@@ -63,6 +65,8 @@ export {
previewQuote,
downloadQuote,
fetchDownloads,
fetchNarrative,
updateNarrative,
refresh,
updateOpportunity,
workflowDispatch,
+9 -109
View File
@@ -5,19 +5,6 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
import { processObjectValuePerms } from "../../../../modules/permission-utils/processObjectPermissions";
import GenericError from "../../../../Errors/GenericError";
import { prisma } from "../../../../constants";
import { computeSubResourceCacheTTL } from "../../../../modules/algorithms/computeSubResourceCacheTTL";
import { computeProductsCacheTTL } from "../../../../modules/algorithms/computeProductsCacheTTL";
import {
getCachedSite,
getCachedNotes,
getCachedContacts,
getCachedProducts,
fetchAndCacheNotes,
fetchAndCacheContacts,
fetchAndCacheProducts,
fetchAndCacheSite,
} from "../../../../modules/cache/opportunityCache";
import { generatedQuotes } from "../../../../managers/generatedQuotes";
/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */
@@ -31,102 +18,15 @@ export default createRoute(
includeParam
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
.filter(Boolean)
);
// ── Quick DB lookup (≈3ms) to get cwOpportunityId for pre-warming ──
const isNumeric = /^\d+$/.test(identifier);
const dbRecord = await prisma.opportunity.findFirst({
where: isNumeric
? { cwOpportunityId: Number(identifier) }
: { id: identifier },
select: {
cwOpportunityId: true,
companyCwId: true,
siteCwId: true,
closedFlag: true,
closedDate: true,
expectedCloseDate: true,
cwLastUpdated: true,
statusCwId: true,
},
});
// Fetch the opportunity from local DB
const item = await opportunities.fetchItem(identifier);
if (!dbRecord) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
console.log("Fetched opportunity:", item ? item.toJson() : null);
// Compute TTLs from DB state
const subTtl = computeSubResourceCacheTTL({
closedFlag: dbRecord.closedFlag,
closedDate: dbRecord.closedDate,
expectedCloseDate: dbRecord.expectedCloseDate,
lastUpdated: dbRecord.cwLastUpdated,
});
const prodTtl = computeProductsCacheTTL({
closedFlag: dbRecord.closedFlag,
closedDate: dbRecord.closedDate,
expectedCloseDate: dbRecord.expectedCloseDate,
lastUpdated: dbRecord.cwLastUpdated,
statusCwId: dbRecord.statusCwId,
});
// ── Pre-warm sub-resources only on cache miss ───────────────────────
// Check Redis first — if the background refresh has kept the keys warm,
// skip the CW calls entirely. Only fetch-and-cache on a miss.
const cwOppId = dbRecord.cwOpportunityId;
const _ignoreErrors = (p: Promise<any>) => p.catch(() => {});
const prewarmPromises: Promise<any>[] = [];
if (dbRecord.companyCwId && dbRecord.siteCwId) {
const compId = dbRecord.companyCwId,
siteId = dbRecord.siteCwId;
prewarmPromises.push(
_ignoreErrors(
getCachedSite(compId, siteId).then(
(c) => c ?? fetchAndCacheSite(compId, siteId),
),
),
);
}
if (includes.has("notes") && subTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedNotes(cwOppId).then(
(c) => c ?? fetchAndCacheNotes(cwOppId, subTtl),
),
),
);
if (includes.has("contacts") && subTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedContacts(cwOppId).then(
(c) => c ?? fetchAndCacheContacts(cwOppId, subTtl),
),
),
);
if (includes.has("products") && prodTtl)
prewarmPromises.push(
_ignoreErrors(
getCachedProducts(cwOppId).then(
(c) => c ?? fetchAndCacheProducts(cwOppId, prodTtl),
),
),
);
// fetchItem runs its own CW calls (opp, activities, company) —
// these execute concurrently with the sub-resource pre-warming above.
const [item] = await Promise.all([
opportunities.fetchItem(identifier),
...prewarmPromises,
]);
// Sub-resources now hit warm Redis cache (near-instant)
// Fetch sub-resources as requested
const subResourcePromises: Record<string, Promise<any>> = {
_site: item.fetchSite(),
};
@@ -147,7 +47,7 @@ export default createRoute(
subResourcePromises.quotes = generatedQuotes
.fetchByOpportunity(item.id)
.then((quotes) =>
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams })),
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams }))
);
}
@@ -158,7 +58,7 @@ export default createRoute(
const gatedData = await processObjectValuePerms(
item.toJson(),
"obj.opportunity",
c.get("user"),
c.get("user")
);
const originalOpportunityNoteText = (gatedData as any).notes;
@@ -179,9 +79,9 @@ export default createRoute(
const response = apiResponse.successful(
"Opportunity fetched successfully!",
gatedData,
gatedData
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
authMiddleware({ permissions: ["sales.opportunity.fetch"] })
);
@@ -3,9 +3,23 @@ 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 { prisma } from "../../../../../constants";
import { z } from "zod";
// Helper to resolve member from DB
async function resolveMember(identifier: string | null | undefined) {
if (!identifier) return null;
const member = await prisma.cwMember.findFirst({ where: { identifier } });
return member
? {
id: member.id,
identifier: member.identifier,
name: `${member.firstName} ${member.lastName}`.trim(),
cwMemberId: member.cwMemberId,
}
: { id: null, identifier, name: identifier, cwMemberId: null };
}
/* POST /v1/sales/opportunities/opportunity/:identifier/notes */
export default createRoute(
"post",
@@ -38,10 +52,10 @@ export default createRoute(
: null,
flagged: created.flagged,
enteredBy: await resolveMember(created.enteredBy),
},
}
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.note.create"] }),
authMiddleware({ permissions: ["sales.opportunity.note.create"] })
);
@@ -4,9 +4,23 @@ 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 { prisma } from "../../../../../constants";
import { z } from "zod";
// Helper to resolve member from DB
async function resolveMember(identifier: string | null | undefined) {
if (!identifier) return null;
const member = await prisma.cwMember.findFirst({ where: { identifier } });
return member
? {
id: member.id,
identifier: member.identifier,
name: `${member.firstName} ${member.lastName}`.trim(),
cwMemberId: member.cwMemberId,
}
: { id: null, identifier, name: identifier, cwMemberId: null };
}
/* PATCH /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
export default createRoute(
"patch",
@@ -48,10 +62,10 @@ export default createRoute(
: null,
flagged: updated.flagged,
enteredBy: await resolveMember(updated.enteredBy),
},
}
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.note.update"] }),
authMiddleware({ permissions: ["sales.opportunity.note.update"] })
);
@@ -4,6 +4,8 @@ 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 { ForecastProductController } from "../../../../../controllers/ForecastProductController";
import { opportunityCw } from "../../../../../modules/cw-utils/opportunities/opportunities";
import { z } from "zod";
const productItemSchema = z
@@ -26,6 +28,8 @@ const productItemSchema = z
recurringCost: z.number().optional(),
cycles: z.number().int().min(0).optional(),
sequenceNumber: z.number().int().min(0).optional(),
procurementNotes: z.string().optional(),
productNarrative: z.string().optional(),
})
.strict();
@@ -34,12 +38,25 @@ const addProductSchema = z.union([
z.array(productItemSchema).min(1, "At least one product is required"),
]);
const procurementCreateSchema = z.object({
catalogItem: z.object({ id: z.number().int().positive() }),
description: z.string().min(1),
customerDescription: z.string().optional(),
quantity: z.number().positive().optional(),
price: z.number().optional(),
cost: z.number().optional(),
taxableFlag: z.boolean().optional(),
billableOption: z.string().optional(),
procurementNotes: z.string().optional(),
productNarrative: z.string().optional(),
});
/* POST /v1/sales/opportunities/opportunity/:identifier/products */
export default createRoute(
"post",
["/opportunities/opportunity/:identifier/products"],
async (c) => {
const identifier = c.req.param("identifier");
const identifier = c.req.param("identifier") as string;
const body = await c.req.json();
const validated = addProductSchema.parse(body);
@@ -56,46 +73,182 @@ export default createRoute(
const item = await opportunities.fetchRecord(identifier);
// Strip customerDescription from forecast payloads — CW only accepts
// it on procurement products, not forecast items.
const customerDescriptions = gatedItems.map(
(g: any) => g.customerDescription,
);
const forecastPayloads = gatedItems.map(
({ customerDescription, ...rest }: any) => rest,
);
// Procurement-first: when catalogItem is available after field-level
// permission gating, create through procurement.
// Fallback: if catalogItem is gated/missing, use forecast creation so
// callers without catalog permissions can still add products.
const procurementInputs: Array<{
index: number;
payload: z.infer<typeof procurementCreateSchema>;
}> = [];
const forecastInputs: Array<{
index: number;
payload: Record<string, unknown>;
customerDescription?: string;
}> = [];
const created = await item.addProducts(forecastPayloads);
for (const [index, g] of gatedItems.entries()) {
const hasCatalogItem =
typeof g?.catalogItem?.id === "number" && g.catalogItem.id > 0;
// If any items included customerDescription, patch the linked
// procurement products after creation. This is best-effort since
// newly created forecast items may not have a linked procurement
// product yet.
const procurementUpdates = created
.map((product, idx) => ({
product,
customerDescription: customerDescriptions[idx],
}))
.filter((entry) => entry.customerDescription != null);
if (!hasCatalogItem) {
const { customerDescription, ...forecastPayload } = g as any;
const normalizedForecastPayload: Record<string, unknown> = {
...forecastPayload,
};
if (procurementUpdates.length > 0) {
await Promise.all(
procurementUpdates.map(({ product, customerDescription }) =>
item
.updateProcurementProductByForecastItem(product.cwForecastId, {
customerDescription,
})
.catch(() => null),
),
);
const hasAnyDescription =
typeof normalizedForecastPayload.forecastDescription === "string" ||
typeof normalizedForecastPayload.productDescription === "string";
// CW rejects bare forecast objects with only defaults (status/type).
// Ensure a minimal description exists for permission-reduced payloads.
if (!hasAnyDescription) {
normalizedForecastPayload.forecastDescription = "Line Item";
}
forecastInputs.push({
index,
payload: normalizedForecastPayload,
customerDescription:
typeof customerDescription === "string"
? customerDescription
: undefined,
});
continue;
}
const quantity =
typeof g.quantity === "number" && g.quantity > 0 ? g.quantity : undefined;
const totalRevenue =
typeof g.revenue === "number" && Number.isFinite(g.revenue)
? g.revenue
: undefined;
const totalCost =
typeof g.cost === "number" && Number.isFinite(g.cost)
? g.cost
: undefined;
const price =
totalRevenue === undefined
? undefined
: quantity && quantity > 0
? totalRevenue / quantity
: totalRevenue;
const cost =
totalCost === undefined
? undefined
: quantity && quantity > 0
? totalCost / quantity
: totalCost;
procurementInputs.push({
index,
payload: procurementCreateSchema.parse({
catalogItem: g.catalogItem,
description:
g.productDescription ??
g.forecastDescription ??
g.customerDescription ??
"Line Item",
customerDescription:
typeof g.customerDescription === "string"
? g.customerDescription
: undefined,
quantity,
price,
cost,
taxableFlag:
typeof g.taxableFlag === "boolean" ? g.taxableFlag : undefined,
billableOption: "Billable",
procurementNotes:
typeof (g as any).procurementNotes === "string"
? (g as any).procurementNotes
: undefined,
productNarrative:
typeof (g as any).productNarrative === "string"
? (g as any).productNarrative
: undefined,
}),
});
}
const createdByIndex = new Map<number, ForecastProductController>();
if (procurementInputs.length > 0) {
const createdProcurement = await item.addProcurementProducts(
procurementInputs.map((entry) => entry.payload),
);
const indexByForecastId = new Map<number, number>();
for (const [createdIdx, proc] of createdProcurement.entries()) {
if (typeof proc.forecastDetailId === "number") {
indexByForecastId.set(
proc.forecastDetailId,
procurementInputs[createdIdx]!.index,
);
}
}
if (indexByForecastId.size > 0) {
const createdForecastItems =
(await opportunityCw.fetchProducts(item.cwOpportunityId)).forecastItems?.filter(
(fi) => indexByForecastId.has(fi.id),
) ?? [];
for (const fi of createdForecastItems) {
const originalIndex = indexByForecastId.get(fi.id);
if (typeof originalIndex === "number") {
createdByIndex.set(originalIndex, new ForecastProductController(fi));
}
}
}
}
if (forecastInputs.length > 0) {
const createdForecast = await item.addProducts(
forecastInputs.map((entry) => entry.payload),
);
for (const [createdIdx, createdItem] of createdForecast.entries()) {
const input = forecastInputs[createdIdx]!;
createdByIndex.set(input.index, createdItem);
}
const procurementUpdates = createdForecast
.map((product, idx) => ({
product,
customerDescription: forecastInputs[idx]?.customerDescription,
}))
.filter((entry) => entry.customerDescription != null);
if (procurementUpdates.length > 0) {
await Promise.all(
procurementUpdates.map(({ product, customerDescription }) =>
item
.updateProcurementProductByForecastItem(product.cwForecastId, {
customerDescription,
})
.catch(() => null),
),
);
}
}
const created = inputItems
.map((_, index) => createdByIndex.get(index))
.filter(
(entry): entry is ForecastProductController => entry !== undefined,
);
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(),
isBatch
? created.map((p) => p.toJson())
: created[0]?.toJson() ?? null,
);
return c.json(response, response.status as ContentfulStatusCode);
},
@@ -8,7 +8,7 @@ import { z } from "zod";
const cancelProductSchema = z
.object({
quantityCancelled: z.number().int().min(0),
quantityCancelled: z.number().min(0),
cancellationReason: z.string().nullable().optional(),
})
.strict();
@@ -4,6 +4,9 @@ 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 { ForecastProductController } from "../../../../../controllers/ForecastProductController";
import { opportunityCw } from "../../../../../modules/cw-utils/opportunities/opportunities";
import { prisma } from "../../../../../constants";
import { z } from "zod";
const PRODUCT_NARRATIVE_FIELD_ID = 46;
@@ -24,14 +27,14 @@ const updateProductSchema = z
.refine(
(value) =>
Object.values(value).some((item) => item !== undefined && item !== null),
"At least one editable field is required",
"At least one editable field is required"
);
const upsertCustomTextField = (
fields: Array<Record<string, unknown>>,
fieldId: number,
caption: string,
value: string,
value: string
) => {
const next = [...fields];
const idx = next.findIndex((f) => Number(f.id) === fieldId);
@@ -76,12 +79,47 @@ export default createRoute(
const input = updateProductSchema.parse(body);
const opportunity = await opportunities.fetchRecord(identifier);
const forecastItems = await opportunity.fetchProducts();
const forecastItem = forecastItems.find(
(item) => item.cwForecastId === productId,
const cwForecast = await opportunityCw.fetchProducts(
opportunity.cwOpportunityId
);
const cwForecastItems = cwForecast.forecastItems ?? [];
const cwForecastIds = new Set(cwForecastItems.map((item) => item.id));
let forecastItemId = productId;
if (!cwForecastIds.has(forecastItemId)) {
const procurementItems = await opportunityCw.fetchProcurementProducts(
opportunity.cwOpportunityId
);
const matchedProcurement = procurementItems.find(
(item) => Number((item as any).id) === productId
);
const mappedForecastDetailId = Number(
(matchedProcurement as any)?.forecastDetailId
);
if (
Number.isInteger(mappedForecastDetailId) &&
mappedForecastDetailId > 0 &&
cwForecastIds.has(mappedForecastDetailId)
) {
forecastItemId = mappedForecastDetailId;
console.warn(
"[ProductUpdate] Resolved procurement product ID to forecast item ID",
{
opportunity: identifier,
requestedProductId: productId,
resolvedForecastItemId: forecastItemId,
}
);
}
}
const rawForecastItem = cwForecastItems.find(
(item) => item.id === forecastItemId
);
if (!forecastItem) {
if (!rawForecastItem) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
@@ -89,6 +127,7 @@ export default createRoute(
});
}
const forecastItem = new ForecastProductController(rawForecastItem);
const forecastJson = forecastItem.toJson();
const effectiveQuantity = input.quantity ?? forecastJson.quantity ?? 1;
@@ -101,12 +140,12 @@ export default createRoute(
}
if (input.unitPrice !== undefined) {
forecastPatch.revenue = Number(
(input.unitPrice * effectiveQuantity).toFixed(2),
(input.unitPrice * effectiveQuantity).toFixed(2)
);
}
if (input.unitCost !== undefined) {
forecastPatch.cost = Number(
(input.unitCost * effectiveQuantity).toFixed(2),
(input.unitCost * effectiveQuantity).toFixed(2)
);
}
if (input.taxableFlag !== undefined) {
@@ -114,19 +153,22 @@ export default createRoute(
}
const existingProcurement =
await opportunity.fetchProcurementProductByForecastItem(productId);
await opportunity.fetchProcurementProductByForecastItem(forecastItemId);
if (
(input.productNarrative !== undefined ||
input.procurementNotes !== undefined) &&
!existingProcurement
) {
throw new GenericError({
status: 400,
name: "ProcurementLinkRequired",
message:
"Product Narrative and Procurement Notes can only be updated on products linked to a procurement record",
});
const hasNarrativeOrNotesValue =
(input.productNarrative !== undefined &&
input.productNarrative !== null) ||
(input.procurementNotes !== undefined && input.procurementNotes !== null);
if (hasNarrativeOrNotesValue && !existingProcurement) {
console.warn(
"[ProductUpdate] Ignoring procurement-only narrative fields for non-linked product",
{
opportunity: identifier,
productId: forecastItemId,
requestedProductId: productId,
}
);
}
let updatedProcurement = existingProcurement;
@@ -164,7 +206,7 @@ export default createRoute(
updatedFields,
PROCUREMENT_NOTES_FIELD_ID,
"Procurement Notes",
input.procurementNotes,
input.procurementNotes
);
}
if (
@@ -175,7 +217,7 @@ export default createRoute(
updatedFields,
PRODUCT_NARRATIVE_FIELD_ID,
"Product Narrative",
input.productNarrative,
input.productNarrative
);
}
if (
@@ -190,28 +232,47 @@ export default createRoute(
if (Object.keys(procurementPatch).length > 0) {
updatedProcurement =
await opportunity.updateProcurementProductByForecastItem(
productId,
procurementPatch,
forecastItemId,
procurementPatch
);
}
}
let updatedForecast = forecastJson;
if (Object.keys(forecastPatch).length > 0) {
const patched = await opportunity.updateProduct(productId, forecastPatch);
const patched = await opportunity.updateProduct(
forecastItemId,
forecastPatch
);
updatedForecast = patched.toJson();
}
// Write changed fields back to the local ProductData row so fetchProducts()
// reflects the edit immediately without waiting for the next dalpuri sync.
const localPatch: Record<string, unknown> = {};
if (input.quantity !== undefined) localPatch.qty = input.quantity;
if (input.unitPrice !== undefined) localPatch.unitPrice = input.unitPrice;
if (input.unitCost !== undefined) localPatch.unitCost = input.unitCost;
if (input.productDescription !== undefined) localPatch.shortDescription = input.productDescription;
if (input.taxableFlag !== undefined) localPatch.taxableFlag = input.taxableFlag;
if (input.productNarrative !== undefined) localPatch.productNarrative = input.productNarrative;
if (Object.keys(localPatch).length > 0) {
await prisma.productData.update({
where: { id: productId },
data: localPatch,
});
}
const updatedFields = Array.isArray(updatedProcurement?.customFields)
? updatedProcurement.customFields
: [];
const procurementNotes =
updatedFields.find(
(field: any) => field?.id === PROCUREMENT_NOTES_FIELD_ID,
(field: any) => field?.id === PROCUREMENT_NOTES_FIELD_ID
)?.value ?? null;
const productNarrative =
updatedFields.find(
(field: any) => field?.id === PRODUCT_NARRATIVE_FIELD_ID,
(field: any) => field?.id === PRODUCT_NARRATIVE_FIELD_ID
)?.value ?? null;
const quantity =
@@ -230,11 +291,13 @@ export default createRoute(
quantity,
unitPrice,
unitCost,
requestedProductId: productId,
forecastItemId,
procurementNotes,
productNarrative,
});
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
authMiddleware({ permissions: ["sales.opportunity.product.update"] })
);
@@ -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/opportunity/:identifier/quotes/narrative */
export default createRoute(
"get",
["/opportunities/opportunity/:identifier/quotes/narrative"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchRecord(identifier);
const quoteNarrative = await item.fetchQuoteNarrative();
const response = apiResponse.successful(
"Quote narrative fetched successfully!",
{
quoteNarrative,
}
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] })
);
@@ -53,9 +53,9 @@ export default createRoute(
{
mimeType: "application/pdf",
contentBase64: previewBase64,
},
}
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.quote.preview"] }),
authMiddleware({ permissions: ["sales.opportunity.quote.preview"] })
);
@@ -0,0 +1,38 @@
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";
const quoteNarrativeSchema = z
.object({
quoteNarrative: z.string().optional().nullable(),
})
.strict();
/* PATCH /v1/sales/opportunities/opportunity/:identifier/quotes/narrative */
export default createRoute(
"patch",
["/opportunities/opportunity/:identifier/quotes/narrative"],
async (c) => {
const identifier = c.req.param("identifier");
const body = quoteNarrativeSchema.parse(
await c.req.json().catch(() => ({}))
);
const item = await opportunities.fetchRecord(identifier);
const quoteNarrative = await item.updateQuoteNarrative(
body.quoteNarrative ?? null
);
const response = apiResponse.successful(
"Quote narrative saved successfully!",
{
quoteNarrative,
}
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.update"] })
);
@@ -10,6 +10,7 @@ const updateSchema = z
.object({
name: z.string().min(1).optional(),
notes: z.string().optional(),
interest: z.enum(["HOT", "WARM", "COLD"]).nullable().optional(),
rating: z.object({ id: z.number() }).optional(),
type: z.object({ id: z.number() }).optional(),
stage: z.object({ id: z.number() }).optional(),
@@ -50,7 +51,7 @@ export default createRoute(
const response = apiResponse.successful(
"Opportunity updated successfully!",
updated.toJson(),
updated.toJson()
);
return c.json(response, response.status as ContentfulStatusCode);
@@ -77,7 +78,7 @@ export default createRoute(
errors: cwErrors,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
cwStatus as ContentfulStatusCode
);
}
@@ -89,5 +90,5 @@ export default createRoute(
});
}
},
authMiddleware({ permissions: ["sales.opportunity.update"] }),
authMiddleware({ permissions: ["sales.opportunity.update"] })
);
+16 -5
View File
@@ -6,6 +6,7 @@ import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { z } from "zod";
import { cwMembers } from "../../../managers/cwMembers";
import { prisma } from "../../../constants";
import {
createWorkflowActivity,
OptimaType,
@@ -18,6 +19,7 @@ const createSchema = z.object({
.min(1)
.transform((v) => new Date(v).toISOString().replace(/\.\d{3}Z$/, "Z")),
notes: z.string().optional(),
interest: z.enum(["HOT", "WARM", "COLD"]).nullable().optional(),
rating: z.object({ id: z.number() }).optional(),
type: z.object({ id: z.number() }).optional(),
stage: z.object({ id: z.number() }).optional(),
@@ -42,9 +44,18 @@ export default createRoute(
async (c) => {
const body = await c.req.json();
const data = createSchema.parse(body);
const { interest, ...cwCreateData } = data;
try {
const item = await opportunities.createItem(data);
const item = await opportunities.createItem(cwCreateData);
if (interest !== undefined) {
await prisma.opportunity.update({
where: { uid: item.id },
data: { interest },
});
item.interest = interest;
}
// Create a workflow activity for the new opportunity
try {
@@ -69,14 +80,14 @@ export default createRoute(
} catch (activityErr) {
console.error(
"[Opportunity Create] Failed to create workflow activity:",
activityErr,
activityErr
);
// Don't fail the opportunity creation if the activity fails
}
const response = apiResponse.created(
"Opportunity created successfully!",
item.toJson(),
item.toJson()
);
return c.json(response, response.status as ContentfulStatusCode);
@@ -103,7 +114,7 @@ export default createRoute(
errors: cwErrors,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
cwStatus as ContentfulStatusCode
);
}
@@ -115,5 +126,5 @@ export default createRoute(
});
}
},
authMiddleware({ permissions: ["sales.opportunity.create"] }),
authMiddleware({ permissions: ["sales.opportunity.create"] })
);
+28 -61
View File
@@ -2,12 +2,8 @@ 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 GenericError from "../../../Errors/GenericError";
import {
getSalesOpportunityMetricsAll,
getSalesOpportunityMetricsForMember,
refreshSalesOpportunityMetricsCache,
} from "../../../modules/cache/salesOpportunityMetricsCache";
import { getSalesOpportunityMetricsForMember, getSalesOpportunityMetricsAll } from "../../../modules/cache/salesOpportunityMetricsCache";
import { prisma } from "../../../constants";
/* GET /v1/sales/opportunities/metrics */
export default createRoute(
@@ -15,60 +11,39 @@ export default createRoute(
["/opportunities/metrics"],
async (c) => {
const user = c.get("user");
const scope = (c.req.query("scope") ?? "me").toLowerCase();
const requestedIdentifier = c.req.query("identifier")?.trim().toLowerCase();
const currentUserIdentifier = user?.cwIdentifier?.trim().toLowerCase();
if (
scope === "all" &&
!(await user.hasPermission("sales.opportunity.metrics.all"))
) {
throw new GenericError({
name: "InsufficientPermission",
message:
"You do not have permission to view metrics for all active members.",
status: 403,
});
}
const usingIdentifierOverride =
scope !== "all" &&
!!requestedIdentifier &&
requestedIdentifier !== currentUserIdentifier;
if (
usingIdentifierOverride &&
!(await user.hasPermission(
"sales.opportunity.metrics.identifier.override",
))
) {
throw new GenericError({
name: "InsufficientPermission",
message:
"You do not have permission to query metrics by overriding the member identifier.",
status: 403,
});
}
const requireWarmCache = async () => {
const all = await getSalesOpportunityMetricsAll();
if (all) return all;
await refreshSalesOpportunityMetricsCache();
return getSalesOpportunityMetricsAll();
};
const scope = c.req.query("scope");
const identifierOverride = c.req.query("identifier");
// scope=all — return metrics for all active members (requires permission)
if (scope === "all") {
const all = await requireWarmCache();
if (!(await user.hasPermission("sales.opportunity.metrics.all"))) {
return c.json({ status: 403, message: "Forbidden", successful: false }, 403);
}
const allMetrics = await getSalesOpportunityMetricsAll();
const response = apiResponse.successful(
"Sales opportunity metrics fetched successfully!",
all,
allMetrics,
);
return c.json(response, response.status as ContentfulStatusCode);
}
const targetIdentifier = requestedIdentifier ?? currentUserIdentifier;
// Determine which CW identifier to look up
let cwIdentifier: string | null = null;
if (!targetIdentifier) {
if (identifierOverride) {
if (!(await user.hasPermission("sales.opportunity.metrics.identifier.override"))) {
return c.json({ status: 403, message: "Forbidden", successful: false }, 403);
}
cwIdentifier = identifierOverride;
} else {
const dbUser = await prisma.user.findFirst({
where: { id: user.id },
select: { cwIdentifier: true },
});
cwIdentifier = dbUser?.cwIdentifier ?? null;
}
if (!cwIdentifier) {
const response = apiResponse.successful(
"Sales opportunity metrics fetched successfully!",
null,
@@ -76,18 +51,10 @@ export default createRoute(
return c.json(response, response.status as ContentfulStatusCode);
}
let metrics = await getSalesOpportunityMetricsForMember(targetIdentifier);
if (!metrics) {
await refreshSalesOpportunityMetricsCache();
metrics = await getSalesOpportunityMetricsForMember(targetIdentifier);
}
const metrics = await getSalesOpportunityMetricsForMember(cwIdentifier);
const response = apiResponse.successful(
"Sales opportunity metrics fetched successfully!",
{
identifier: targetIdentifier,
metrics,
},
metrics,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+22
View File
@@ -0,0 +1,22 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { schedules } from "../../../managers/schedules";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
/* /v1/schedule/schedules/:identifier */
export default createRoute(
"get",
["/schedules/:identifier"],
async (c) => {
const schedule = await schedules.fetch(c.req.param("identifier"));
const response = apiResponse.successful(
"Schedule Fetched Successfully!",
schedule.toJson(),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["schedule.fetch"] }),
);
+22
View File
@@ -0,0 +1,22 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { schedules } from "../../managers/schedules";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* /v1/schedule/count */
export default createRoute(
"get",
["/count"],
async (c) => {
const count = await schedules.count();
const response = apiResponse.successful(
"Schedule count fetched successfully!",
{ count },
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["schedule.fetch.many"] }),
);
+42
View File
@@ -0,0 +1,42 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { schedules } from "../../managers/schedules";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* /v1/schedule/schedules */
export default createRoute(
"get",
["/schedules"],
async (c) => {
const page = new Number(c.req.query("page") ?? 1) as number;
const rpp = new Number(c.req.query("rpp") ?? 30) as number;
const search = c.req.query("search") as string;
const data = search
? await schedules.search(search, page, rpp)
: await schedules.fetchPages(page, rpp);
const totalRecords = search
? (await schedules.search(search, 1, 999999)).length
: await schedules.count();
const response = apiResponse.successful(
"Schedules Fetched Successfully!",
data.map((s) => s.toJson()),
{
pagination: {
previousPage: page == 1 ? null : page - 1,
currentPage: page,
nextPage: page >= totalRecords / rpp ? null : page + 1,
totalPages: Math.ceil(totalRecords / rpp),
totalRecords,
listedRecords: rpp,
},
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["schedule.fetch.many"] }),
);
+7
View File
@@ -0,0 +1,7 @@
import { default as fetchAll } from "./fetchAll";
import { default as fetch } from "./[id]/fetch";
import { default as count } from "./count";
import { default as fetchByDateRange } from "./member/fetchByDateRange";
import { default as fetchMySchedule } from "./me/fetchMySchedule";
export { count, fetch, fetchAll, fetchByDateRange, fetchMySchedule };
@@ -0,0 +1,63 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { schedules } from "../../../managers/schedules";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
/* /v1/schedule/@me?start=<ISO>&end=<ISO> */
export default createRoute(
"get",
["/@me"],
async (c) => {
const user = c.get("user");
if (!user?.cwIdentifier) {
throw new GenericError({
name: "BadRequest",
message: "Your account is not linked to a ConnectWise member. Cannot fetch schedule.",
status: 400,
});
}
const startParam = c.req.query("start");
const endParam = c.req.query("end");
if (!startParam || !endParam) {
throw new GenericError({
name: "BadRequest",
message: "Query params 'start' and 'end' are required (ISO 8601 date strings).",
status: 400,
});
}
const startDate = new Date(startParam);
const endDate = new Date(endParam);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new GenericError({
name: "BadRequest",
message: "Invalid date format. Use ISO 8601 (e.g. 2026-04-01T00:00:00Z).",
status: 400,
});
}
if (startDate >= endDate) {
throw new GenericError({
name: "BadRequest",
message: "'start' must be before 'end'.",
status: 400,
});
}
const data = await schedules.fetchByMemberDateRange(user.cwIdentifier, startDate, endDate);
const response = apiResponse.successful(
"Your schedule entries fetched successfully!",
data.map((s) => s.toJson()),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["schedule.fetch"] }),
);
@@ -0,0 +1,62 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { schedules } from "../../../managers/schedules";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
/* /v1/schedule/member/:memberId?start=<ISO>&end=<ISO> */
export default createRoute(
"get",
["/member/:memberId"],
async (c) => {
const memberId = c.req.param("memberId");
const startParam = c.req.query("start");
const endParam = c.req.query("end");
if (!memberId) {
throw new GenericError({
name: "BadRequest",
message: "memberId path parameter is required.",
status: 400,
});
}
if (!startParam || !endParam) {
throw new GenericError({
name: "BadRequest",
message: "Query params 'start' and 'end' are required (ISO 8601 date strings).",
status: 400,
});
}
const startDate = new Date(startParam);
const endDate = new Date(endParam);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new GenericError({
name: "BadRequest",
message: "Invalid date format. Use ISO 8601 (e.g. 2026-04-01T00:00:00Z).",
status: 400,
});
}
if (startDate >= endDate) {
throw new GenericError({
name: "BadRequest",
message: "'start' must be before 'end'.",
status: 400,
});
}
const data = await schedules.fetchByMemberDateRange(memberId, startDate, endDate);
const response = apiResponse.successful(
"Schedule entries fetched successfully!",
data.map((s) => s.toJson()),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["schedule.fetch.many"] }),
);
+26 -13
View File
@@ -4,6 +4,18 @@ import { ZodError } from "zod";
import { cors } from "hono/cors";
import GenericError from "../Errors/GenericError";
import teapot from "./teapot";
import authRouter from "./routers/authRouter";
import userRouter from "./routers/user";
import companyRouter from "./routers/companyRouter";
import credentialRouter from "./routers/credentialRouter";
import credentialTypeRouter from "./routers/credentialTypeRouter";
import roleRouter from "./routers/roleRouter";
import permissionRouter from "./routers/permissionRouter";
import unifiRouter from "./routers/unifiRouter";
import procurementRouter from "./routers/procurementRouter";
import salesRouter from "./routers/salesRouter";
import cwRouter from "./routers/cwRouter";
import scheduleRouter from "./routers/scheduleRouter";
const app = new Hono();
const v1 = new Hono();
@@ -24,7 +36,7 @@ app.onError((err, ctx) => {
return ctx.json(
apiResponse.zodError(err),
//@ts-ignore
apiResponse.zodError(err).status,
apiResponse.zodError(err).status
);
}
@@ -41,23 +53,24 @@ app.notFound((c) => {
message: `Cannot ${c.req.method.toUpperCase()} ${c.req.path}`,
status: 404,
cause: "Unknown",
}),
})
);
return c.json(response, response.status);
});
v1.route("/teapot", teapot);
v1.route("/auth", require("./routers/authRouter").default);
v1.route("/user", require("./routers/user").default);
v1.route("/company", require("./routers/companyRouter").default);
v1.route("/credential", require("./routers/credentialRouter").default);
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);
v1.route("/cw", require("./routers/cwRouter").default);
v1.route("/auth", authRouter);
v1.route("/user", userRouter);
v1.route("/company", companyRouter);
v1.route("/credential", credentialRouter);
v1.route("/credential-type", credentialTypeRouter);
v1.route("/role", roleRouter);
v1.route("/permissions", permissionRouter);
v1.route("/unifi", unifiRouter);
v1.route("/procurement", procurementRouter);
v1.route("/sales", salesRouter);
v1.route("/cw", cwRouter);
v1.route("/schedule", scheduleRouter);
app.route("/v1", v1);
export default app;
@@ -2,7 +2,7 @@ import { Socket } from "socket.io";
import { attachSocketEventPermissions } from "../middleware/authorization";
import { opportunities } from "../../../managers/opportunities";
const LIVE_QUOTE_PREVIEW_PERMISSION = "sales.opportunity.fetch";
const LIVE_QUOTE_PREVIEW_PERMISSION = "sales.opportunity.quote.preview";
export const registerLiveQuotePreviewHandlers = (socket: Socket) => {
attachSocketEventPermissions(socket, {
@@ -15,7 +15,7 @@ export const registerLiveQuotePreviewHandlers = (socket: Socket) => {
"opp:live_quote_preview",
async (
payload: { id?: string | number },
ack?: (response: { ok: boolean; event?: string; error?: string }) => void,
ack?: (response: { ok: boolean; event?: string; error?: string }) => void
) => {
const oppId = payload?.id;
const normalizedId =
@@ -97,6 +97,6 @@ export const registerLiveQuotePreviewHandlers = (socket: Socket) => {
id: normalizedId,
event: dataEvent,
});
},
}
);
};
+4 -6
View File
@@ -15,17 +15,15 @@ export default createRoute(
const processWlans = await Promise.all(
wlans.map((wlan) =>
processObjectValuePerms(wlan, "unifi.site.wifi.read", c.get("user")),
),
processObjectValuePerms(wlan, "unifi.site.wifi.read", c.get("user"))
)
);
console.log(processWlans);
const response = apiResponse.successful(
"UniFi WiFi Networks Fetched Successfully!",
processWlans,
processWlans
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["unifi.access", "unifi.site.wifi"] }),
authMiddleware({ permissions: ["unifi.access", "unifi.site.wifi"] })
);
+4 -2
View File
@@ -7,6 +7,8 @@ import { authMiddleware } from "../../middleware/authorization";
const updateSchema = z
.object({
name: z.string().optional(),
firstName: z.string().nullable().optional(),
lastName: z.string().nullable().optional(),
image: z.string().optional(),
})
.strict();
@@ -19,10 +21,10 @@ export default createRoute(
const updatedUser = await c.get("user")?.update(body);
const response = apiResponse.successful(
"Successfully updated user.",
updatedUser?.toJson(),
updatedUser?.toJson()
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ scopes: ["user.write"] }),
authMiddleware({ scopes: ["user.write"] })
);
+4 -2
View File
@@ -9,6 +9,8 @@ import GenericError from "../../Errors/GenericError";
const updateSchema = z
.object({
name: z.string().optional(),
firstName: z.string().nullable().optional(),
lastName: z.string().nullable().optional(),
image: z.string().optional(),
roles: z.array(z.string()).optional(),
permissions: z.array(z.string()).optional(),
@@ -60,9 +62,9 @@ export default createRoute(
const response = apiResponse.successful(
"User Updated Successfully!",
user.toJson(),
user.toJson()
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["user.write.other"] }),
authMiddleware({ permissions: ["user.write.other"] })
);
-25
View File
@@ -4,7 +4,6 @@ import { Prisma, PrismaClient } from "../generated/prisma/client";
import * as msal from "@azure/msal-node";
import { Server } from "socket.io";
import { Server as Engine } from "@socket.io/bun-engine";
import { io as createSocketClient } from "socket.io-client";
import axios from "axios";
import { UnifiClient } from "./modules/unifi-api/UnifiClient";
import { attachCwApiLogger } from "./modules/cw-utils/cwApiLogger";
@@ -13,18 +12,11 @@ import Redis from "ioredis";
const connectionString = `${process.env.DATABASE_URL}`;
const adapter = new PrismaPg({ connectionString });
interface EnvKey {
PORT: number;
}
// ENV CONSTANTS
export const PORT = process.env.PORT;
export const API_BASE_URL =
process.env.API_BASE_URL || `http://localhost:${PORT || 3000}`;
export const COLLECTOR_WS_URL =
process.env.COLLECTOR_WS_URL || "http://localhost:7204";
export const COLLECTOR_PSK = process.env.COLLECTOR_PSK || "";
export const prisma = new PrismaClient({ adapter });
@@ -77,23 +69,6 @@ const engine = new Engine();
io.bind(engine);
export { io, engine };
export const collectorSocket = createSocketClient(COLLECTOR_WS_URL, {
autoConnect: true,
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
transports: ["websocket"],
auth: COLLECTOR_PSK ? { psk: COLLECTOR_PSK } : undefined,
});
export const connectCollectorSocket = () => {
if (collectorSocket.connected) {
return;
}
collectorSocket.connect();
};
// Connectwise API Client
const connectWiseApi = axios.create({
-21
View File
@@ -5,7 +5,6 @@ import {
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
@@ -127,26 +126,6 @@ export class ActivityController {
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
*
+46 -21
View File
@@ -1,9 +1,27 @@
import { CatalogItem } from "../../generated/prisma/client";
import {
CatalogItem,
CatalogCategory,
CatalogSubcategory,
CatalogManufacturer,
} 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";
/**
* Shape of the Prisma result when catalog items are queried with
* `include: { linkedItems, manufacturer, subcategory: { include: { category } } }`.
*/
type CatalogItemWithRelations = CatalogItem & {
linkedItems?: (CatalogItem & {
manufacturer?: CatalogManufacturer | null;
subcategory?: CatalogSubcategory & { category?: CatalogCategory | null };
})[];
manufacturer?: CatalogManufacturer | null;
subcategory?: (CatalogSubcategory & { category?: CatalogCategory | null }) | null;
};
/**
* Catalog Item Controller
*
@@ -12,13 +30,15 @@ import GenericError from "../Errors/GenericError";
* the internal database representation with ConnectWise catalog data.
*/
export class CatalogItemController {
/** The ConnectWise catalog record ID (`CatalogItem.id` in Prisma — Int @unique). */
public readonly cwCatalogId: number;
/** The Prisma primary key (UUID). */
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;
@@ -48,24 +68,29 @@ export class CatalogItemController {
public readonly createdAt: Date;
public updatedAt: Date;
constructor(
itemData: CatalogItem & {
linkedItems?: CatalogItem[];
},
) {
this.id = itemData.id;
constructor(itemData: CatalogItemWithRelations) {
// `id` (Int @unique) is the ConnectWise catalog record ID.
// `uid` (String @id) is the Prisma primary key.
this.cwCatalogId = itemData.id;
this.id = itemData.uid;
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;
// Extract relation data into flat fields
const sub = itemData.subcategory;
const cat = sub?.category;
const mfr = itemData.manufacturer;
this.category = cat?.name ?? null;
this.categoryCwId = cat?.id ?? null;
this.subcategory = sub?.name ?? null;
this.subcategoryCwId = sub?.id ?? null;
this.manufacturer = mfr?.name ?? null;
this.manufactureCwId = mfr?.id ?? null;
this.partNumber = itemData.partNumber;
this.vendorName = itemData.vendorName;
this.vendorSku = itemData.vendorSku;
@@ -97,7 +122,7 @@ export class CatalogItemController {
if (onHand !== this.onHand) {
await prisma.catalogItem.update({
where: { id: this.id },
where: { uid: this.id },
data: { onHand },
});
this.onHand = onHand;
@@ -137,7 +162,7 @@ export class CatalogItemController {
}
const target = await prisma.catalogItem.findFirst({
where: { id: targetId },
where: { uid: targetId },
});
if (!target) {
@@ -150,9 +175,9 @@ export class CatalogItemController {
}
const updated = await prisma.catalogItem.update({
where: { id: this.id },
where: { uid: this.id },
data: {
linkedItems: { connect: { id: targetId } },
linkedItems: { connect: { uid: targetId } },
},
include: { linkedItems: true },
});
@@ -174,9 +199,9 @@ export class CatalogItemController {
*/
public async unlinkItem(targetId: string): Promise<CatalogItemController> {
const updated = await prisma.catalogItem.update({
where: { id: this.id },
where: { uid: this.id },
data: {
linkedItems: { disconnect: { id: targetId } },
linkedItems: { disconnect: { uid: targetId } },
},
include: { linkedItems: true },
});
+214 -148
View File
@@ -1,204 +1,270 @@
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";
Company,
CompanyAddress,
Contact,
} from "../../generated/prisma/client";
import { connectWiseApi, prisma } from "../constants";
import GenericError from "../Errors/GenericError";
import { processConfigurationResponse } from "../modules/cw-utils/configurations/processConfigurationResponse";
import { withCwRetry } from "../modules/cw-utils/withCwRetry";
import type { ConfigurationResponse } from "../types/ConnectWiseTypes";
// Type for company data with relations
type CompanyWithRelations = Company & {
contacts?: Contact[];
companyAddresses?: CompanyAddress[];
};
/**
* Company Controller
*
* This class is for creating a controller that can manage company data,
* synchronize with external systems, and provide methods for accessing
* and updating company information within our internal system.
* This class manages company data from the local database.
* Data is synced from ConnectWise via the dalpuri service.
*/
export class CompanyController {
public readonly id: string;
public readonly id: number;
public readonly uid: string;
public name: string;
public readonly cw_Identifier: string;
public readonly cw_CompanyId: number;
public cw_Data?: {
company: CWCompany;
defaultContact: Contact | null;
allContacts: Contact[];
};
public phone: string | null;
public website: string | null;
constructor(companyData: Company, cwData?: typeof this.cw_Data) {
private _contacts: Contact[] = [];
private _addresses: CompanyAddress[] = [];
private _defaultContact: Contact | null = null;
private _defaultAddress: CompanyAddress | null = null;
constructor(companyData: CompanyWithRelations) {
this.id = companyData.id;
this.uid = companyData.uid;
this.name = companyData.name;
this.cw_Identifier = companyData.cw_Identifier;
this.cw_CompanyId = companyData.cw_CompanyId;
this.cw_Data = cwData;
this.phone = companyData.phone;
this.website = companyData.website;
if (companyData.contacts) {
this._contacts = companyData.contacts;
this._defaultContact =
companyData.contacts.find((c) => c.default) ?? null;
}
if (companyData.companyAddresses) {
this._addresses = companyData.companyAddresses;
this._defaultAddress =
companyData.companyAddresses.find((a) => a.defaultFlag) ?? null;
}
}
/**
* Hydrate CW Data
* Hydrate Data
*
* Fetches and populates the full ConnectWise company data
* (company, default contact, all contacts) if not already loaded.
*
* @returns {ThisType}
* Loads contacts and addresses from the local database if not already loaded.
*/
public async hydrateCwData() {
if (this.cw_Data) return this;
public async hydrateData() {
if (this._contacts.length === 0) {
this._contacts = await prisma.contact.findMany({
where: { companyId: this.id },
});
this._defaultContact = this._contacts.find((c) => c.default) ?? null;
}
const cwCompany = await fetchCwCompanyById(this.cw_CompanyId);
if (!cwCompany) return this;
const allContactsData = await connectWiseApi.get(
`${cwCompany._info.contacts_href}&pageSize=1000`,
);
// Derive default contact from allContacts instead of a separate CW call
const defaultContactId = cwCompany.defaultContact?.id;
const defaultContactData = defaultContactId
? ((allContactsData.data as any[]).find(
(c: any) => c.id === defaultContactId,
) ?? null)
: null;
this.cw_Data = {
company: cwCompany,
defaultContact: defaultContactData,
allContacts: allContactsData.data,
};
if (this._addresses.length === 0) {
this._addresses = await prisma.companyAddress.findMany({
where: { companyId: this.id },
});
this._defaultAddress = this._addresses.find((a) => a.defaultFlag) ?? null;
}
return this;
}
/**
* Refresh Internal Company Data from ConnectWise
* Refresh from DB
*
* This method fetches the latest company data from ConnectWise and updates
* the internal company information accordingly.
*
* @returns {ThisType} - Updated Controller
* Reloads the company data from the local database.
*/
public async refreshFromCW() {
const data = await updateCwInternalCompany(this.cw_CompanyId);
public async refreshFromDb() {
const data = await prisma.company.findUnique({
where: { id: this.id },
});
if (data) {
this.name = data.name;
this.phone = data.phone;
this.website = data.website;
}
this.name = data?.name || this.name;
return this;
}
/**
* Fetch ConnectWise Company Data
*
* This method retrieves the latest company data directly from ConnectWise
* using the stored ConnectWise Company ID.
*
* @returns {Company}
*/
public async fetchCwData(): Promise<CWCompany | null> {
const data = await fetchCwCompanyById(this.cw_CompanyId);
return data;
}
/**
* Fetch Company Configurations
*
* This method retrieves the configurations associated with
* the company from ConnectWise.
*
* @returns {ProcessedConfiguration}
* Fetches configurations directly from ConnectWise.
*/
public async fetchConfigurations() {
const data = await fetchCompanyConfigurations(this.cw_CompanyId);
return data;
const pageSize = 1000;
const conditions = encodeURIComponent(`company/id=${this.id}`);
const configurations: ConfigurationResponse = [];
try {
for (let page = 1; ; page++) {
const response = await withCwRetry(
() =>
connectWiseApi.get<ConfigurationResponse>(
`/company/configurations?page=${page}&pageSize=${pageSize}&conditions=${conditions}`,
),
{ label: `company-configurations:${this.id}:page-${page}` },
);
const items = Array.isArray(response.data) ? response.data : [];
configurations.push(...items);
if (items.length < pageSize) break;
}
return processConfigurationResponse(configurations);
} catch (error: any) {
const cwStatus = Number(error?.response?.status);
const status = cwStatus >= 400 && cwStatus <= 599 ? cwStatus : 502;
throw new GenericError({
message: `Failed to fetch company configurations from ConnectWise`,
name: "ConnectWiseFetchFailed",
cause:
error?.response?.data?.message ??
error?.response?.statusText ??
error?.message ??
"Unknown ConnectWise error",
status,
});
}
}
/**
* Fetch Company Sites
*
* Retrieves all sites for this company from ConnectWise
* and returns them as serialized site objects.
* Retrieves all sites (addresses) for this company from local DB.
*/
public async fetchSites() {
const sites = await fetchCompanySites(this.cw_CompanyId);
return sites.map(serializeCwSite);
const sites = await prisma.companyAddress.findMany({
where: { companyId: this.id },
});
return sites.map((site) => ({
id: site.id,
name: site.name,
addressLine1: site.addressLine1,
addressLine2: site.addressLine2,
city: site.city,
state: site.state,
zip: site.zipCode,
country: site.country,
phone: site.phone,
fax: site.fax,
defaultFlag: site.defaultFlag,
inactiveFlag: site.inactiveFlag,
}));
}
/**
* 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
* Retrieves a single site by its ID from local DB.
*/
public async fetchSite(cwSiteId: number) {
const site = await fetchCompanySite(this.cw_CompanyId, cwSiteId);
return serializeCwSite(site);
public async fetchSite(siteId: number) {
const site = await prisma.companyAddress.findFirst({
where: { id: siteId, companyId: this.id },
});
if (!site) return null;
return {
id: site.id,
name: site.name,
addressLine1: site.addressLine1,
addressLine2: site.addressLine2,
city: site.city,
state: site.state,
zip: site.zipCode,
country: site.country,
phone: site.phone,
fax: site.fax,
defaultFlag: site.defaultFlag,
inactiveFlag: site.inactiveFlag,
};
}
/**
* Get all contacts
*/
public getContacts() {
return this._contacts;
}
/**
* Get default contact
*/
public getDefaultContact() {
return this._defaultContact;
}
/**
* Get default address
*/
public getDefaultAddress() {
return this._defaultAddress;
}
public toJson(opts?: {
includeAddress: boolean;
includePrimaryContact: boolean;
includeAddress?: boolean;
includePrimaryContact?: boolean;
includeAllContacts?: boolean;
}) {
const cw_Data: Record<string, unknown> = {};
if (opts?.includeAddress) {
cw_Data.address = this._defaultAddress
? {
line1: this._defaultAddress.addressLine1,
line2: this._defaultAddress.addressLine2,
city: this._defaultAddress.city,
state: this._defaultAddress.state,
zip: this._defaultAddress.zipCode,
country: this._defaultAddress.country ?? "US",
}
: null;
}
if (opts?.includePrimaryContact) {
cw_Data.primaryContact = this._defaultContact
? {
firstName: this._defaultContact.firstName,
lastName: this._defaultContact.lastName,
cwId: this._defaultContact.id,
inactive: !this._defaultContact.active,
title: this._defaultContact.title,
phone: this._defaultContact.phone,
email: this._defaultContact.email,
}
: null;
}
if (opts?.includeAllContacts) {
cw_Data.allContacts = this._contacts.map((contact) => ({
firstName: contact.firstName,
lastName: contact.lastName,
cwId: contact.id,
inactive: !contact.active,
title: contact.title,
phone: contact.phone,
email: contact.email,
}));
}
return {
id: this.id,
id: this.uid,
name: this.name,
cw_Identifier: this.cw_Identifier,
cw_CompanyId: this.cw_CompanyId,
cw_Data: {
address: !opts?.includeAddress
? undefined
: {
line1: this.cw_Data?.company.addressLine1,
line2: this.cw_Data?.company.addressLine2 ?? null,
city: this.cw_Data?.company.city,
state: this.cw_Data?.company.state,
zip: this.cw_Data?.company.zip,
country: this.cw_Data?.company.country
? this.cw_Data.company.country.name
: "United States",
},
primaryContact: !opts?.includePrimaryContact
? undefined
: this.cw_Data?.defaultContact
? {
firstName: this.cw_Data.defaultContact.firstName,
lastName: this.cw_Data.defaultContact.lastName,
cwId: this.cw_Data.defaultContact.id,
inactive: this.cw_Data.defaultContact.inactiveFlag,
title: this.cw_Data.defaultContact.title,
phone: this.cw_Data.defaultContact.defaultPhoneNbr,
email: (() => {
if (!this.cw_Data?.defaultContact?.communicationItems)
return null;
return (
this.cw_Data.defaultContact.communicationItems.find(
(v) => v.type.name === "Email",
)?.value ?? null
);
})(),
}
: null,
allContacts: !opts?.includeAllContacts
? undefined
: this.cw_Data?.allContacts.map((contact) => ({
firstName: contact.firstName,
lastName: contact.lastName,
cwId: contact.id,
inactive: contact.inactiveFlag,
title: contact.title,
phone: contact.defaultPhoneNbr,
email: (() => {
if (!contact.communicationItems) return null;
return (
contact.communicationItems.find(
(v) => v.type.name === "Email",
)?.value ?? null
);
})(),
})),
},
cw_CompanyId: this.id,
cw_Data,
};
}
}
-19
View File
@@ -1,5 +1,4 @@
import type { CwMember } from "../../generated/prisma/client";
import type { CWMember } from "../modules/cw-utils/members/fetchAllMembers";
/**
* CW Member Controller
@@ -44,24 +43,6 @@ export class CwMemberController {
return name || this.identifier;
}
/**
* Map CW Member Prisma create/update payload
*
* Static helper used by both the controller and the refresh sync.
*/
public static mapCwToDb(item: CWMember) {
return {
identifier: item.identifier,
firstName: item.firstName ?? "",
lastName: item.lastName ?? "",
officeEmail: item.officeEmail ?? null,
inactiveFlag: item.inactiveFlag ?? false,
cwLastUpdated: item._info?.lastUpdated
? new Date(item._info.lastUpdated)
: new Date(),
};
}
/**
* To JSON
*
@@ -8,6 +8,9 @@ import { CWForecastItem } from "../modules/cw-utils/opportunities/opportunity.ty
* locally all data is sourced directly from the ConnectWise API.
*/
export class ForecastProductController {
private static readonly PROCUREMENT_NOTES_FIELD_ID = 29;
private static readonly PRODUCT_NARRATIVE_FIELD_ID = 46;
public readonly cwForecastId: number;
public forecastDescription: string;
@@ -24,6 +27,8 @@ export class ForecastProductController {
public productDescription: string;
public customerDescription: string | null;
public description: string | null;
public procurementNotes: string | null;
public productNarrative: string | null;
public productClass: string;
public forecastType: string;
@@ -49,6 +54,11 @@ export class ForecastProductController {
public cwLastUpdated: Date | null;
public cwUpdatedBy: string | null;
// Pricing data (from local ProductData)
public unitPrice: number;
public listPrice: number;
public discount: number;
// Cancellation data (from procurement products endpoint)
public cancelledFlag: boolean;
public quantityCancelled: number;
@@ -77,8 +87,23 @@ export class ForecastProductController {
this.productDescription = data.productDescription;
this.customerDescription = data.customerDescription ?? null;
this.description = null;
this.procurementNotes =
data.procurementNotes ??
data.customFields
?.find(
(f) => f.id === ForecastProductController.PROCUREMENT_NOTES_FIELD_ID
)
?.value?.toString() ??
null;
this.productNarrative =
data.customFields?.find((f) => f.id === 46)?.value?.toString() ?? null;
data.productNarrative ??
data.customFields
?.find(
(f) => f.id === ForecastProductController.PRODUCT_NARRATIVE_FIELD_ID
)
?.value?.toString() ??
null;
this.productClass = data.productClass;
this.forecastType = data.forecastType;
@@ -105,6 +130,11 @@ export class ForecastProductController {
: null;
this.cwUpdatedBy = data._info?.updatedBy ?? null;
// Pricing defaults — enriched later via applyPricingData()
this.unitPrice = 0;
this.listPrice = 0;
this.discount = 0;
// Cancellation defaults — enriched later via applyCancellationData()
this.cancelledFlag = false;
this.quantityCancelled = 0;
@@ -133,8 +163,19 @@ export class ForecastProductController {
public applyProcurementCustomFields(data: {
customFields?: Array<{ id: number; value?: unknown }>;
}): void {
const notes = data.customFields
?.find(
(f) => f.id === ForecastProductController.PROCUREMENT_NOTES_FIELD_ID
)
?.value?.toString();
if (notes) {
this.procurementNotes = notes;
}
const narrative = data.customFields
?.find((f) => f.id === 46)
?.find(
(f) => f.id === ForecastProductController.PRODUCT_NARRATIVE_FIELD_ID
)
?.value?.toString();
if (narrative) {
this.productNarrative = narrative;
@@ -257,6 +298,7 @@ export class ForecastProductController {
: null,
productDescription: this.productDescription,
customerDescription: this.customerDescription,
procurementNotes: this.procurementNotes,
productNarrative: this.productNarrative,
productClass: this.productClass,
forecastType: this.forecastType,
@@ -281,6 +323,25 @@ export class ForecastProductController {
cwUpdatedBy: this.cwUpdatedBy,
onHand: this.onHand,
inStock: this.inStock,
unitPrice: this.unitPrice,
listPrice: this.listPrice,
discount: this.discount,
};
}
/**
* Apply Pricing Data
*
* Enriches this forecast product with pricing data from the local
* ProductData table.
*/
public applyPricingData(data: {
unitPrice?: number;
listPrice?: number;
discount?: number;
}): void {
this.unitPrice = data.unitPrice ?? 0;
this.listPrice = data.listPrice ?? 0;
this.discount = data.discount ?? 0;
}
}
@@ -33,7 +33,7 @@ export class GeneratedQuoteController {
data: GeneratedQuotes & {
opportunity?: Opportunity | null;
createdBy?: (User & { roles: Role[] }) | null;
},
}
) {
this.id = data.id;
@@ -67,7 +67,7 @@ export class GeneratedQuoteController {
if (this._opportunity) return this._opportunity;
const opportunity = await prisma.opportunity.findFirst({
where: { id: this.opportunityId },
where: { uid: this.opportunityId },
});
if (!opportunity) return null;
@@ -114,8 +114,8 @@ export class GeneratedQuoteController {
quoteFile: !opts?.includeFile
? undefined
: opts?.encodeFileAsBase64
? Buffer.from(this.quoteFile).toString("base64")
: this.quoteFile,
? Buffer.from(this.quoteFile).toString("base64")
: this.quoteFile,
opportunity:
opts?.includeOpportunity && this._opportunity
? this._opportunity.toJson()
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -89,7 +89,7 @@ export class RoleController {
});
throw new PermissionsVerificationError(
`Unable to verify permissions for role '${this.title}, it is recommended that you override and rewrite these permissions immediately.`,
(err as Error).message,
(err as Error).message
);
}
@@ -261,14 +261,14 @@ export class RoleController {
title: string;
moniker: string;
permissions: string[];
}>,
}>
) {
const schema = z
.object({
title: z.string().min(1, "Title cannot be empty."),
moniker: z.string().min(1, "Moniker cannot be empty."),
permissions: z.array(
z.string().min(1, "Permission node cannot be empty"),
z.string().min(1, "Permission node cannot be empty")
),
})
.partial()
@@ -284,7 +284,7 @@ export class RoleController {
if (checkMoniker && checkMoniker.moniker !== this.moniker)
throw new RoleError(
"Moniker is already taken.",
"Another role with this moniker already exists in the databse.",
"Another role with this moniker already exists in the databse."
);
}
@@ -337,7 +337,10 @@ export class RoleController {
users: opts?.viewUsers
? this._users.map((v) => ({
id: v.id,
name: v.name,
name:
`${v.firstName ?? ""} ${v.lastName ?? ""}`.trim() ||
v.login ||
v.email,
login: v.login,
roles: v.roles.map((r: any) => r.id),
}))
+84
View File
@@ -0,0 +1,84 @@
import {
Schedule,
ScheduleStatus,
ScheduleType,
ScheduleSpan,
} from "../../generated/prisma/client";
type ScheduleWithRelations = Schedule & {
status?: ScheduleStatus | null;
type?: ScheduleType | null;
scheduleSpan?: ScheduleSpan | null;
};
export class ScheduleController {
public readonly id: number;
public readonly uid: string;
public name: string;
public description: string | null;
private _data: ScheduleWithRelations;
constructor(data: ScheduleWithRelations) {
this.id = data.id;
this.uid = data.uid;
this.name = data.name;
this.description = data.description;
this._data = data;
}
public toJson() {
const d = this._data;
return {
id: d.uid,
cwId: d.id,
memberId: d.memberId,
name: d.name,
description: d.description,
closedFlag: d.closedFlag,
reminderFlag: d.reminderFlag,
allDayFlag: d.allDayFlag,
acknowledgementFlag: d.acknowledgementFlag,
meetingFlag: d.meetingFlag,
recurringFlag: d.recurringFlag,
billableFlag: d.billableFlag,
acknowledgedById: d.acknowledgedById,
acknowledgedAt: d.acknowledgedAt,
startDate: d.startDate,
endDate: d.endDate,
hoursScheduled: d.hoursScheduled,
duration: d.duration,
hoursPerDay: d.hoursPerDay,
reminderMinutes: d.reminderMinutes,
closedById: d.closedById,
closedAt: d.closedAt,
createdById: d.createdById,
updatedById: d.updatedById,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
status: d.status
? {
id: d.status.id,
uid: d.status.uid,
name: d.status.name,
color: d.status.color,
}
: null,
type: d.type
? {
id: d.type.id,
uid: d.type.uid,
name: d.type.name,
displayColor: d.type.displayColor,
}
: null,
scheduleSpan: d.scheduleSpan
? {
id: d.scheduleSpan.id,
scheduleSpanId: d.scheduleSpan.scheduleSpanId,
spanDesc: d.scheduleSpan.spanDesc,
}
: null,
};
}
}
+61 -14
View File
@@ -1,6 +1,5 @@
import { Collection } from "@discordjs/collection";
import { Role } from "../../generated/prisma/client";
import { User } from "../../generated/prisma/browser";
import { Role, User } from "../../generated/prisma/client";
import { SessionTokensObject } from "./SessionController";
import { sessions } from "../managers/sessions";
import BodyError from "../Errors/BodyError";
@@ -15,11 +14,13 @@ import { permissionsPrivateKey } from "../constants";
export default class UserController {
public id: string;
public name: string | null;
public firstName: string | null;
public lastName: string | null;
public login: string;
public email: string;
public image: string | null;
public cwIdentifier: string | null;
public cwMemberId: number | null;
private _roles: Collection<string, Role>;
private _permissions: string | null;
@@ -33,13 +34,38 @@ export default class UserController {
public createdAt: Date;
public updatedAt: Date;
public get name(): string | null {
const full = [this.firstName, this.lastName]
.filter(Boolean)
.join(" ")
.trim();
return full.length > 0 ? full : null;
}
private _splitName(name: string): {
firstName: string | null;
lastName: string | null;
} {
const trimmed = name.trim();
if (!trimmed) return { firstName: null, lastName: null };
const parts = trimmed.split(/\s+/);
const firstName = parts.shift() ?? null;
const lastName = parts.length > 0 ? parts.join(" ") : null;
return { firstName, lastName };
}
constructor(userdata: User & { roles: Role[] }) {
this.id = userdata.id;
this.name = userdata.name;
this.firstName = userdata.firstName ?? null;
this.lastName = userdata.lastName ?? null;
this.login = userdata.login;
this.email = userdata.email;
this.image = userdata.image;
this.cwIdentifier = userdata.cwIdentifier ?? null;
this.cwMemberId = userdata.cwMemberId ?? null;
this.updatedAt = userdata.updatedAt;
this.createdAt = userdata.createdAt;
this._permissions = userdata.permissions ?? null;
@@ -62,11 +88,13 @@ export default class UserController {
*/
private _updateInternalValues(userdata: User) {
this.id = userdata.id;
this.name = userdata.name;
this.firstName = userdata.firstName ?? null;
this.lastName = userdata.lastName ?? null;
this.login = userdata.login;
this.email = userdata.email;
this.image = userdata.image;
this.cwIdentifier = userdata.cwIdentifier ?? null;
this.cwMemberId = userdata.cwMemberId ?? null;
this.updatedAt = userdata.updatedAt;
this.createdAt = userdata.createdAt;
}
@@ -92,17 +120,33 @@ export default class UserController {
* @param data - A partial of the user data
* @returns {Promise<UserController>} - The updated user controller
*/
public async update(data: Partial<Pick<User, "name" | "image">>) {
if (Object.keys(data).length == 0)
public async update(
data: Partial<Pick<User, "firstName" | "lastName" | "image">> & {
name?: string;
}
) {
const updateData: Partial<Pick<User, "firstName" | "lastName" | "image">> =
{};
if (data.image !== undefined) updateData.image = data.image;
if (data.name !== undefined) {
const parsed = this._splitName(data.name);
updateData.firstName = parsed.firstName;
updateData.lastName = parsed.lastName;
}
if (data.firstName !== undefined) updateData.firstName = data.firstName;
if (data.lastName !== undefined) updateData.lastName = data.lastName;
if (Object.keys(updateData).length == 0)
throw new BodyError("Body cannot be empty.");
const updatedUser = await prisma.user.update({
where: { id: this.id },
data,
data: updateData,
});
this._updateInternalValues(updatedUser);
events.emit("user:updated", { user: this, updatedValues: data });
events.emit("user:updated", { user: this, updatedValues: updateData });
return this;
}
@@ -118,7 +162,7 @@ export default class UserController {
*/
public async setRoles(roleIdentifiers: string[]): Promise<UserController> {
const resolvedRoles = await Promise.all(
roleIdentifiers.map((identifier) => roles.fetch(identifier)),
roleIdentifiers.map((identifier) => roles.fetch(identifier))
);
const updatedUser = await prisma.user.update({
@@ -242,8 +286,8 @@ export default class UserController {
await Promise.all(
this._roles.map(async (v) =>
collection.set(v.id, await roles.fetch(v.id)),
),
collection.set(v.id, await roles.fetch(v.id))
)
);
return collection;
@@ -307,11 +351,13 @@ export default class UserController {
return {
id: this.id,
name: this.name,
firstName: this.firstName,
lastName: this.lastName,
roles: opts?.safeReturn
? undefined
: this._roles.size > 0
? this._roles.map((v) => v.moniker)
: undefined,
? this._roles.map((v) => v.moniker)
: undefined,
permissions: opts?.safeReturn
? undefined
: (() => {
@@ -325,6 +371,7 @@ export default class UserController {
login: opts?.safeReturn ? undefined : this.login,
email: opts?.safeReturn ? undefined : this.email,
cwIdentifier: opts?.safeReturn ? undefined : this.cwIdentifier,
cwMemberId: this.cwMemberId,
image: this.image,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
+52 -168
View File
@@ -1,61 +1,32 @@
import { refresh } from "./api/auth";
import app from "./api/server";
import { setupSockets } from "./api/sockets";
import {
COLLECTOR_WS_URL,
connectCollectorSocket,
collectorSocket,
engine,
PORT,
prisma,
unifi,
unifiPassword,
unifiUsername,
} from "./constants";
import { engine, PORT, prisma } 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 { listenInventoryAdjustments } from "./modules/cw-utils/procurement/listenInventoryAdjustments";
import { refreshSalesOpportunityMetricsCache } from "./modules/cache/salesOpportunityMetricsCache";
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
import { refreshCwMembers } from "./modules/cw-utils/members/refreshCwMembers";
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
import { events } from "./modules/globalEvents";
import { setupEventDebugger } from "./modules/logging/eventDebugger";
import { signPermissions } from "./modules/permission-utils/signPermissions";
import { RoleController } from "./controllers/RoleController";
import cuid from "cuid";
import { initializeWorkerSystem, getBoss } from "./workert";
import { WorkerQueue } from "./modules/workers/queues";
import { enqueueIncrementalSync } from "./modules/workers/incremental-sync";
import { startCommsServer } from "./modules/workers/coms";
import {
enqueueActiveOpportunityRefreshJob,
startOpportunityCacheWorkers,
} from "./workert";
import cuid from "cuid";
const startupArgs = new Set(Bun.argv.slice(2));
const simpleTerminalMode =
startupArgs.has("-st") || startupArgs.has("--simple-terminal");
// Setup global event debugger in non-production environments
if (Bun.env.NODE_ENV == "development") {
if (Bun.env.NODE_ENV == "development" && !simpleTerminalMode) {
setupEventDebugger({ processLabel: "API" });
}
/** Concise error message for interval logs — avoids dumping full Axios error objects. */
const briefErr = (err: any): string => {
if (err?.isAxiosError) {
const method = (err.config?.method ?? "?").toUpperCase();
const url = err.config?.url ?? "?";
return `${method} ${url}${err.code ?? `HTTP ${err.response?.status}`}`;
}
return err?.message ?? String(err);
};
// Helper to run a startup sync safely — failures are logged but never crash the process.
// Helper to run a startup task 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,
);
console.error(`[startup] ${label} failed`, err);
}
};
@@ -83,33 +54,30 @@ console.log(`[startup] Server listening on port ${PORT}`);
setupSockets();
console.log("[startup] Socket namespaces initialized");
collectorSocket.on("connect", () => {
console.log(
`[startup] Collector socket connected: ${COLLECTOR_WS_URL} (${collectorSocket.id})`,
);
});
collectorSocket.on("connect_error", (err) => {
console.error(`[startup] Collector socket connect_error: ${err.message}`);
});
connectCollectorSocket();
console.log("[startup] Collector socket initialization started");
// Initialize worker system (PgBoss connection)
await safeStartup("initializeWorkerSystem", () => initializeWorkerSystem());
// Start the inter-process comms server so the worker can connect on :8671
startCommsServer();
console.log("[startup] Worker comms server initialized");
console.log("[startup] Comms server listening on :8671");
const embeddedWorkersEnabled = Bun.env.START_EMBEDDED_WORKERS === "true";
if (embeddedWorkersEnabled) {
await safeStartup(
"startOpportunityCacheWorkers",
startOpportunityCacheWorkers,
// Enqueue a full dalpuri sync on startup
await safeStartup("enqueueDalpuriFullSync", async () => {
const jobId = await getBoss().send(WorkerQueue.DALPURI_FULL_SYNC, {}, { singletonKey: `startup-${Date.now()}` });
if (jobId) {
console.log(`[startup] Dalpuri full sync enqueued: ${jobId}`);
} else {
console.warn("[startup] Dalpuri full sync send returned null — job may already be pending or PgBoss not ready");
}
});
// Broadcast incremental sync jobs from the API process every 5s so the
// interval survives worker restarts.
setInterval(() => {
enqueueIncrementalSync().catch((err) =>
console.error(`[interval] enqueueIncrementalSync failed: ${err?.message ?? err}`)
);
} else {
console.log(
"[startup] Embedded opportunity workers disabled on API node (set START_EMBEDDED_WORKERS=true to override)",
);
}
}, 5_000);
// ---------------------------------------------------------------------------
// Background initialisation — none of this blocks the server.
@@ -141,114 +109,30 @@ await safeStartup("ensureAdminRole", async () => {
}
});
// Refresh the internal list of companies every minute
await safeStartup("refreshCompanies", refreshCompanies);
setInterval(() => {
return refreshCompanies().catch((err) =>
console.error(`[interval] refreshCompanies failed: ${briefErr(err)}`),
// Enqueue an initial cold-load metrics refresh on startup
await safeStartup("enqueueSalesMetricsRefresh", async () => {
const jobId = await getBoss().send(
WorkerQueue.REFRESH_SALES_METRICS,
{ forceColdLoad: true },
{ singletonKey: `startup-metrics-${Date.now()}` }
);
}, 60 * 1000);
if (jobId) {
console.log(`[startup] Sales metrics refresh enqueued: ${jobId}`);
} else {
console.warn("[startup] Sales metrics refresh send returned null — job may already be pending");
}
});
// Refresh the internal catalog every 30 minutes
await safeStartup("refreshCatalog", refreshCatalog);
setInterval(
() => {
return refreshCatalog().catch((err) =>
console.error(`[interval] refreshCatalog failed: ${briefErr(err)}`),
);
},
30 * 60 * 1000,
);
// Fallback full inventory sweep every 6 hours (listener handles real-time deltas)
setInterval(
() => {
return refreshInventory().catch((err) =>
console.error(`[interval] refreshInventory failed: ${briefErr(err)}`),
);
},
6 * 60 * 60 * 1000,
);
// Listen for procurement adjustment changes and sync changed products to DB + cache
await safeStartup("listenInventoryAdjustments", listenInventoryAdjustments);
// Enqueue a metrics refresh every 5 minutes
setInterval(() => {
return listenInventoryAdjustments().catch((err) =>
console.error(
`[interval] listenInventoryAdjustments failed: ${briefErr(err)}`,
),
);
}, 60 * 1000);
// Refresh opportunity CW cache every 20 minutes (activities + company hydration)
// NOTE: Do NOT await — register the interval immediately so the cache refresh
// is never blocked by a slow/stuck startup task above.
safeStartup("enqueueActiveOpportunityRefreshJob", async () => {
await enqueueActiveOpportunityRefreshJob();
});
setInterval(
() => {
return enqueueActiveOpportunityRefreshJob().catch((err) => {
console.error(
`[interval] enqueueActiveOpportunityRefreshJob failed: ${briefErr(err)}`,
);
});
},
20 * 60 * 1000,
);
// Refresh User Defined Fields every 5 minutes
await safeStartup("refreshUDFs", async () => {
await userDefinedFieldsCw.refresh();
});
setInterval(
() => {
return userDefinedFieldsCw
.refresh()
.catch((err) =>
console.error(`[interval] refreshUDFs failed: ${briefErr(err)}`),
);
},
5 * 60 * 1000,
);
// Refresh sales opportunity metrics cache for active CW members every 5 minutes
await safeStartup("refreshSalesOpportunityMetricsCache", () =>
refreshSalesOpportunityMetricsCache({ forceColdLoad: true }),
);
setInterval(
() => {
return refreshSalesOpportunityMetricsCache().catch((err) =>
console.error(
`[interval] refreshSalesOpportunityMetricsCache failed: ${briefErr(err)}`,
),
getBoss()
.send(WorkerQueue.REFRESH_SALES_METRICS, {}, { singletonKey: "metrics-interval" })
.catch((err) =>
console.error(`[interval] REFRESH_SALES_METRICS enqueue failed: ${err?.message ?? 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: ${briefErr(err)}`),
);
},
30 * 60 * 1000,
);
// Refresh CW members DB table every hour
await safeStartup("refreshCwMembers", refreshCwMembers);
setInterval(
() => {
return refreshCwMembers().catch((err) =>
console.error(`[interval] refreshCwMembers failed: ${briefErr(err)}`),
);
},
60 * 60 * 1000,
);
}, 5 * 60 * 1000);
// Sync UniFi sites
await safeStartup("syncSites", async () => {
await unifiSites.syncSites();
});
@@ -256,6 +140,6 @@ setInterval(() => {
return unifiSites
.syncSites()
.catch((err) =>
console.error(`[interval] syncSites failed: ${briefErr(err)}`),
console.error(`[interval] syncSites failed: ${err?.message ?? err}`)
);
}, 60 * 1000);
+15 -74
View File
@@ -1,5 +1,4 @@
import { ActivityController } from "../controllers/ActivityController";
import { connectWiseApi } from "../constants";
import GenericError from "../Errors/GenericError";
import { activityCw } from "../modules/cw-utils/activities/activities";
import {
@@ -18,28 +17,20 @@ export const activities = {
* @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,
});
}
// TODO: Query local Activity table when synced by dalpuri
throw new GenericError({
name: "NotAvailable",
message: "Activity fetch from local DB not yet implemented",
status: 501,
});
},
/**
* 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
* @param conditions - Optional conditions string for filtering
* @returns {Promise<ActivityController[]>}
*/
async fetchPages(
@@ -47,73 +38,32 @@ export const activities = {
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,
});
}
// TODO: Query local Activity table when synced by dalpuri
return [];
},
/**
* 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,
});
}
// TODO: Query local Activity table when synced by dalpuri
return [];
},
/**
* 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,
});
}
// TODO: Query local Activity table when synced by dalpuri
return [];
},
/**
@@ -196,16 +146,7 @@ export const activities = {
* @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,
});
}
// TODO: Count from local Activity table when synced by dalpuri
return 0;
},
};
+17 -27
View File
@@ -1,35 +1,23 @@
import { connectWiseApi, prisma } from "../constants";
import { prisma } from "../constants";
import { CompanyController } from "../controllers/CompanyController";
import { Company } from "../types/ConnectWiseTypes";
export const companies = {
async fetch(identifier: string | number): Promise<CompanyController> {
const search = await prisma.company.findFirst({
where: {
OR: [{ id: identifier as string }],
},
const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier));
const company = await prisma.company.findFirst({
where: isNumeric
? { id: Number(identifier) }
: {
OR: [{ uid: String(identifier) }, { name: String(identifier) }],
},
include: { contacts: true, companyAddresses: true },
});
if (!search) throw new Error("Unknown company.");
if (!company) throw new Error("Unknown company.");
const freshCwData: { data: Company } = await connectWiseApi.get(
`/company/companies/${search.cw_CompanyId}`,
);
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 ?? null,
allContacts: allContactsData.data,
});
return new CompanyController(company);
},
async count() {
@@ -75,12 +63,14 @@ export const companies = {
const skip = (page > 1 ? page : 0) * rpp;
const take = rpp ?? 30;
const numericQuery = parseInt(query, 10);
const data = prisma.company.findMany({
where: {
OR: [
{ cw_Identifier: { contains: query, mode: "insensitive" } },
{ name: { contains: query, mode: "insensitive" } },
{ id: { contains: query, mode: "insensitive" } },
{ uid: { contains: query, mode: "insensitive" } },
...(!isNaN(numericQuery) ? [{ id: numericQuery }] : []),
],
},
skip,
+2 -3
View File
@@ -2,6 +2,7 @@ import { prisma } from "../constants";
import { CredentialTypeController } from "../controllers/CredentialTypeController";
import { CredentialTypeField } from "../modules/credentials/credentialTypeDefs";
import GenericError from "../Errors/GenericError";
import { Prisma } from "@prisma/client";
export const credentialTypes = {
/**
@@ -83,7 +84,7 @@ export const credentialTypes = {
data: {
name: data.name,
permissionScope: data.permissionScope,
fields: data.fields as any,
fields: data.fields as Prisma.JsonArray,
icon: data.icon,
},
include: {
@@ -91,8 +92,6 @@ export const credentialTypes = {
},
});
console.log(credentialType.fields);
return new CredentialTypeController(credentialType);
},
+15 -13
View File
@@ -8,6 +8,7 @@ import {
} from "../modules/credentials/credentialTypeDefs";
import { generateSecureValue } from "../modules/credentials/generateSecureValue";
import GenericError from "../Errors/GenericError";
import { Prisma } from "@prisma/client";
/**
* Standard include clause used by every credential query.
@@ -115,7 +116,8 @@ export const credentials = {
});
}
const typeFields = credentialType.fields! as any as CredentialTypeField[];
const typeFields =
credentialType.fields! as Prisma.JsonArray as CredentialTypeField[];
// Validate the fields against acceptable fields (exclude multi-credential fields
// from value validation since they don't carry a direct value).
@@ -129,16 +131,16 @@ export const credentials = {
})) as CredentialTypeField[];
const validatedFields = await fieldValidator(
data.fields as any as CredentialField[],
acceptableFields,
data.fields as Prisma.JsonArray as CredentialField[],
acceptableFields
);
// Separate secure, non-secure, and multi-credential fields
const secureFields = validatedFields.filter(
(f) => f.secure && !f.isMultiCredential,
(f) => f.secure && !f.isMultiCredential
);
const nonSecureFields = validatedFields.filter(
(f) => !f.secure && !f.isMultiCredential,
(f) => !f.secure && !f.isMultiCredential
);
// Build fields object for non-secure fields
@@ -181,7 +183,7 @@ export const credentials = {
// Create inline sub-credentials when provided
if (data.subCredentials) {
for (const [fieldId, subCredDataList] of Object.entries(
data.subCredentials,
data.subCredentials
)) {
const fieldDef = typeFields.find((f) => f.id === fieldId);
@@ -200,8 +202,8 @@ export const credentials = {
for (const subCredData of subCredDataList) {
const validatedSubFields = await fieldValidator(
subCredData.fields as any as CredentialField[],
subFieldDefs,
subCredData.fields as Prisma.JsonArray as CredentialField[],
subFieldDefs
);
const subSecure = validatedSubFields.filter((f) => f.secure);
@@ -267,7 +269,7 @@ export const credentials = {
data: {
name: string;
fields: { fieldId: string; value: string }[];
},
}
): Promise<CredentialController> {
const parent = await prisma.credential.findFirst({
where: { id: parentId },
@@ -297,8 +299,8 @@ export const credentials = {
const subFieldDefs = (fieldDef.subFields ?? []) as CredentialTypeField[];
const validatedFields = await fieldValidator(
data.fields as any as CredentialField[],
subFieldDefs,
data.fields as Prisma.JsonArray as CredentialField[],
subFieldDefs
);
const secureFields = validatedFields.filter((f) => f.secure);
@@ -352,7 +354,7 @@ export const credentials = {
*/
async removeSubCredential(
parentId: string,
subCredentialId: string,
subCredentialId: string
): Promise<void> {
const subCredential = await prisma.credential.findFirst({
where: { id: subCredentialId, subCredentialOfId: parentId },
@@ -382,7 +384,7 @@ export const credentials = {
for (const key of Object.keys(parentFields)) {
if (Array.isArray(parentFields[key])) {
parentFields[key] = parentFields[key].filter(
(id: string) => id !== subCredentialId,
(id: string) => id !== subCredentialId
);
}
}
+7 -7
View File
@@ -31,7 +31,7 @@ export const generatedQuotes = {
},
async fetchByOpportunity(
opportunityId: string,
opportunityId: string
): Promise<GeneratedQuoteController[]> {
const rows = await prisma.generatedQuotes.findMany({
where: { opportunityId },
@@ -43,7 +43,7 @@ export const generatedQuotes = {
},
async fetchByCreator(
createdById: string,
createdById: string
): Promise<GeneratedQuoteController[]> {
const rows = await prisma.generatedQuotes.findMany({
where: { createdById },
@@ -55,7 +55,7 @@ export const generatedQuotes = {
},
async fetchByHash(
quoteRegenHash: string,
quoteRegenHash: string
): Promise<GeneratedQuoteController | null> {
const quote = await prisma.generatedQuotes.findUnique({
where: { quoteRegenHash },
@@ -76,15 +76,15 @@ export const generatedQuotes = {
createdById: string;
}): Promise<GeneratedQuoteController> {
const opportunity = await prisma.opportunity.findFirst({
where: { id: data.opportunityId },
select: { id: true },
where: { uid: data.opportunityId },
select: { uid: true },
});
if (!opportunity) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with ID '${data.opportunityId}'`,
cause: `No opportunity exists with uid '${data.opportunityId}'`,
status: 404,
});
}
@@ -147,7 +147,7 @@ export const generatedQuotes = {
name?: string | null;
email: string;
fetchAction: string;
},
}
): Promise<GeneratedQuoteController> {
const existing = await prisma.generatedQuotes.findFirst({
where: { id },
File diff suppressed because it is too large Load Diff
+114 -46
View File
@@ -11,10 +11,14 @@ import {
/**
* Standard include clause used by catalog item queries.
* Includes one level of linked items.
* Includes one level of linked items plus the manufacturer and
* subcategory (with its parent category) relations so the
* CatalogItemController can populate all fields.
*/
const catalogItemInclude = {
linkedItems: true,
manufacturer: true,
subcategory: { include: { category: true } },
} as const;
const LABOR_STYLE_CANDIDATES = {
@@ -22,8 +26,22 @@ const LABOR_STYLE_CANDIDATES = {
tech: ["LABOR & INSTALLATION - TECH", "LABOR - TECH", "LABOR TECH"],
} as const;
const DEFAULT_CATALOG_RPP = 30;
function normalizePositiveInt(value: number, fallback: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
return Math.floor(parsed);
}
function normalizeFiniteNumber(value?: number): number | undefined {
if (value === undefined) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
async function findCatalogByExactCandidates(
candidates: readonly string[],
candidates: readonly string[]
): Promise<CatalogItemController | null> {
for (const candidate of candidates) {
const item = await prisma.catalogItem.findFirst({
@@ -44,7 +62,7 @@ async function findCatalogByExactCandidates(
}
async function findCatalogByLaborStyle(
style: "field" | "tech",
style: "field" | "tech"
): Promise<CatalogItemController | null> {
const fallback = await prisma.catalogItem.findFirst({
where: {
@@ -92,6 +110,8 @@ export interface CatalogFilterOpts {
*/
function buildFilterWhere(opts: CatalogFilterOpts = {}) {
const conditions: Record<string, unknown>[] = [];
const minPrice = normalizeFiniteNumber(opts.minPrice);
const maxPrice = normalizeFiniteNumber(opts.maxPrice);
const parseNumericId = (value?: string): number | null => {
if (!value) return null;
@@ -131,14 +151,14 @@ function buildFilterWhere(opts: CatalogFilterOpts = {}) {
if (opts.category) {
if (categoryId) {
const categoryOr: Record<string, unknown>[] = [
{ categoryCwId: categoryId },
{ subcategory: { is: { category: { is: { id: categoryId } } } } },
];
if (resolvedCategoryName) {
categoryOr.push({ category: resolvedCategoryName });
categoryOr.push({ subcategory: { is: { category: { is: { name: resolvedCategoryName } } } } });
}
conditions.push({ OR: categoryOr });
} else {
conditions.push({ category: opts.category });
conditions.push({ subcategory: { is: { category: { is: { name: opts.category } } } } });
}
}
@@ -146,40 +166,43 @@ function buildFilterWhere(opts: CatalogFilterOpts = {}) {
if (subcategoryId) {
const resolvedSubcategoryName = resolveSubcategoryNameById(subcategoryId);
const subcategoryOr: Record<string, unknown>[] = [
{ subcategoryCwId: subcategoryId },
{ subcategory: { is: { id: subcategoryId } } },
];
if (resolvedSubcategoryName) {
subcategoryOr.push({ subcategory: resolvedSubcategoryName });
subcategoryOr.push({ subcategory: { is: { name: resolvedSubcategoryName } } });
}
conditions.push({ OR: subcategoryOr });
} else {
conditions.push({ subcategory: opts.subcategory });
conditions.push({ subcategory: { is: { name: opts.subcategory } } });
}
}
if (opts.group && opts.category) {
if (!resolvedCategoryName) {
conditions.push({ category: "__unknown_category__" });
conditions.push({ subcategory: { is: { category: { is: { name: "__unknown_category__" } } } } });
}
if (resolvedCategoryName) {
const subcats = getSubcategoriesForGroup(
resolvedCategoryName,
opts.group,
opts.group
);
if (subcats.length > 0) {
conditions.push({ subcategory: { in: subcats } });
conditions.push({ subcategory: { is: { name: { 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 } });
conditions.push({
subcategory: {
is: {
name: { in: subcats },
category: { is: { name: cat.name } },
},
},
});
break;
}
}
@@ -187,20 +210,32 @@ function buildFilterWhere(opts: CatalogFilterOpts = {}) {
if (opts.manufacturer) {
conditions.push({
manufacturer: { contains: opts.manufacturer, mode: "insensitive" },
manufacturer: {
is: {
name: { contains: opts.manufacturer, mode: "insensitive" },
},
},
});
}
if (opts.ecosystem) {
const eco = ECOSYSTEM_TREE.find(
(e) => e.name.toLowerCase() === opts.ecosystem!.toLowerCase(),
(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,
manufacturer: {
is: {
name: { contains: m.name, mode: "insensitive" as const },
},
},
subcategory: {
is: {
name: { startsWith: m.subcategoryPrefix },
category: { is: { name: m.category } },
},
},
})),
});
}
@@ -210,12 +245,12 @@ function buildFilterWhere(opts: CatalogFilterOpts = {}) {
conditions.push({ onHand: { gt: 0 } });
}
if (opts.minPrice !== undefined) {
conditions.push({ price: { gte: opts.minPrice } });
if (minPrice !== undefined) {
conditions.push({ price: { gte: minPrice } });
}
if (opts.maxPrice !== undefined) {
conditions.push({ price: { lte: opts.maxPrice } });
if (maxPrice !== undefined) {
conditions.push({ price: { lte: maxPrice } });
}
return conditions.length > 0 ? { AND: conditions } : undefined;
@@ -237,10 +272,10 @@ export const procurement = {
const item = await prisma.catalogItem.findFirst({
where: isNumeric
? { cwCatalogId: Number(identifier) }
? { id: Number(identifier) }
: {
OR: [
{ id: identifier as string },
{ uid: identifier as string },
{ identifier: identifier as string },
],
},
@@ -302,10 +337,12 @@ export const procurement = {
async fetchPages(
page: number,
rpp: number,
opts?: CatalogFilterOpts,
opts?: CatalogFilterOpts
): Promise<CatalogItemController[]> {
const skip = (Math.max(page, 1) - 1) * rpp;
const take = rpp;
const safePage = normalizePositiveInt(page, 1);
const safeRpp = normalizePositiveInt(rpp, DEFAULT_CATALOG_RPP);
const skip = (safePage - 1) * safeRpp;
const take = safeRpp;
const items = await prisma.catalogItem.findMany({
where: buildFilterWhere(opts),
@@ -334,10 +371,12 @@ export const procurement = {
query: string,
page: number,
rpp: number,
opts?: CatalogFilterOpts,
opts?: CatalogFilterOpts
): Promise<CatalogItemController[]> {
const skip = (Math.max(page, 1) - 1) * rpp;
const take = rpp;
const safePage = normalizePositiveInt(page, 1);
const safeRpp = normalizePositiveInt(rpp, DEFAULT_CATALOG_RPP);
const skip = (safePage - 1) * safeRpp;
const take = safeRpp;
const filterWhere = buildFilterWhere(opts) ?? {};
@@ -350,7 +389,11 @@ export const procurement = {
{ description: { contains: query, mode: "insensitive" } },
{ partNumber: { contains: query, mode: "insensitive" } },
{ vendorSku: { contains: query, mode: "insensitive" } },
{ manufacturer: { contains: query, mode: "insensitive" } },
{
manufacturer: {
is: { name: { contains: query, mode: "insensitive" } },
},
},
],
},
skip,
@@ -371,7 +414,7 @@ export const procurement = {
* @returns {Promise<number>} - Total count
*/
async count(
opts?: CatalogFilterOpts & { activeOnly?: boolean },
opts?: CatalogFilterOpts & { activeOnly?: boolean }
): Promise<number> {
// Support legacy `activeOnly` flag by mapping it to `includeInactive`
const filterOpts: CatalogFilterOpts = {
@@ -407,7 +450,11 @@ export const procurement = {
{ description: { contains: query, mode: "insensitive" } },
{ partNumber: { contains: query, mode: "insensitive" } },
{ vendorSku: { contains: query, mode: "insensitive" } },
{ manufacturer: { contains: query, mode: "insensitive" } },
{
manufacturer: {
is: { name: { contains: query, mode: "insensitive" } },
},
},
],
},
});
@@ -425,18 +472,39 @@ export const procurement = {
*/
async fetchDistinctValues(
field: "category" | "subcategory" | "manufacturer",
opts?: CatalogFilterOpts,
opts?: CatalogFilterOpts
): Promise<string[]> {
if (field === "manufacturer") {
const items = await prisma.catalogItem.findMany({
where: buildFilterWhere(opts),
select: { manufacturer: { select: { name: true } } },
});
const names = items
.map((item) => item.manufacturer?.name ?? null)
.filter((v): v is string => v !== null);
return [...new Set(names)].sort();
}
if (field === "subcategory") {
const items = await prisma.catalogItem.findMany({
where: buildFilterWhere(opts),
select: { subcategory: { select: { name: true } } },
});
const names = items
.map((item) => item.subcategory?.name ?? null)
.filter((v): v is string => v !== null);
return [...new Set(names)].sort();
}
// field === "category"
const items = await prisma.catalogItem.findMany({
where: buildFilterWhere(opts),
select: { [field]: true },
distinct: [field],
orderBy: { [field]: "asc" },
select: { subcategory: { select: { category: { select: { name: true } } } } },
});
return items
.map((item: Record<string, unknown>) => item[field] as string | null)
const names = items
.map((item) => item.subcategory?.category?.name ?? null)
.filter((v): v is string => v !== null);
return [...new Set(names)].sort();
},
/**
@@ -450,7 +518,7 @@ export const procurement = {
*/
async linkItems(
sourceIdentifier: string | number,
targetIdentifier: string | number,
targetIdentifier: string | number
): Promise<CatalogItemController> {
const source = await procurement.fetchItem(sourceIdentifier);
const target = await procurement.fetchItem(targetIdentifier);
@@ -469,7 +537,7 @@ export const procurement = {
*/
async unlinkItems(
sourceIdentifier: string | number,
targetIdentifier: string | number,
targetIdentifier: string | number
): Promise<CatalogItemController> {
const source = await procurement.fetchItem(sourceIdentifier);
const target = await procurement.fetchItem(targetIdentifier);
+92
View File
@@ -0,0 +1,92 @@
import { prisma } from "../constants";
import { ScheduleController } from "../controllers/ScheduleController";
const scheduleIncludes = {
status: true,
type: true,
scheduleSpan: true,
} as const;
export const schedules = {
async fetch(identifier: string | number): Promise<ScheduleController> {
const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier));
const schedule = await prisma.schedule.findFirst({
where: isNumeric
? { id: Number(identifier) }
: { uid: String(identifier) },
include: scheduleIncludes,
});
if (!schedule) throw new Error("Unknown schedule.");
return new ScheduleController(schedule);
},
async count() {
return await prisma.schedule.count();
},
async fetchPages(page: number, rpp: number) {
page = page.valueOf();
rpp = rpp.valueOf();
const skip = (page > 1 ? page : 0) * rpp;
const take = rpp ?? 30;
const data = await prisma.schedule.findMany({
skip,
take,
include: scheduleIncludes,
orderBy: { startDate: "desc" },
});
return data.map((s) => new ScheduleController(s));
},
async search(query: string, page: number, rpp: number) {
page = page.valueOf();
rpp = rpp.valueOf();
const skip = (page > 1 ? page : 0) * rpp;
const take = rpp ?? 30;
const numericQuery = parseInt(query, 10);
const data = await prisma.schedule.findMany({
where: {
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
{ uid: { contains: query, mode: "insensitive" } },
...(!isNaN(numericQuery) ? [{ id: numericQuery }] : []),
],
},
skip,
take,
include: scheduleIncludes,
orderBy: { startDate: "desc" },
});
return data.map((s) => new ScheduleController(s));
},
async fetchByMemberDateRange(
memberId: string,
startDate: Date,
endDate: Date
) {
const data = await prisma.schedule.findMany({
where: {
memberId,
startDate: { gte: startDate },
endDate: { lte: endDate },
},
include: scheduleIncludes,
orderBy: { startDate: "asc" },
});
return data.map((s) => new ScheduleController(s));
},
};
+40 -13
View File
@@ -1,10 +1,8 @@
import { ms } from "zod/locales";
import { User } from "../../generated/prisma/client";
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";
@@ -22,7 +20,7 @@ export const users = {
* @param authRequest - The code supplied in the callback url of the Microsoft oAuth transaction
*/
async authenticate(
authRequest: msal.AuthenticationResult,
authRequest: msal.AuthenticationResult
): Promise<SessionTokensObject> {
let id = authRequest.uniqueId as string;
@@ -63,7 +61,7 @@ export const users = {
email: string;
login: string;
userId: string;
}>,
}>
) {
if (Object.keys(identifier).length == 0) return null;
const userData = await prisma.user.findFirst({
@@ -90,18 +88,45 @@ export const users = {
*/
async createUser(token: string): Promise<UserController> {
const msData = await fetchMicrosoftUser(token);
const resolvedEmail = (msData.mail ?? msData.userPrincipalName ?? "")
.trim()
.toLowerCase();
if (!resolvedEmail) {
throw new Error("Microsoft account did not include an email address.");
}
const resolvedLogin = (msData.userPrincipalName ?? resolvedEmail)
.trim()
.toLowerCase();
// Attempt to resolve the user's ConnectWise identifier by email
const cwIdentifier = await findCwIdentifierByEmail(msData.mail).catch(
() => null,
);
const cwIdentifier = await prisma.cwMember
.findFirst({ where: { officeEmail: resolvedEmail } })
.then((m) => m?.identifier ?? null)
.catch(() => null);
const newUser = await prisma.user.create({
data: {
const existingUser = await prisma.user.findUnique({
where: { email: resolvedEmail },
select: { id: true },
});
const newUser = await prisma.user.upsert({
where: { email: resolvedEmail },
create: {
userId: msData.id,
email: msData.mail ?? msData.userPrincipalName,
name: `${msData.givenName} ${msData.surname}`,
login: msData.userPrincipalName,
email: resolvedEmail,
firstName: msData.givenName ?? null,
lastName: msData.surname ?? null,
login: resolvedLogin,
cwIdentifier,
token,
},
update: {
userId: msData.id,
firstName: msData.givenName ?? null,
lastName: msData.surname ?? null,
login: resolvedLogin,
cwIdentifier,
token,
},
@@ -109,7 +134,9 @@ export const users = {
});
let controller = new UserController(newUser);
events.emit("user:created", controller);
if (!existingUser) {
events.emit("user:created", controller);
}
return controller;
},
@@ -1,168 +0,0 @@
/**
* @module computeCacheTTL
*
* Adaptive Cache TTL Algorithm
* ============================
*
* Determines how long a cached record should live before it must be
* re-fetched from the upstream source (e.g. ConnectWise API).
*
* The algorithm prioritises freshness for records that are actively
* being worked on, while avoiding unnecessary API calls for stale or
* inactive data.
*
* ## Spec
*
* | # | Condition | TTL (ms) | TTL (human) | Rationale |
* |---|------------------------------------------------------------------|----------|-------------|--------------------------------------------------------------------|
* | 1 | `closedFlag` is `true` | `null` | Do not cache| Closed records are rarely accessed; caching wastes memory. |
* | 2 | `expectedCloseDate` OR `lastUpdated` is within the last **5 days**| 60 000 | 60 seconds | High-activity window data changes frequently and must stay fresh.|
* | 3 | `expectedCloseDate` OR `lastUpdated` is within the last **14 days**| 90 000 | 90 seconds | Moderate activity still relevant, but changes less often. |
* | 4 | Everything else (older than 14 days) | 900 000 | 15 minutes | Low activity safe to serve from cache for longer. |
*
* ## Evaluation order
*
* Rules are evaluated **top-to-bottom**; the first matching rule wins.
* Rule 2 (5-day window) is a subset of Rule 3 (14-day window), so it
* must be checked first.
*
* ## Inputs
*
* | Field | Type | Description |
* |--------------------|------------------|--------------------------------------------------------------------|
* | `closedFlag` | `boolean` | Whether the record is closed / inactive. |
* | `expectedCloseDate`| `Date \| null` | The projected close date (future-looking relevance signal). |
* | `lastUpdated` | `Date \| null` | The last time the upstream record was modified (backward-looking). |
* | `now` | `Date` (optional)| Override for the current timestamp; defaults to `new Date()`. |
*
* ## Output
*
* Returns `number | null`:
* - A positive integer representing the TTL in **milliseconds**, or
* - `null` when the record should **not** be cached at all.
*
* ## Usage
*
* ```ts
* import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
*
* const ttl = computeCacheTTL({
* closedFlag: opportunity.closedFlag,
* expectedCloseDate: opportunity.expectedCloseDate,
* lastUpdated: opportunity.cwLastUpdated,
* });
*
* if (ttl !== null) {
* await redis.set(key, serialised, "PX", ttl);
* }
* ```
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** 60 seconds TTL for high-activity records (within 5 days).
* Must exceed the 30-second background refresh interval so the cache
* stays warm between cycles. */
export const TTL_HIGH_ACTIVITY = 60_000;
/** 90 seconds TTL for moderate-activity records (within 14 days). */
export const TTL_MODERATE_ACTIVITY = 90_000;
/** 15 minutes TTL for low-activity / stale records. */
export const TTL_LOW_ACTIVITY = 900_000;
/** 30 days in milliseconds. */
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/** 5 days in milliseconds. */
const FIVE_DAYS_MS = 5 * 24 * 60 * 60 * 1000;
/** 14 days in milliseconds. */
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
// ---------------------------------------------------------------------------
// Input type
// ---------------------------------------------------------------------------
export interface CacheTTLInput {
/** Whether the record is closed / inactive. */
closedFlag: boolean;
/** When the record was closed — used for recently-closed caching (within 30 days). */
closedDate: Date | null;
/** The projected close date — serves as a forward-looking relevance signal. */
expectedCloseDate: Date | null;
/** The date the upstream record was last modified — backward-looking signal. */
lastUpdated: Date | null;
/**
* Override for the current timestamp.
* Useful for deterministic testing. Defaults to `new Date()`.
*/
now?: Date;
}
// ---------------------------------------------------------------------------
// Algorithm
// ---------------------------------------------------------------------------
/**
* Compute the cache TTL for a record based on its activity signals.
*
* @param input - The record's activity signals. See {@link CacheTTLInput}.
* @returns The TTL in milliseconds, or `null` if the record should not be cached.
*
* @see Module-level JSDoc for the full spec table and evaluation rules.
*/
export function computeCacheTTL(input: CacheTTLInput): number | null {
const {
closedFlag,
closedDate,
expectedCloseDate,
lastUpdated,
now = new Date(),
} = input;
const nowMs = now.getTime();
/**
* Check whether a date falls within a window around `now`.
*
* "Within" means the date is between `now - windowMs` and `now + windowMs`,
* allowing both past updates and future-scheduled dates to qualify.
*/
const isWithinWindow = (date: Date | null, windowMs: number): boolean => {
if (!date) return false;
const diff = Math.abs(nowMs - date.getTime());
return diff <= windowMs;
};
// Rule 1 — Closed records
if (closedFlag) {
// Rule 1b — Recently closed (within 30 days) → cache at low-activity TTL
if (isWithinWindow(closedDate, THIRTY_DAYS_MS)) {
return TTL_LOW_ACTIVITY;
}
// Rule 1a — Closed longer than 30 days → do not cache
return null;
}
// Rule 2 — High activity (5 days)
if (
isWithinWindow(expectedCloseDate, FIVE_DAYS_MS) ||
isWithinWindow(lastUpdated, FIVE_DAYS_MS)
) {
return TTL_HIGH_ACTIVITY;
}
// Rule 3 — Moderate activity (14 days)
if (
isWithinWindow(expectedCloseDate, FOURTEEN_DAYS_MS) ||
isWithinWindow(lastUpdated, FOURTEEN_DAYS_MS)
) {
return TTL_MODERATE_ACTIVITY;
}
// Rule 4 — Low activity / stale
return TTL_LOW_ACTIVITY;
}
@@ -1,116 +0,0 @@
/**
* @module computeProductsCacheTTL
*
* Adaptive Cache TTL for Opportunity Products
* ============================================
*
* Determines how long products (forecast items) should be cached in
* Redis before being re-fetched from ConnectWise.
*
* Products have unique caching rules compared to notes or contacts
* because they are typically finalised before a deal closes and do not
* change once the opportunity reaches a terminal status.
*
* ## Spec
*
* | # | Condition | TTL (ms) | TTL (human) | Rationale |
* |---|------------------------------------------------------------------------------|------------|-------------|---------------------------------------------------------------------------------------|
* | 1 | Status is **Won**, **Lost**, **Pending Won**, or **Pending Lost** | `null` | No cache | Products on terminal / near-terminal opps are static; no need to keep them warm. |
* | 2 | Opportunity is **not cacheable** (main cache TTL is `null`) | `null` | No cache | If the opp itself is evicted, sub-resources follow suit. |
* | 3 | `lastUpdated` is within the last **3 days** | 15 000 | 15 seconds | Actively-worked deals products are being edited and need near-real-time freshness. |
* | 4 | Everything else | 1 200 000 | 20 minutes | Lazy on-demand cache: fetched when requested, expires after 20 min without refresh. |
*
* ## Evaluation order
*
* Rules are evaluated top-to-bottom; the first matching rule wins.
*
* ## Inputs
*
* Extends {@link CacheTTLInput} from `computeCacheTTL` with an
* additional `statusCwId` field used to identify terminal statuses.
*
* ## Output
*
* Returns `number | null`:
* - Positive integer = TTL in **milliseconds**.
* - `null` = do **not** cache.
*/
import type { CacheTTLInput } from "./computeCacheTTL";
import { computeCacheTTL } from "./computeCacheTTL";
import { QUOTE_STATUSES } from "../../types/QuoteStatuses";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** 45 seconds TTL for hot products (opportunity updated within 3 days).
* Must exceed the 30-second background refresh interval so the cache
* stays warm between cycles. */
export const PRODUCTS_TTL_HOT = 45_000;
/** 20 minutes — TTL for on-demand product cache (lazy fallback). */
export const PRODUCTS_TTL_LAZY = 1_200_000;
/** 3 days in milliseconds. */
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
/**
* Set of all CW status IDs that map to a Won or Lost canonical status.
*
* Built at module load from {@link QUOTE_STATUSES} so it stays in sync
* with any future status additions.
*/
export const WON_LOST_STATUS_IDS: ReadonlySet<number> = new Set(
QUOTE_STATUSES.filter((s) => s.wonFlag || s.lostFlag).flatMap((s) => [
s.id,
...s.optimaEquivalency,
]),
);
// ---------------------------------------------------------------------------
// Input type
// ---------------------------------------------------------------------------
export interface ProductsCacheTTLInput extends CacheTTLInput {
/** The CW status ID of the opportunity. */
statusCwId: number | null;
}
// ---------------------------------------------------------------------------
// Algorithm
// ---------------------------------------------------------------------------
/**
* Compute the cache TTL for an opportunity's products.
*
* @param input - The opportunity's activity signals plus status ID.
* @returns TTL in milliseconds, or `null` if products should not be cached.
*/
export function computeProductsCacheTTL(
input: ProductsCacheTTLInput,
): number | null {
const { statusCwId, lastUpdated, now = new Date() } = input;
// Rule 1 — Terminal statuses: Won / Lost / Pending Won / Pending Lost
if (statusCwId !== null && WON_LOST_STATUS_IDS.has(statusCwId)) {
return null;
}
// Rule 2 — If the opportunity itself is not cacheable, skip products too
const mainTTL = computeCacheTTL(input);
if (mainTTL === null) {
return null;
}
// Rule 3 — Hot: updated within the last 3 days
if (lastUpdated) {
const diff = Math.abs(now.getTime() - lastUpdated.getTime());
if (diff <= THREE_DAYS_MS) {
return PRODUCTS_TTL_HOT;
}
}
// Rule 4 — Lazy fallback
return PRODUCTS_TTL_LAZY;
}
@@ -1,118 +0,0 @@
/**
* @module computeSubResourceCacheTTL
*
* Adaptive Cache TTL for Opportunity Sub-Resources
* =================================================
*
* Determines how long cached sub-resource data (notes, contacts) should
* live before being re-fetched from ConnectWise.
*
* Sub-resources change less frequently than the opportunity record itself
* or its activity feed, so TTLs are longer than the primary cache. The
* same activity-signal heuristics are used (expected close date, last
* updated, closed status) but with relaxed durations.
*
* ## Spec
*
* | # | Condition | TTL (ms) | TTL (human) | Rationale |
* |---|-------------------------------------------------------------------|----------|-------------|--------------------------------------------------------------------|
* | 1 | `closedFlag` is `true` AND closed > 30 days ago | `null` | Do not cache| Old closed records are rarely accessed. |
* | 1b| `closedFlag` is `true` AND closed within 30 days | 300 000 | 5 minutes | Recently-closed records may still be viewed occasionally. |
* | 2 | `expectedCloseDate` OR `lastUpdated` within **5 days** | 60 000 | 60 seconds | Active deals contacts/notes may still change. |
* | 3 | `expectedCloseDate` OR `lastUpdated` within **14 days** | 120 000 | 2 minutes | Moderate activity less likely to change. |
* | 4 | Everything else (older than 14 days) | 300 000 | 5 minutes | Low activity safe to cache longer. |
*
* ## Evaluation order
*
* Rules are evaluated top-to-bottom; the first matching rule wins.
*
* ## Inputs
*
* Uses the same {@link CacheTTLInput} interface as `computeCacheTTL`.
*
* ## Output
*
* Returns `number | null`:
* - Positive integer = TTL in **milliseconds**.
* - `null` = do **not** cache.
*/
import type { CacheTTLInput } from "./computeCacheTTL";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** 60 seconds — TTL for high-activity sub-resources (within 5 days). */
export const SUB_TTL_HIGH_ACTIVITY = 60_000;
/** 2 minutes — TTL for moderate-activity sub-resources (within 14 days). */
export const SUB_TTL_MODERATE_ACTIVITY = 120_000;
/** 5 minutes — TTL for low-activity / stale sub-resources. */
export const SUB_TTL_LOW_ACTIVITY = 300_000;
/** 30 days in milliseconds. */
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/** 5 days in milliseconds. */
const FIVE_DAYS_MS = 5 * 24 * 60 * 60 * 1000;
/** 14 days in milliseconds. */
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
// ---------------------------------------------------------------------------
// Algorithm
// ---------------------------------------------------------------------------
/**
* Compute the cache TTL for an opportunity sub-resource (notes, contacts).
*
* @param input - The opportunity's activity signals. See {@link CacheTTLInput}.
* @returns The TTL in milliseconds, or `null` if the data should not be cached.
*/
export function computeSubResourceCacheTTL(
input: CacheTTLInput,
): number | null {
const {
closedFlag,
closedDate,
expectedCloseDate,
lastUpdated,
now = new Date(),
} = input;
const nowMs = now.getTime();
const isWithinWindow = (date: Date | null, windowMs: number): boolean => {
if (!date) return false;
return Math.abs(nowMs - date.getTime()) <= windowMs;
};
// Rule 1 — Closed records
if (closedFlag) {
if (isWithinWindow(closedDate, THIRTY_DAYS_MS)) {
return SUB_TTL_LOW_ACTIVITY;
}
return null;
}
// Rule 2 — High activity (5 days)
if (
isWithinWindow(expectedCloseDate, FIVE_DAYS_MS) ||
isWithinWindow(lastUpdated, FIVE_DAYS_MS)
) {
return SUB_TTL_HIGH_ACTIVITY;
}
// Rule 3 — Moderate activity (14 days)
if (
isWithinWindow(expectedCloseDate, FOURTEEN_DAYS_MS) ||
isWithinWindow(lastUpdated, FOURTEEN_DAYS_MS)
) {
return SUB_TTL_MODERATE_ACTIVITY;
}
// Rule 4 — Low activity / stale
return SUB_TTL_LOW_ACTIVITY;
}
-659
View File
@@ -1,659 +0,0 @@
/**
* @module opportunityCache
*
* Redis-backed cache for expensive ConnectWise API data associated
* with opportunities.
*
* ## What is cached
*
* Each non-closed opportunity may have cached payloads keyed by its `cwOpportunityId`:
*
* - **Activities** (`opp:activities:{cwOpportunityId}`) the raw `CWActivity[]` array
* - **Company CW data** (`opp:company-cw:{cw_CompanyId}`) hydrated company / contacts blob
* - **Notes** (`opp:notes:{cwOpportunityId}`) raw CW notes array
* - **Contacts** (`opp:contacts:{cwOpportunityId}`) raw CW contacts array
* - **Products** (`opp:products:{cwOpportunityId}`) raw CW forecast + procurement products blob
*
* TTLs are computed dynamically via {@link computeCacheTTL}.
*
* ## Background refresh (Worker-based)
*
* ** This module is now READ-ONLY.** Cache refresh logic has been moved to workers:
*
* - {@link refreshActiveOpportunitiesWorker} Scheduled to run every 20 minutes
* to run a unified cache pass across all opportunities. Active/recent records
* use adaptive TTLs and archived records use {@link TTL_ARCHIVED_MS}.
*
* See `src/modules/workers/cache/` for worker implementations.
*
* ## This module now provides
*
* - `getCached*()` functions for reading cached data
* - `fetchAndCache*()` functions used internally by workers
* - `invalidate*()` functions for cache invalidation after mutations
* - Cache key helpers for Redis operations
*/
import { prisma, redis } from "../../constants";
import { activityCw } from "../cw-utils/activities/activities";
import { computeCacheTTL } from "../algorithms/computeCacheTTL";
import { computeSubResourceCacheTTL } from "../algorithms/computeSubResourceCacheTTL";
import {
computeProductsCacheTTL,
PRODUCTS_TTL_HOT,
} from "../algorithms/computeProductsCacheTTL";
import { connectWiseApi } from "../../constants";
import { fetchCwCompanyById } from "../cw-utils/fetchCompany";
import { fetchCompanySite } from "../cw-utils/sites/companySites";
import { opportunityCw } from "../cw-utils/opportunities/opportunities";
import { withCwRetry } from "../cw-utils/withCwRetry";
import { events } from "../globalEvents";
// ---------------------------------------------------------------------------
// Key helpers
// ---------------------------------------------------------------------------
const ACTIVITY_PREFIX = "opp:activities:";
const COMPANY_CW_PREFIX = "opp:company-cw:";
const NOTES_PREFIX = "opp:notes:";
const CONTACTS_PREFIX = "opp:contacts:";
const PRODUCTS_PREFIX = "opp:products:";
const SITE_PREFIX = "opp:site:";
const OPP_CW_PREFIX = "opp:cw-data:";
/** Redis key for cached activities by CW opportunity ID. */
export const activityCacheKey = (cwOppId: number) =>
`${ACTIVITY_PREFIX}${cwOppId}`;
/** Redis key for cached company CW hydration data by CW company ID. */
export const companyCwCacheKey = (cwCompanyId: number) =>
`${COMPANY_CW_PREFIX}${cwCompanyId}`;
/** Redis key for cached opportunity notes by CW opportunity ID. */
export const notesCacheKey = (cwOppId: number) => `${NOTES_PREFIX}${cwOppId}`;
/** Redis key for cached opportunity contacts by CW opportunity ID. */
export const contactsCacheKey = (cwOppId: number) =>
`${CONTACTS_PREFIX}${cwOppId}`;
/** Redis key for cached opportunity products by CW opportunity ID. */
export const productsCacheKey = (cwOppId: number) =>
`${PRODUCTS_PREFIX}${cwOppId}`;
/** Redis key for cached company site by CW company ID + site ID. */
export const siteCacheKey = (cwCompanyId: number, cwSiteId: number) =>
`${SITE_PREFIX}${cwCompanyId}:${cwSiteId}`;
/** Redis key for cached CW opportunity response by CW opportunity ID. */
export const oppCwDataCacheKey = (cwOppId: number) =>
`${OPP_CW_PREFIX}${cwOppId}`;
// ---------------------------------------------------------------------------
// Read helpers
// ---------------------------------------------------------------------------
/**
* Retrieve cached CW activities for an opportunity.
*
* @returns The parsed `CWActivity[]` or `null` on cache miss.
*/
export async function getCachedActivities(
cwOpportunityId: number,
): Promise<any[] | null> {
const raw = await redis.get(activityCacheKey(cwOpportunityId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Retrieve cached company CW hydration data.
*
* @returns `{ company, defaultContact, allContacts }` or `null` on cache miss.
*/
export async function getCachedCompanyCwData(
cwCompanyId: number,
): Promise<{ company: any; defaultContact: any; allContacts: any[] } | null> {
const raw = await redis.get(companyCwCacheKey(cwCompanyId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Retrieve cached opportunity notes (raw CW data).
*
* @returns The parsed raw CW notes array or `null` on cache miss.
*/
export async function getCachedNotes(
cwOpportunityId: number,
): Promise<any[] | null> {
const raw = await redis.get(notesCacheKey(cwOpportunityId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Retrieve cached opportunity contacts (raw CW data).
*
* @returns The parsed raw CW contacts array or `null` on cache miss.
*/
export async function getCachedContacts(
cwOpportunityId: number,
): Promise<any[] | null> {
const raw = await redis.get(contactsCacheKey(cwOpportunityId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Retrieve cached opportunity products (raw CW forecast + procurement blob).
*
* @returns `{ forecast, procProducts }` or `null` on cache miss.
*/
export async function getCachedProducts(
cwOpportunityId: number,
): Promise<{ forecast: any; procProducts: any[] } | null> {
const raw = await redis.get(productsCacheKey(cwOpportunityId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Retrieve cached CW site data for a company/site pair.
*
* @returns Parsed site data or `null` on cache miss.
*/
export async function getCachedSite(
cwCompanyId: number,
cwSiteId: number,
): Promise<any | null> {
const raw = await redis.get(siteCacheKey(cwCompanyId, cwSiteId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Retrieve cached CW opportunity response data.
*
* @returns Parsed CW opportunity object or `null` on cache miss.
*/
export async function getCachedOppCwData(
cwOpportunityId: number,
): Promise<any | null> {
const raw = await redis.get(oppCwDataCacheKey(cwOpportunityId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Check whether an error is an Axios 404 (resource not found in CW). */
function isNotFoundError(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false;
const e = err as Record<string, any>;
return e.isAxiosError === true && e.response?.status === 404;
}
/**
* Check whether an error is a transient network / timeout error.
*
* These are safe to swallow in background refresh tasks CW will be
* retried on the next refresh cycle. Logs a concise one-line warning
* instead of dumping the full Axios error object.
*/
function isTransientError(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false;
const e = err as Record<string, any>;
if (!e.isAxiosError) return false;
const code = e.code as string | undefined;
return (
code === "ECONNABORTED" ||
code === "ECONNREFUSED" ||
code === "ECONNRESET" ||
code === "ETIMEDOUT" ||
code === "ERR_NETWORK" ||
code === "ENETUNREACH" ||
code === "ERR_BAD_RESPONSE"
);
}
/** Build a concise error description for logging (avoids dumping entire Axios objects). */
function describeError(err: unknown): string {
if (typeof err !== "object" || err === null) return String(err);
const e = err as Record<string, any>;
if (e.isAxiosError) {
const method = (e.config?.method ?? "?").toUpperCase();
const url = e.config?.url ?? "unknown";
const code = e.code ?? "";
const status = e.response?.status ?? "";
return `${method} ${url}${code || `HTTP ${status}`} (${e.message})`;
}
return e.message ?? String(err);
}
/**
* When true, transient-error warnings inside fetchAndCache* are suppressed.
* Used during background refresh to avoid flooding the terminal the
* refresh function prints a single summary line instead.
*/
let _suppressTransientWarnings = false;
// ---------------------------------------------------------------------------
// Write helpers
// ---------------------------------------------------------------------------
/**
* Fetch activities from CW and cache them with the appropriate TTL.
*
* Returns an empty array if CW responds with 404 (opportunity doesn't
* exist or was deleted upstream).
*
* @returns The raw `CWActivity[]` collection (as plain array).
*/
export async function fetchAndCacheActivities(
cwOpportunityId: number,
ttlMs: number,
): Promise<any[]> {
try {
// Use the direct (single-call) variant to avoid the extra count request
const arr = await activityCw.fetchByOpportunityDirect(cwOpportunityId);
await redis.set(
activityCacheKey(cwOpportunityId),
JSON.stringify(arr),
"PX",
ttlMs,
);
return arr;
} catch (err) {
if (isNotFoundError(err)) return [];
if (isTransientError(err)) {
console.warn(
`[cache] activities opp#${cwOpportunityId}: ${describeError(err)}`,
);
return [];
}
throw err;
}
}
/**
* Fetch company CW data (company, contacts) and cache with the given TTL.
*
* @returns The hydration blob or `null` if the company doesn't exist in CW.
*/
export async function fetchAndCacheCompanyCwData(
cwCompanyId: number,
ttlMs: number,
): Promise<{ company: any; defaultContact: any; allContacts: any[] } | null> {
try {
// Fetch company and all-contacts in parallel — the allContacts URL
// can be constructed directly without the company response.
const [cwCompany, allContactsData] = await Promise.all([
fetchCwCompanyById(cwCompanyId),
withCwRetry(
() =>
connectWiseApi.get(
`/company/companies/${cwCompanyId}/contacts?pageSize=1000`,
),
{ label: `company#${cwCompanyId}/allContacts` },
),
]);
if (!cwCompany) return null;
// Default contact: derive from allContacts instead of making an
// extra serial CW call. The company object carries the default
// contact's ID, so we can pull it from the list we already fetched.
const defaultContactId = cwCompany.defaultContact?.id;
const defaultContactData = defaultContactId
? ((allContactsData.data as any[]).find(
(c: any) => c.id === defaultContactId,
) ?? null)
: null;
const blob = {
company: cwCompany,
defaultContact: defaultContactData,
allContacts: allContactsData.data,
};
await redis.set(
companyCwCacheKey(cwCompanyId),
JSON.stringify(blob),
"PX",
ttlMs,
);
return blob;
} catch (err) {
if (isNotFoundError(err)) return null;
if (isTransientError(err)) {
console.warn(`[cache] company#${cwCompanyId}: ${describeError(err)}`);
return null;
}
throw err;
}
}
/**
* Fetch opportunity notes from CW and cache the raw response.
*
* Returns an empty array if CW responds with 404.
*
* @returns The raw CW notes array.
*/
export async function fetchAndCacheNotes(
cwOpportunityId: number,
ttlMs: number,
): Promise<any[]> {
try {
const notes = await opportunityCw.fetchNotes(cwOpportunityId);
await redis.set(
notesCacheKey(cwOpportunityId),
JSON.stringify(notes),
"PX",
ttlMs,
);
return notes;
} catch (err) {
if (isNotFoundError(err)) return [];
if (isTransientError(err)) {
console.warn(
`[cache] notes opp#${cwOpportunityId}: ${describeError(err)}`,
);
return [];
}
throw err;
}
}
/**
* Fetch opportunity contacts from CW and cache the raw response.
*
* Returns an empty array if CW responds with 404.
*
* @returns The raw CW contacts array.
*/
export async function fetchAndCacheContacts(
cwOpportunityId: number,
ttlMs: number,
): Promise<any[]> {
try {
const contacts = await opportunityCw.fetchContacts(cwOpportunityId);
await redis.set(
contactsCacheKey(cwOpportunityId),
JSON.stringify(contacts),
"PX",
ttlMs,
);
return contacts;
} catch (err) {
if (isNotFoundError(err)) return [];
if (isTransientError(err)) {
console.warn(
`[cache] contacts opp#${cwOpportunityId}: ${describeError(err)}`,
);
return [];
}
throw err;
}
}
/**
* Invalidate cached notes for an opportunity.
*
* Call this after any note mutation (create, update, delete) so the
* next read refreshes from ConnectWise.
*/
export async function invalidateNotesCache(
cwOpportunityId: number,
): Promise<void> {
await redis.del(notesCacheKey(cwOpportunityId));
}
/**
* Invalidate cached contacts for an opportunity.
*
* Call this after any contact mutation so the next read refreshes
* from ConnectWise.
*/
export async function invalidateContactsCache(
cwOpportunityId: number,
): Promise<void> {
await redis.del(contactsCacheKey(cwOpportunityId));
}
/**
* Fetch opportunity products (forecast + procurement) from CW and cache.
*
* Stores both the forecast response and procurement products together
* so that `fetchProducts()` can reconstruct ForecastProductControllers
* from a single cache hit.
*
* @returns `{ forecast, procProducts }` blob.
*/
export async function fetchAndCacheProducts(
cwOpportunityId: number,
ttlMs: number,
): Promise<{ forecast: any; procProducts: any[] }> {
try {
const [forecast, procProducts] = await Promise.all([
opportunityCw.fetchProducts(cwOpportunityId),
opportunityCw.fetchProcurementProducts(cwOpportunityId),
]);
const blob = { forecast, procProducts };
await redis.set(
productsCacheKey(cwOpportunityId),
JSON.stringify(blob),
"PX",
ttlMs,
);
return blob;
} catch (err) {
if (isNotFoundError(err))
return { forecast: { forecastItems: [] }, procProducts: [] };
if (isTransientError(err)) {
console.warn(
`[cache] products opp#${cwOpportunityId}: ${describeError(err)}`,
);
return { forecast: { forecastItems: [] }, procProducts: [] };
}
throw err;
}
}
/**
* Invalidate cached products for an opportunity.
*
* Call this after any product mutation (add, update, resequence) so the
* next read refreshes from ConnectWise.
*/
export async function invalidateProductsCache(
cwOpportunityId: number,
): Promise<void> {
await redis.del(productsCacheKey(cwOpportunityId));
}
/**
* Invalidate all cached data for an opportunity.
*
* Removes activities, notes, contacts, products, and CW data cache keys.
* Call this when an opportunity is deleted.
*/
export async function invalidateAllOpportunityCaches(
cwOpportunityId: number,
): Promise<void> {
await redis.del(
activityCacheKey(cwOpportunityId),
notesCacheKey(cwOpportunityId),
contactsCacheKey(cwOpportunityId),
productsCacheKey(cwOpportunityId),
oppCwDataCacheKey(cwOpportunityId),
);
}
/**
* Site TTL 20 minutes. Site/address data rarely changes so we cache
* aggressively. The background refresh does NOT proactively warm site keys;
* they are populated lazily on the first detail-view request.
*/
const SITE_TTL_MS = 1_200_000;
/**
* Fetch a CW company site from ConnectWise and cache the result.
*
* @returns The raw CW site object.
*/
export async function fetchAndCacheSite(
cwCompanyId: number,
cwSiteId: number,
): Promise<any> {
try {
const site = await fetchCompanySite(cwCompanyId, cwSiteId);
await redis.set(
siteCacheKey(cwCompanyId, cwSiteId),
JSON.stringify(site),
"PX",
SITE_TTL_MS,
);
return site;
} catch (err) {
if (isNotFoundError(err)) return null;
if (isTransientError(err)) {
console.warn(
`[cache] site company#${cwCompanyId}/site#${cwSiteId}: ${describeError(err)}`,
);
return null;
}
throw err;
}
}
/**
* Fetch the raw CW opportunity response from ConnectWise and cache it.
*
* Used by `fetchItem()` in the manager to avoid a CW roundtrip when
* the detail view is reloaded within the cache TTL window.
*
* @param cwOpportunityId - The CW opportunity ID
* @param ttlMs - Cache TTL in milliseconds
* @returns The raw CW opportunity response object.
*/
export async function fetchAndCacheOppCwData(
cwOpportunityId: number,
ttlMs: number,
): Promise<any> {
try {
const cwData = await opportunityCw.fetch(cwOpportunityId);
await redis.set(
oppCwDataCacheKey(cwOpportunityId),
JSON.stringify(cwData),
"PX",
ttlMs,
);
return cwData;
} catch (err) {
if (isNotFoundError(err)) return null;
if (isTransientError(err)) {
console.warn(`[cache] opp#${cwOpportunityId}: ${describeError(err)}`);
return null;
}
throw err;
}
}
// ---------------------------------------------------------------------------
// Background refresh
// ---------------------------------------------------------------------------
/**
* Fixed 24-hour TTL used for archived (closed > 30 days) opportunity cache entries.
* These opportunities are outside the adaptive-TTL window and are rebuilt once per
* day at midnight via {@link refreshArchivedOpportunityCache}.
*/
export const TTL_ARCHIVED_MS = 24 * 60 * 60 * 1000; // 24 hours
/**
* Cache opportunities that fall outside the adaptive-TTL window i.e. those
* closed **more than 30 days ago** with a fixed 24-hour TTL.
*
* These opportunities are excluded by {@link computeCacheTTL} (returns `null`)
* and are therefore never warmed by {@link refreshOpportunityCache}. This
* function fills that gap so archived deals are still served from cache on
* the rare occasion they are accessed.
*
* ## Scheduling
*
* Designed to be triggered once per day at midnight from `src/index.ts`. At
* midnight `force` is `true` so every key is unconditionally overwritten,
* ensuring data is no more than 24 hours stale.
*
* On startup `force` defaults to `false` so only truly missing keys are
* populated; this avoids a large CW burst on every process restart.
*
* @param force - When `true`, overwrite every cache key without checking
* whether it already exists. Defaults to `false`.
*/
/**
* TODO: This function has been moved to a worker at
* `src/modules/workers/cache/refreshArchivedOpportunities.ts`
*
* Wire up the worker to run daily at midnight with force=true to ensure
* archived opportunities (closed > 30 days) have fresh cache entries.
*
* @deprecated - Use refreshArchivedOpportunitiesWorker from the worker module
*/
export async function refreshArchivedOpportunityCache(
force = false,
): Promise<void> {
throw new Error(
"refreshArchivedOpportunityCache has been moved to a worker. " +
"Use refreshArchivedOpportunitiesWorker from src/modules/workers/cache/refreshArchivedOpportunities.ts",
);
}
/**
* TODO: This function has been moved to a worker at
* `src/modules/workers/cache/refreshActiveOpportunities.ts`
*
* Wire up the worker to run every 30 seconds to refresh cache for active
* and recently-closed (within 30 days) opportunities.
*
* @deprecated - Use refreshActiveOpportunitiesWorker from the worker module
*/
export async function refreshOpportunityCache(): Promise<void> {
throw new Error(
"refreshOpportunityCache has been moved to a worker. " +
"Use refreshActiveOpportunitiesWorker from src/modules/workers/cache/refreshActiveOpportunities.ts",
);
}
+36 -138
View File
@@ -1,6 +1,4 @@
import { prisma, redis } from "../../constants";
import { getCachedOppCwData, getCachedProducts } from "./opportunityCache";
import { OpportunityStatus } from "../../workflows/wf.opportunity";
import { events } from "../globalEvents";
import { opportunities } from "../../managers/opportunities";
import { normalizeProbabilityRatio } from "../sales-utils/normalizeProbability";
@@ -101,13 +99,16 @@ interface CachedOpportunityRevenue {
}
interface OpportunityRow {
id: string;
cwOpportunityId: number;
id: number;
uid: string;
name: string;
primarySalesRepIdentifier: string | null;
secondarySalesRepIdentifier: string | null;
statusCwId: number | null;
statusName: string | null;
primarySalesRepId: string | null;
secondarySalesRepId: string | null;
status: {
wonFlag: boolean;
lostFlag: boolean;
closeFlag: boolean;
} | null;
closedFlag: boolean;
dateBecameLead: Date | null;
closedDate: Date | null;
@@ -137,107 +138,23 @@ const toFinite = (value: unknown): number => {
return n;
};
const isWon = (opp: {
statusCwId: number | null;
statusName: string | null;
closedFlag: boolean;
}) => {
if (opp.statusCwId === OpportunityStatus.Won) return true;
if (opp.statusName?.toLowerCase().includes("won")) return true;
if (opp.closedFlag && opp.statusName?.toLowerCase().includes("won"))
return true;
return false;
};
const isWon = (opp: { status: { wonFlag: boolean } | null }) =>
Boolean(opp.status?.wonFlag);
const isLost = (opp: {
statusCwId: number | null;
statusName: string | null;
closedFlag: boolean;
}) => {
if (opp.statusCwId === OpportunityStatus.Lost) return true;
if (opp.statusName?.toLowerCase().includes("lost")) return true;
if (opp.closedFlag && opp.statusName?.toLowerCase().includes("lost"))
return true;
return false;
};
const isLost = (opp: { status: { lostFlag: boolean } | null }) =>
Boolean(opp.status?.lostFlag);
const isClosedOpportunity = (opp: {
statusCwId: number | null;
statusName: string | null;
status: { wonFlag: boolean; lostFlag: boolean; closeFlag: boolean } | null;
closedFlag: boolean;
}) => {
if (opp.closedFlag) return true;
if (opp.status?.closeFlag) return true;
if (isWon(opp)) return true;
if (isLost(opp)) return true;
return false;
};
const buildCancellationMap = (procProducts: any[]) => {
const map = new Map<number, any>();
for (const pp of procProducts) {
const rawForecastDetailId = pp?.forecastDetailId;
const forecastDetailId =
typeof rawForecastDetailId === "number"
? rawForecastDetailId
: Number(rawForecastDetailId);
if (Number.isFinite(forecastDetailId) && forecastDetailId > 0) {
map.set(forecastDetailId, pp);
}
}
return map;
};
const computeRevenueFromProductsBlob = (
blob: any,
): Omit<OpportunityRevenue, "cacheHit"> => {
const forecastItems = Array.isArray(blob?.forecast?.forecastItems)
? blob.forecast.forecastItems
: [];
const procProducts = Array.isArray(blob?.procProducts)
? blob.procProducts
: [];
const cancellationMap = buildCancellationMap(procProducts);
let totalRevenue = 0;
let taxableRevenue = 0;
for (const item of forecastItems) {
if (!cancellationMap.has(item?.id)) continue;
if (!item?.includeFlag) continue;
const quantity = Math.max(0, toFinite(item?.quantity));
const revenue = toFinite(item?.revenue);
const cancellation = cancellationMap.get(item.id);
const cancelledFlag = Boolean(cancellation?.cancelledFlag);
const quantityCancelled = Math.max(
0,
toFinite(cancellation?.quantityCancelled),
);
if (cancelledFlag && quantity > 0 && quantityCancelled >= quantity)
continue;
const ratio =
quantity > 0 ? Math.max(0, (quantity - quantityCancelled) / quantity) : 1;
const effectiveRevenue = revenue * ratio;
totalRevenue += effectiveRevenue;
if (item?.taxableFlag) taxableRevenue += effectiveRevenue;
}
const nonTaxableRevenue = totalRevenue - taxableRevenue;
return {
totalRevenue: roundCurrency(totalRevenue),
taxableRevenue: roundCurrency(taxableRevenue),
nonTaxableRevenue: roundCurrency(nonTaxableRevenue),
};
};
const computeRevenueFromControllers = (
products: Array<{
@@ -298,20 +215,8 @@ const writeCachedOpportunityRevenue = async (
);
};
const resolveProbabilityRatio = async (opp: {
cwOpportunityId: number;
probability: number;
}): Promise<number> => {
const fromDb = normalizeProbabilityRatio(opp.probability);
if (fromDb > 0) return fromDb;
const cachedCwOpp = await getCachedOppCwData(opp.cwOpportunityId);
if (!cachedCwOpp) return 0;
const rawProbability =
cachedCwOpp?.probability?.name ?? cachedCwOpp?.probability ?? 0;
return normalizeProbabilityRatio(rawProbability);
};
const resolveProbabilityRatio = (opp: { probability: number }): number =>
normalizeProbabilityRatio(opp.probability);
const getOpportunityRevenueCacheFirst = async (
cwOpportunityId: number,
@@ -327,18 +232,6 @@ const getOpportunityRevenueCacheFirst = async (
}
}
if (!opts?.forceColdLoad) {
const cachedProducts = await getCachedProducts(cwOpportunityId);
if (cachedProducts) {
const computed = computeRevenueFromProductsBlob(cachedProducts);
await writeCachedOpportunityRevenue(cwOpportunityId, computed);
return {
...computed,
cacheHit: true,
};
}
}
try {
const opportunity = await opportunities.fetchRecord(cwOpportunityId);
const products = await opportunity.fetchProducts({
@@ -489,8 +382,8 @@ export async function refreshSalesOpportunityMetricsCache(
AND: [
{
OR: [
{ primarySalesRepIdentifier: { in: memberIdentifiers } },
{ secondarySalesRepIdentifier: { in: memberIdentifiers } },
{ primarySalesRepId: { in: memberIdentifiers } },
{ secondarySalesRepId: { in: memberIdentifiers } },
],
},
{ dateBecameLead: { gte: yearStart } },
@@ -501,12 +394,17 @@ export async function refreshSalesOpportunityMetricsCache(
},
select: {
id: true,
cwOpportunityId: true,
uid: true,
name: true,
primarySalesRepIdentifier: true,
secondarySalesRepIdentifier: true,
statusCwId: true,
statusName: true,
primarySalesRepId: true,
secondarySalesRepId: true,
status: {
select: {
wonFlag: true,
lostFlag: true,
closeFlag: true,
},
},
closedFlag: true,
dateBecameLead: true,
closedDate: true,
@@ -565,7 +463,7 @@ export async function refreshSalesOpportunityMetricsCache(
async (opp) => {
const [revenue, probabilityRatio] = await Promise.all([
withTimeout(
getOpportunityRevenueCacheFirst(opp.cwOpportunityId, {
getOpportunityRevenueCacheFirst(opp.id, {
forceColdLoad,
}),
PRODUCT_LOOKUP_TIMEOUT_MS,
@@ -619,10 +517,10 @@ export async function refreshSalesOpportunityMetricsCache(
for (const opp of opportunityRows) {
const assigned = new Set<string>();
if (opp.primarySalesRepIdentifier)
assigned.add(opp.primarySalesRepIdentifier);
if (opp.secondarySalesRepIdentifier)
assigned.add(opp.secondarySalesRepIdentifier);
if (opp.primarySalesRepId)
assigned.add(opp.primarySalesRepId);
if (opp.secondarySalesRepId)
assigned.add(opp.secondarySalesRepId);
for (const identifier of assigned) {
const bucket = opportunitiesByMember.get(identifier);
@@ -665,8 +563,8 @@ export async function refreshSalesOpportunityMetricsCache(
);
const breakdownEntry: OpportunityBreakdownEntry = {
id: opp.id,
cwId: opp.cwOpportunityId,
id: opp.uid,
cwId: opp.id,
name: opp.name,
revenue: revenue.totalRevenue,
taxableRevenue: revenue.taxableRevenue,
@@ -1,103 +0,0 @@
import { collectorSocket } from "../../constants";
export type CollectorQueryOptions = {
select?: string[];
include?: string[];
[key: string]: unknown;
};
type CollectorSuccessResponse<T> = {
success: true;
data: T;
};
type CollectorErrorResponse = {
success: false;
error: string;
};
type CollectorResponse<T> =
| CollectorSuccessResponse<T>
| CollectorErrorResponse;
const DEFAULT_ACK_TIMEOUT_MS = Number(
Bun.env.COLLECTOR_ACK_TIMEOUT_MS ?? "15000",
);
const DEFAULT_CONNECT_TIMEOUT_MS = Number(
Bun.env.COLLECTOR_CONNECT_TIMEOUT_MS ?? "5000",
);
const ensureCollectorConnected = async (
timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
): Promise<void> => {
if (collectorSocket.connected) {
return;
}
collectorSocket.connect();
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("Collector socket connection timeout"));
}, timeoutMs);
const onConnect = () => {
cleanup();
resolve();
};
const onConnectError = (err: Error) => {
cleanup();
reject(err);
};
const cleanup = () => {
clearTimeout(timeout);
collectorSocket.off("connect", onConnect);
collectorSocket.off("connect_error", onConnectError);
};
collectorSocket.on("connect", onConnect);
collectorSocket.on("connect_error", onConnectError);
});
};
export const runCollector = async <T = unknown>(
collector: string,
opts?: CollectorQueryOptions,
): Promise<T> => {
await ensureCollectorConnected();
const response = await new Promise<CollectorResponse<T>>(
(resolve, reject) => {
collectorSocket
.timeout(DEFAULT_ACK_TIMEOUT_MS)
.emit(
collector,
opts,
(err: Error | null, payload?: CollectorResponse<T>) => {
if (err) {
reject(err);
return;
}
if (!payload) {
reject(
new Error(`Collector '${collector}' returned an empty payload`),
);
return;
}
resolve(payload);
},
);
},
);
if (!response.success) {
throw new Error(`Collector '${collector}' failed: ${response.error}`);
}
return response.data;
};
@@ -1,27 +0,0 @@
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,
});
}
};
@@ -1,28 +0,0 @@
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,
});
}
};
+2 -37
View File
@@ -1,41 +1,10 @@
import { Company } from "../../types/ConnectWiseTypes";
import {
CollectorCompanyRecord,
CompanySourceRecord,
NormalizedCompanyRecord,
} from "../../types/CompanySourceTypes";
export const isCollectorCompanyRecord = (
value: unknown,
): value is CollectorCompanyRecord => {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<CollectorCompanyRecord>;
return (
typeof candidate.companyRecId === "number" &&
"companyId" in candidate &&
"companyName" in candidate
);
};
const normalizeFromCollector = (
company: CollectorCompanyRecord,
): NormalizedCompanyRecord | null => {
if (!company.companyId || !company.companyName) {
return null;
}
return {
id: company.companyRecId,
identifier: company.companyId,
name: company.companyName,
};
};
const normalizeFromCwApi = (
const normalizeCompany = (
company: Company,
): NormalizedCompanyRecord | null => {
if (!company.identifier || !company.name) {
@@ -52,11 +21,7 @@ const normalizeFromCwApi = (
export const normalizeCompanyRecord = (
source: CompanySourceRecord,
): NormalizedCompanyRecord | null => {
if (isCollectorCompanyRecord(source)) {
return normalizeFromCollector(source);
}
return normalizeFromCwApi(source);
return normalizeCompany(source);
};
export const normalizeCompanyRecords = (

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