diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2c1bd0b..aa34cea 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,9 +24,10 @@ Keep each layer focused: ## Runtime / tooling -The project runs on **Bun**. DB tooling uses **Prisma**; the generated client lives under `generated/prisma` (do NOT edit generated files). Key scripts in `package.json`: +The project runs on **Bun** exclusively — **always use `bun` commands, never `npm`, `npx`, or `yarn`**. DB tooling uses **Prisma**; the generated client lives under `generated/prisma` (do NOT edit generated files). Test preloads are configured in `bunfig.toml` so bare `bun test` works. Key scripts in `package.json`: - `dev` — `NODE_ENV=development bun --watch src/index.ts` (start dev server with hot reload) +- `test` — `bun test` (runs all tests with preload from `bunfig.toml`) - `db:gen` — `prisma generate` - `db:push` — `prisma migrate dev --skip-generate` - `utils:dev` — `docker compose -f .docker/docker-compose.yml up --build` @@ -36,7 +37,7 @@ The project runs on **Bun**. DB tooling uses **Prisma**; the generated client li ## Data layer -Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` for browser type contexts). The shared `prisma` instance is exported from `src/constants.ts`. Always run `npm run db:gen` after updating `schema.prisma`. +Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` for browser type contexts). The shared `prisma` instance is exported from `src/constants.ts`. Always run `bun run db:gen` after updating `schema.prisma`. ## Shared constants (`src/constants.ts`) @@ -174,13 +175,14 @@ The `UnifiClient` class in `src/modules/unifi-api/UnifiClient.ts` wraps all UniF ## Local dev / quick checks -- Start dev server: `npm run dev` -- Regenerate Prisma client: `npm run db:gen` -- Apply DB migrations locally: `npm run db:push` -- Docker dev utilities: `npm run utils:dev` -- Generate private keys: `npm run utils:gen_private_keys` -- Create admin role: `npm run utils:create_admin_role` -- Assign user role: `npm run utils:assign_user_role` +- Start dev server: `bun run dev` +- Run tests: `bun test` +- Regenerate Prisma client: `bun run db:gen` +- Apply DB migrations locally: `bun run db:push` +- Docker dev utilities: `bun run utils:dev` +- Generate private keys: `bun run utils:gen_private_keys` +- Create admin role: `bun run utils:create_admin_role` +- Assign user role: `bun run utils:assign_user_role` ## When editing generated or infra files diff --git a/API_ROUTES.md b/API_ROUTES.md index ff0e434..1d96ae6 100644 --- a/API_ROUTES.md +++ b/API_ROUTES.md @@ -2832,6 +2832,7 @@ Fetch a paginated list of opportunities. Supports search. Each opportunity inclu "closedBy": null, "companyId": "clx...", "cwLastUpdated": "2026-02-26T10:00:00.000Z", + "productSequence": [31848, 31846, 31847], "createdAt": "2026-02-01T00:00:00.000Z", "updatedAt": "2026-02-26T10:00:00.000Z", "customFields": [], @@ -3015,6 +3016,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The "closedBy": null, "companyId": "clx...", "cwLastUpdated": "2026-02-26T10:00:00.000Z", + "productSequence": [31848, 31846, 31847], "createdAt": "2026-02-01T00:00:00.000Z", "updatedAt": "2026-02-26T10:00:00.000Z", "customFields": [], @@ -3180,7 +3182,7 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise. The **GET** `/sales/opportunities/:identifier/products` -Fetch products (forecast/revenue line items) for an opportunity. Data is fetched live from ConnectWise using the opportunity's CW ID. +Fetch products (forecast/revenue line items) for an opportunity. Data is fetched live from ConnectWise using the opportunity's CW ID. Products are returned sorted by the opportunity's local `productSequence` array when set; otherwise, items are sorted by their ConnectWise `sequenceNumber`. **Authentication Required:** Yes @@ -3266,7 +3268,9 @@ Internal inventory data is sourced from the local CatalogItem database. If the p **PATCH** `/sales/opportunities/:identifier/products/sequence` -Update the sequence order of products (forecast items) on an opportunity. Sends a `sequenceNumber` PATCH to each forecast item in ConnectWise. +Update the display order of products (forecast items) on an opportunity. The sequence is stored **locally** in the database (`productSequence` field on the Opportunity model) — no modifications are made to ConnectWise. This means forecast item IDs remain stable and procurement product linkages are unaffected. + +When a `productSequence` is set, `GET .../products` returns items in that order. Any forecast items not included in the array (e.g. newly added items) are appended at the end in CW `sequenceNumber` order. **Authentication Required:** Yes @@ -3280,11 +3284,11 @@ Update the sequence order of products (forecast items) on an opportunity. Sends ```json { - "orderedIds": [31846, 31847, 31848] + "orderedIds": [31848, 31846, 31847] } ``` -- `orderedIds` — Array of forecast item IDs in the desired sequence order. Position in the array determines the `sequenceNumber` (1-based). +- `orderedIds` — Array of CW forecast item IDs in the desired display order. All IDs must exist on the opportunity's forecast in ConnectWise. **Response:** @@ -3295,24 +3299,138 @@ Update the sequence order of products (forecast items) on an opportunity. Sends "data": { "products": [ { - "id": 31850, + "id": 31848, + "forecastDescription": "Hardware", + "sequenceNumber": 3, + "..." + }, + { + "id": 31846, "forecastDescription": "Service", "sequenceNumber": 1, "..." + }, + { + "id": 31847, + "forecastDescription": "Licensing", + "sequenceNumber": 2, + "..." } - ], - "idMap": { - "31846": 31850, - "31847": 31851, - "31848": 31852 - } + ] }, "successful": true } ``` -- `data.products` — Full updated product objects (IDs may change after PUT to ConnectWise). -- `data.idMap` — Maps each original forecast item ID (from the request) to the new ID returned by ConnectWise. Use this to update references in the UI. +- `data.products` — Full product objects in the new display order. IDs are unchanged — CW `sequenceNumber` still reflects the original CW order, but the array order matches the locally stored sequence. + +--- + +### Add Product to Opportunity + +**POST** `/sales/opportunities/:identifier/products` + +Add a new product (forecast item) to an opportunity in ConnectWise. The request body is validated with Zod, then each submitted field is gated against `sales.opportunity.product.field.` permissions — only fields the user has permission for are forwarded to ConnectWise. + +**Authentication Required:** Yes + +**Required Permissions:** `sales.opportunity.product.add` + +**Field-Level Permission Gating:** Yes — uses `processObjectValuePerms` with scope `sales.opportunity.product.field` on the **input body**. See the field-level permissions table under `sales.opportunity.product.add` in PERMISSIONS.md. + +**Path Parameters:** + +- `identifier` — Internal ID (cuid) or ConnectWise opportunity ID (numeric) + +**Request Body:** + +All fields are optional. Only fields the user has the corresponding `sales.opportunity.product.field.` permission for will be sent to ConnectWise. + +```json +{ + "catalogItem": { "id": 1234 }, + "forecastDescription": "Managed Services", + "productDescription": "Monthly managed services agreement", + "quantity": 1, + "status": { "id": 1 }, + "productClass": "Agreement", + "forecastType": "Product", + "revenue": 500.0, + "cost": 250.0, + "includeFlag": true, + "linkFlag": false, + "recurringFlag": true, + "taxableFlag": true, + "recurringRevenue": 500.0, + "recurringCost": 250.0, + "cycles": 12, + "sequenceNumber": 1 +} +``` + +| Field | Type | Description | +| --------------------- | ---------------- | ------------------------------------------------ | +| `catalogItem` | `{ id: number }` | ConnectWise catalog item reference | +| `forecastDescription` | string | Forecast description text | +| `productDescription` | string | Product description text | +| `quantity` | number | Quantity (must be positive) | +| `status` | `{ id: number }` | ConnectWise status reference | +| `productClass` | string | Product class (e.g. Product, Service, Agreement) | +| `forecastType` | string | Forecast type | +| `revenue` | number | Revenue amount | +| `cost` | number | Cost amount | +| `includeFlag` | boolean | Whether to include in forecast totals | +| `linkFlag` | boolean | Whether the item is linked | +| `recurringFlag` | boolean | Whether this is a recurring item | +| `taxableFlag` | boolean | Whether this item is taxable | +| `recurringRevenue` | number | Recurring revenue amount | +| `recurringCost` | number | Recurring cost amount | +| `cycles` | number | Number of recurring cycles (integer, min 0) | +| `sequenceNumber` | number | Display sequence number (integer, min 0) | + +**Response:** + +```json +{ + "status": 201, + "message": "Product added to opportunity successfully!", + "data": { + "id": 31855, + "forecastDescription": "Managed Services", + "opportunity": { "id": 5678, "name": "Example Opportunity" }, + "quantity": 1, + "status": { "id": 1, "name": "Open" }, + "catalogItem": { "id": 1234, "identifier": "MSP-001" }, + "productDescription": "Monthly managed services agreement", + "productClass": "Agreement", + "forecastType": "Product", + "revenue": 500.0, + "cost": 250.0, + "margin": 250.0, + "profit": 250.0, + "percentage": 0, + "includeFlag": true, + "linkFlag": false, + "recurringFlag": true, + "taxableFlag": true, + "recurringRevenue": 500.0, + "recurringCost": 250.0, + "cycles": 12, + "sequenceNumber": 1, + "subNumber": 0, + "cwLastUpdated": "2026-03-01T00:00:00.000Z", + "cwUpdatedBy": "Admin1", + "cancelled": false, + "cancellationType": null, + "quantityCancelled": 0, + "cancelledReason": null, + "cancelledDate": null, + "onHand": null, + "inStock": null + }, + "successful": true +} +``` --- diff --git a/PERMISSIONS.md b/PERMISSIONS.md index dd23b4a..93bf921 100644 --- a/PERMISSIONS.md +++ b/PERMISSIONS.md @@ -128,15 +128,43 @@ Admin-specific UI permissions that control visibility and data loading for admin Permissions for accessing and managing sales opportunities. Opportunities are synced from ConnectWise and stored locally; sub-resources (products, notes, contacts) are fetched live from CW. -| Permission Node | Description | Used In | Dependencies | -| ---------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | -| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/products.ts](src/api/sales/[id]/products.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/fetchNote.ts](src/api/sales/[id]/fetchNote.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | | -| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.ts) | | -| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/[id]/createNote.ts](src/api/sales/[id]/createNote.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/[id]/updateNote.ts](src/api/sales/[id]/updateNote.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.ts) | `sales.opportunity.fetch` | -| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts) | `sales.opportunity.fetch` | +| Permission Node | Description | Used In | Dependencies | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | +| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/[id]/fetch.ts](src/api/sales/[id]/fetch.ts), [src/api/sales/[id]/products.ts](src/api/sales/[id]/products.ts), [src/api/sales/[id]/notes.ts](src/api/sales/[id]/notes.ts), [src/api/sales/[id]/fetchNote.ts](src/api/sales/[id]/fetchNote.ts), [src/api/sales/[id]/contacts.ts](src/api/sales/[id]/contacts.ts) | | +| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/fetchAll.ts](src/api/sales/fetchAll.ts), [src/api/sales/count.ts](src/api/sales/count.ts), [src/api/sales/fetchOpportunityTypes.ts](src/api/sales/fetchOpportunityTypes.ts) | | +| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/[id]/refresh.ts](src/api/sales/[id]/refresh.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.note.create` | Create a new note on an opportunity | [src/api/sales/[id]/createNote.ts](src/api/sales/[id]/createNote.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.note.update` | Update an existing note on an opportunity | [src/api/sales/[id]/updateNote.ts](src/api/sales/[id]/updateNote.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.note.delete` | Delete a note from an opportunity | [src/api/sales/[id]/deleteNote.ts](src/api/sales/[id]/deleteNote.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.product.update` | Update products (forecast items) on an opportunity, including resequencing | [src/api/sales/[id]/resequenceProducts.ts](src/api/sales/[id]/resequenceProducts.ts) | `sales.opportunity.fetch` | +| `sales.opportunity.product.add` | Add a new product (forecast item) to an opportunity. Individual fields gated by `sales.opportunity.product.field.` permissions. | [src/api/sales/[id]/addProduct.ts](src/api/sales/[id]/addProduct.ts) | `sales.opportunity.fetch` | + +
+Field-level permissions for sales.opportunity.product.add + +Each submitted field is gated by a `sales.opportunity.product.field.` permission node. Only fields the user has permission for are forwarded to ConnectWise. + +| Field Permission Node | Description | +| ----------------------------------------------------- | -------------------------------------------------------- | +| `sales.opportunity.product.field.catalogItem` | Set the catalog item reference | +| `sales.opportunity.product.field.forecastDescription` | Set the forecast description | +| `sales.opportunity.product.field.productDescription` | Set the product description | +| `sales.opportunity.product.field.quantity` | Set the quantity | +| `sales.opportunity.product.field.status` | Set the status reference | +| `sales.opportunity.product.field.productClass` | Set the product class (e.g. Product, Service, Agreement) | +| `sales.opportunity.product.field.forecastType` | Set the forecast type | +| `sales.opportunity.product.field.revenue` | Set the revenue amount | +| `sales.opportunity.product.field.cost` | Set the cost amount | +| `sales.opportunity.product.field.includeFlag` | Set the include flag | +| `sales.opportunity.product.field.linkFlag` | Set the link flag | +| `sales.opportunity.product.field.recurringFlag` | Set the recurring flag | +| `sales.opportunity.product.field.taxableFlag` | Set the taxable flag | +| `sales.opportunity.product.field.recurringRevenue` | Set the recurring revenue amount | +| `sales.opportunity.product.field.recurringCost` | Set the recurring cost amount | +| `sales.opportunity.product.field.cycles` | Set the number of recurring cycles | +| `sales.opportunity.product.field.sequenceNumber` | Set the sequence number (display order) | + +
### UniFi Permissions diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..8370a01 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./tests/setup.ts"] diff --git a/generated/prisma/internal/class.ts b/generated/prisma/internal/class.ts index 4ed01b4..eb47bd9 100644 --- a/generated/prisma/internal/class.ts +++ b/generated/prisma/internal/class.ts @@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = { "clientVersion": "7.3.0", "engineVersion": "9d6ad21cbbceab97458517b147a6a09ff43aa735", "activeProvider": "postgresql", - "inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\n// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?\n// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel Session {\n id String @id @default(uuid())\n sessionKey String @unique @default(cuid())\n userId String\n expires DateTime\n refreshTokenGenerated Boolean @default(false)\n refreshedAt DateTime?\n invalidatedAt DateTime?\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\nmodel User {\n id String @id @default(cuid())\n roles Role[]\n permissions String?\n login String @unique\n name String?\n email String @unique\n emailVerified DateTime?\n image String?\n\n cwIdentifier String?\n\n userId String @unique\n token String?\n\n sessions Session[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Role {\n id String @id @default(uuid())\n title String\n moniker String @unique // e.g. admin, super_admin, moderator\n\n permissions String\n users User[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel UnifiSite {\n id String @id @default(cuid())\n name String\n\n siteId String @unique\n\n companyId String?\n company Company? @relation(fields: [companyId], references: [id])\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Company {\n id String @id @default(cuid())\n name String\n\n cw_CompanyId Int @unique\n cw_Identifier String @unique\n\n credentials Credential[]\n unifiSites UnifiSite[]\n opportunities Opportunity[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel CatalogItem {\n id String @id @default(cuid())\n cwCatalogId Int @unique\n identifier String? @unique\n name String\n description String?\n customerDescription String?\n internalNotes String?\n\n linkedItems CatalogItem[] @relation(\"LinkedItems\")\n linkedTo CatalogItem[] @relation(\"LinkedItems\")\n\n category String?\n categoryCwId Int?\n subcategory String?\n subcategoryCwId Int?\n\n manufacturer String?\n manufactureCwId Int?\n\n partNumber String?\n\n vendorName String?\n vendorSku String?\n vendorCwId Int?\n\n price Float\n cost Float\n\n inactive Boolean @default(false)\n salesTaxable Boolean @default(true)\n\n onHand Int @default(0)\n cwLastUpdated DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Opportunity {\n id String @id @default(cuid())\n cwOpportunityId Int @unique\n name String\n notes String?\n\n // Stage / status / priority / type / rating stored as JSON references\n // so we don't need separate lookup tables for CW enums\n typeName String?\n typeCwId Int?\n stageName String?\n stageCwId Int?\n statusName String?\n statusCwId Int?\n priorityName String?\n priorityCwId Int?\n ratingName String?\n ratingCwId Int?\n source String?\n campaignName String?\n campaignCwId Int?\n\n // Sales rep references\n primarySalesRepName String?\n primarySalesRepIdentifier String?\n primarySalesRepCwId Int?\n secondarySalesRepName String?\n secondarySalesRepIdentifier String?\n secondarySalesRepCwId Int?\n\n // Company / contact / site\n companyCwId Int?\n companyName String?\n contactCwId Int?\n contactName String?\n siteCwId Int?\n siteName String?\n customerPO String?\n\n // Financials\n totalSalesTax Float @default(0)\n\n // Location / department\n locationName String?\n locationCwId Int?\n departmentName String?\n departmentCwId Int?\n\n // Dates\n expectedCloseDate DateTime?\n pipelineChangeDate DateTime?\n dateBecameLead DateTime?\n closedDate DateTime?\n closedFlag Boolean @default(false)\n closedByName String?\n closedByCwId Int?\n\n // Internal relation to Company (optional, linked by cwCompanyId)\n companyId String?\n company Company? @relation(fields: [companyId], references: [id])\n\n cwLastUpdated DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel CredentialType {\n id String @id @default(cuid())\n name String @unique\n\n permissionScope String\n icon String?\n fields Json\n\n credentials Credential[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel SecureValue {\n id String @id @default(cuid())\n name String\n\n content String // Encrypted content\n hash String // Hash of the original content for integrity verification and Search\n\n credentialId String\n credential Credential @relation(fields: [credentialId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Credential {\n id String @id @default(cuid())\n name String\n notes String?\n subCredentialOfId String?\n subCredentialOf Credential? @relation(\"SubCredentials\", fields: [subCredentialOfId], references: [id], onDelete: Cascade)\n subCredentials Credential[] @relation(\"SubCredentials\")\n\n typeId String\n type CredentialType @relation(fields: [typeId], references: [id], onDelete: Cascade)\n\n fields Json\n\n companyId String\n company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)\n\n securevalues SecureValue[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n", + "inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\n// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?\n// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel Session {\n id String @id @default(uuid())\n sessionKey String @unique @default(cuid())\n userId String\n expires DateTime\n refreshTokenGenerated Boolean @default(false)\n refreshedAt DateTime?\n invalidatedAt DateTime?\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\nmodel User {\n id String @id @default(cuid())\n roles Role[]\n permissions String?\n login String @unique\n name String?\n email String @unique\n emailVerified DateTime?\n image String?\n\n cwIdentifier String?\n\n userId String @unique\n token String?\n\n sessions Session[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Role {\n id String @id @default(uuid())\n title String\n moniker String @unique // e.g. admin, super_admin, moderator\n\n permissions String\n users User[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel UnifiSite {\n id String @id @default(cuid())\n name String\n\n siteId String @unique\n\n companyId String?\n company Company? @relation(fields: [companyId], references: [id])\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Company {\n id String @id @default(cuid())\n name String\n\n cw_CompanyId Int @unique\n cw_Identifier String @unique\n\n credentials Credential[]\n unifiSites UnifiSite[]\n opportunities Opportunity[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel CatalogItem {\n id String @id @default(cuid())\n cwCatalogId Int @unique\n identifier String? @unique\n name String\n description String?\n customerDescription String?\n internalNotes String?\n\n linkedItems CatalogItem[] @relation(\"LinkedItems\")\n linkedTo CatalogItem[] @relation(\"LinkedItems\")\n\n category String?\n categoryCwId Int?\n subcategory String?\n subcategoryCwId Int?\n\n manufacturer String?\n manufactureCwId Int?\n\n partNumber String?\n\n vendorName String?\n vendorSku String?\n vendorCwId Int?\n\n price Float\n cost Float\n\n inactive Boolean @default(false)\n salesTaxable Boolean @default(true)\n\n onHand Int @default(0)\n cwLastUpdated DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Opportunity {\n id String @id @default(cuid())\n cwOpportunityId Int @unique\n name String\n notes String?\n\n // Stage / status / priority / type / rating stored as JSON references\n // so we don't need separate lookup tables for CW enums\n typeName String?\n typeCwId Int?\n stageName String?\n stageCwId Int?\n statusName String?\n statusCwId Int?\n priorityName String?\n priorityCwId Int?\n ratingName String?\n ratingCwId Int?\n source String?\n campaignName String?\n campaignCwId Int?\n\n // Sales rep references\n primarySalesRepName String?\n primarySalesRepIdentifier String?\n primarySalesRepCwId Int?\n secondarySalesRepName String?\n secondarySalesRepIdentifier String?\n secondarySalesRepCwId Int?\n\n // Company / contact / site\n companyCwId Int?\n companyName String?\n contactCwId Int?\n contactName String?\n siteCwId Int?\n siteName String?\n customerPO String?\n\n // Financials\n totalSalesTax Float @default(0)\n\n // Location / department\n locationName String?\n locationCwId Int?\n departmentName String?\n departmentCwId Int?\n\n // Dates\n expectedCloseDate DateTime?\n pipelineChangeDate DateTime?\n dateBecameLead DateTime?\n closedDate DateTime?\n closedFlag Boolean @default(false)\n closedByName String?\n closedByCwId Int?\n\n // Internal relation to Company (optional, linked by cwCompanyId)\n companyId String?\n company Company? @relation(fields: [companyId], references: [id])\n\n // Local product sequence — array of CW forecast item IDs in display order.\n // When present, fetchProducts() uses this order instead of CW sequenceNumber.\n productSequence Int[] @default([])\n\n cwLastUpdated DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel CredentialType {\n id String @id @default(cuid())\n name String @unique\n\n permissionScope String\n icon String?\n fields Json\n\n credentials Credential[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel SecureValue {\n id String @id @default(cuid())\n name String\n\n content String // Encrypted content\n hash String // Hash of the original content for integrity verification and Search\n\n credentialId String\n credential Credential @relation(fields: [credentialId], references: [id], onDelete: Cascade)\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n\nmodel Credential {\n id String @id @default(cuid())\n name String\n notes String?\n subCredentialOfId String?\n subCredentialOf Credential? @relation(\"SubCredentials\", fields: [subCredentialOfId], references: [id], onDelete: Cascade)\n subCredentials Credential[] @relation(\"SubCredentials\")\n\n typeId String\n type CredentialType @relation(fields: [typeId], references: [id], onDelete: Cascade)\n\n fields Json\n\n companyId String\n company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)\n\n securevalues SecureValue[]\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n", "runtimeDataModel": { "models": {}, "enums": {}, @@ -28,7 +28,7 @@ const config: runtime.GetPrismaClientConfig = { } } -config.runtimeDataModel = JSON.parse("{\"models\":{\"Session\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessionKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expires\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"refreshTokenGenerated\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"refreshedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"invalidatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"SessionToUser\"}],\"dbName\":null},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"roles\",\"kind\":\"object\",\"type\":\"Role\",\"relationName\":\"RoleToUser\"},{\"name\":\"permissions\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"login\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"emailVerified\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"image\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwIdentifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"token\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessions\",\"kind\":\"object\",\"type\":\"Session\",\"relationName\":\"SessionToUser\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Role\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"moniker\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"permissions\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"users\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"RoleToUser\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"UnifiSite\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"siteId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"companyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"company\",\"kind\":\"object\",\"type\":\"Company\",\"relationName\":\"CompanyToUnifiSite\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Company\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cw_CompanyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"cw_Identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credentials\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"CompanyToCredential\"},{\"name\":\"unifiSites\",\"kind\":\"object\",\"type\":\"UnifiSite\",\"relationName\":\"CompanyToUnifiSite\"},{\"name\":\"opportunities\",\"kind\":\"object\",\"type\":\"Opportunity\",\"relationName\":\"CompanyToOpportunity\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"CatalogItem\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwCatalogId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"customerDescription\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"internalNotes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"linkedItems\",\"kind\":\"object\",\"type\":\"CatalogItem\",\"relationName\":\"LinkedItems\"},{\"name\":\"linkedTo\",\"kind\":\"object\",\"type\":\"CatalogItem\",\"relationName\":\"LinkedItems\"},{\"name\":\"category\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"categoryCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"subcategory\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subcategoryCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"manufacturer\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"manufactureCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"partNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"vendorName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"vendorSku\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"vendorCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"price\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"cost\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"inactive\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"salesTaxable\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"onHand\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"cwLastUpdated\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Opportunity\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwOpportunityId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"notes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"typeName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"typeCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"stageName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"stageCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"statusName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"statusCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"priorityName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"priorityCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"ratingName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ratingCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"source\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"campaignName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"campaignCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"primarySalesRepName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"primarySalesRepIdentifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"primarySalesRepCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"secondarySalesRepName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"secondarySalesRepIdentifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"secondarySalesRepCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"companyCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"companyName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"contactCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"contactName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"siteCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"siteName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"customerPO\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"totalSalesTax\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"locationName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"locationCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"departmentName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"departmentCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"expectedCloseDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"pipelineChangeDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"dateBecameLead\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"closedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"closedFlag\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"closedByName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"closedByCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"companyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"company\",\"kind\":\"object\",\"type\":\"Company\",\"relationName\":\"CompanyToOpportunity\"},{\"name\":\"cwLastUpdated\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"CredentialType\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"permissionScope\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"icon\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"fields\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"credentials\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"CredentialToCredentialType\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"SecureValue\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"hash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credentialId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credential\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"CredentialToSecureValue\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Credential\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"notes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subCredentialOfId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subCredentialOf\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"SubCredentials\"},{\"name\":\"subCredentials\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"SubCredentials\"},{\"name\":\"typeId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"type\",\"kind\":\"object\",\"type\":\"CredentialType\",\"relationName\":\"CredentialToCredentialType\"},{\"name\":\"fields\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"companyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"company\",\"kind\":\"object\",\"type\":\"Company\",\"relationName\":\"CompanyToCredential\"},{\"name\":\"securevalues\",\"kind\":\"object\",\"type\":\"SecureValue\",\"relationName\":\"CredentialToSecureValue\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") +config.runtimeDataModel = JSON.parse("{\"models\":{\"Session\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessionKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expires\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"refreshTokenGenerated\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"refreshedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"invalidatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"SessionToUser\"}],\"dbName\":null},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"roles\",\"kind\":\"object\",\"type\":\"Role\",\"relationName\":\"RoleToUser\"},{\"name\":\"permissions\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"login\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"emailVerified\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"image\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwIdentifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"token\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessions\",\"kind\":\"object\",\"type\":\"Session\",\"relationName\":\"SessionToUser\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Role\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"moniker\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"permissions\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"users\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"RoleToUser\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"UnifiSite\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"siteId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"companyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"company\",\"kind\":\"object\",\"type\":\"Company\",\"relationName\":\"CompanyToUnifiSite\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Company\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cw_CompanyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"cw_Identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credentials\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"CompanyToCredential\"},{\"name\":\"unifiSites\",\"kind\":\"object\",\"type\":\"UnifiSite\",\"relationName\":\"CompanyToUnifiSite\"},{\"name\":\"opportunities\",\"kind\":\"object\",\"type\":\"Opportunity\",\"relationName\":\"CompanyToOpportunity\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"CatalogItem\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwCatalogId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"customerDescription\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"internalNotes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"linkedItems\",\"kind\":\"object\",\"type\":\"CatalogItem\",\"relationName\":\"LinkedItems\"},{\"name\":\"linkedTo\",\"kind\":\"object\",\"type\":\"CatalogItem\",\"relationName\":\"LinkedItems\"},{\"name\":\"category\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"categoryCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"subcategory\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subcategoryCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"manufacturer\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"manufactureCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"partNumber\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"vendorName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"vendorSku\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"vendorCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"price\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"cost\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"inactive\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"salesTaxable\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"onHand\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"cwLastUpdated\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Opportunity\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cwOpportunityId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"notes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"typeName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"typeCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"stageName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"stageCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"statusName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"statusCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"priorityName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"priorityCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"ratingName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ratingCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"source\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"campaignName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"campaignCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"primarySalesRepName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"primarySalesRepIdentifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"primarySalesRepCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"secondarySalesRepName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"secondarySalesRepIdentifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"secondarySalesRepCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"companyCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"companyName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"contactCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"contactName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"siteCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"siteName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"customerPO\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"totalSalesTax\",\"kind\":\"scalar\",\"type\":\"Float\"},{\"name\":\"locationName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"locationCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"departmentName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"departmentCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"expectedCloseDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"pipelineChangeDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"dateBecameLead\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"closedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"closedFlag\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"closedByName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"closedByCwId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"companyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"company\",\"kind\":\"object\",\"type\":\"Company\",\"relationName\":\"CompanyToOpportunity\"},{\"name\":\"productSequence\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"cwLastUpdated\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"CredentialType\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"permissionScope\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"icon\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"fields\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"credentials\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"CredentialToCredentialType\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"SecureValue\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"hash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credentialId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credential\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"CredentialToSecureValue\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Credential\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"notes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subCredentialOfId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subCredentialOf\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"SubCredentials\"},{\"name\":\"subCredentials\",\"kind\":\"object\",\"type\":\"Credential\",\"relationName\":\"SubCredentials\"},{\"name\":\"typeId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"type\",\"kind\":\"object\",\"type\":\"CredentialType\",\"relationName\":\"CredentialToCredentialType\"},{\"name\":\"fields\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"companyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"company\",\"kind\":\"object\",\"type\":\"Company\",\"relationName\":\"CompanyToCredential\"},{\"name\":\"securevalues\",\"kind\":\"object\",\"type\":\"SecureValue\",\"relationName\":\"CredentialToSecureValue\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") async function decodeBase64AsWasm(wasmBase64: string): Promise { const { Buffer } = await import('node:buffer') diff --git a/generated/prisma/internal/prismaNamespace.ts b/generated/prisma/internal/prismaNamespace.ts index be6f61b..04dfbf8 100644 --- a/generated/prisma/internal/prismaNamespace.ts +++ b/generated/prisma/internal/prismaNamespace.ts @@ -1334,6 +1334,7 @@ export const OpportunityScalarFieldEnum = { closedByName: 'closedByName', closedByCwId: 'closedByCwId', companyId: 'companyId', + productSequence: 'productSequence', cwLastUpdated: 'cwLastUpdated', createdAt: 'createdAt', updatedAt: 'updatedAt' diff --git a/generated/prisma/internal/prismaNamespaceBrowser.ts b/generated/prisma/internal/prismaNamespaceBrowser.ts index 4f8ecac..54ca505 100644 --- a/generated/prisma/internal/prismaNamespaceBrowser.ts +++ b/generated/prisma/internal/prismaNamespaceBrowser.ts @@ -221,6 +221,7 @@ export const OpportunityScalarFieldEnum = { closedByName: 'closedByName', closedByCwId: 'closedByCwId', companyId: 'companyId', + productSequence: 'productSequence', cwLastUpdated: 'cwLastUpdated', createdAt: 'createdAt', updatedAt: 'updatedAt' diff --git a/generated/prisma/models/Opportunity.ts b/generated/prisma/models/Opportunity.ts index 9bfb1f3..362e0c1 100644 --- a/generated/prisma/models/Opportunity.ts +++ b/generated/prisma/models/Opportunity.ts @@ -43,6 +43,7 @@ export type OpportunityAvgAggregateOutputType = { locationCwId: number | null departmentCwId: number | null closedByCwId: number | null + productSequence: number | null } export type OpportunitySumAggregateOutputType = { @@ -62,6 +63,7 @@ export type OpportunitySumAggregateOutputType = { locationCwId: number | null departmentCwId: number | null closedByCwId: number | null + productSequence: number[] } export type OpportunityMinAggregateOutputType = { @@ -206,6 +208,7 @@ export type OpportunityCountAggregateOutputType = { closedByName: number closedByCwId: number companyId: number + productSequence: number cwLastUpdated: number createdAt: number updatedAt: number @@ -230,6 +233,7 @@ export type OpportunityAvgAggregateInputType = { locationCwId?: true departmentCwId?: true closedByCwId?: true + productSequence?: true } export type OpportunitySumAggregateInputType = { @@ -249,6 +253,7 @@ export type OpportunitySumAggregateInputType = { locationCwId?: true departmentCwId?: true closedByCwId?: true + productSequence?: true } export type OpportunityMinAggregateInputType = { @@ -393,6 +398,7 @@ export type OpportunityCountAggregateInputType = { closedByName?: true closedByCwId?: true companyId?: true + productSequence?: true cwLastUpdated?: true createdAt?: true updatedAt?: true @@ -529,6 +535,7 @@ export type OpportunityGroupByOutputType = { closedByName: string | null closedByCwId: number | null companyId: string | null + productSequence: number[] cwLastUpdated: Date | null createdAt: Date updatedAt: Date @@ -601,6 +608,7 @@ export type OpportunityWhereInput = { closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null + productSequence?: Prisma.IntNullableListFilter<"Opportunity"> cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string @@ -651,6 +659,7 @@ export type OpportunityOrderByWithRelationInput = { closedByName?: Prisma.SortOrderInput | Prisma.SortOrder closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder companyId?: Prisma.SortOrderInput | Prisma.SortOrder + productSequence?: Prisma.SortOrder cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder createdAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder @@ -704,6 +713,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{ closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null + productSequence?: Prisma.IntNullableListFilter<"Opportunity"> cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string @@ -754,6 +764,7 @@ export type OpportunityOrderByWithAggregationInput = { closedByName?: Prisma.SortOrderInput | Prisma.SortOrder closedByCwId?: Prisma.SortOrderInput | Prisma.SortOrder companyId?: Prisma.SortOrderInput | Prisma.SortOrder + productSequence?: Prisma.SortOrder cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder createdAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder @@ -811,6 +822,7 @@ export type OpportunityScalarWhereWithAggregatesInput = { closedByName?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null closedByCwId?: Prisma.IntNullableWithAggregatesFilter<"Opportunity"> | number | null companyId?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null + productSequence?: Prisma.IntNullableListFilter<"Opportunity"> cwLastUpdated?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null createdAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string @@ -859,6 +871,7 @@ export type OpportunityCreateInput = { closedFlag?: boolean closedByName?: string | null closedByCwId?: number | null + productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null createdAt?: Date | string updatedAt?: Date | string @@ -909,6 +922,7 @@ export type OpportunityUncheckedCreateInput = { closedByName?: string | null closedByCwId?: number | null companyId?: string | null + productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null createdAt?: Date | string updatedAt?: Date | string @@ -957,6 +971,7 @@ export type OpportunityUpdateInput = { closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string @@ -1007,6 +1022,7 @@ export type OpportunityUncheckedUpdateInput = { closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string @@ -1056,6 +1072,7 @@ export type OpportunityCreateManyInput = { closedByName?: string | null closedByCwId?: number | null companyId?: string | null + productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null createdAt?: Date | string updatedAt?: Date | string @@ -1104,6 +1121,7 @@ export type OpportunityUpdateManyMutationInput = { closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string @@ -1153,6 +1171,7 @@ export type OpportunityUncheckedUpdateManyInput = { closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null + productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string @@ -1168,6 +1187,14 @@ export type OpportunityOrderByRelationAggregateInput = { _count?: Prisma.SortOrder } +export type IntNullableListFilter<$PrismaModel = never> = { + equals?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null + has?: number | Prisma.IntFieldRefInput<$PrismaModel> | null + hasEvery?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> + hasSome?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> + isEmpty?: boolean +} + export type OpportunityCountOrderByAggregateInput = { id?: Prisma.SortOrder cwOpportunityId?: Prisma.SortOrder @@ -1212,6 +1239,7 @@ export type OpportunityCountOrderByAggregateInput = { closedByName?: Prisma.SortOrder closedByCwId?: Prisma.SortOrder companyId?: Prisma.SortOrder + productSequence?: Prisma.SortOrder cwLastUpdated?: Prisma.SortOrder createdAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder @@ -1234,6 +1262,7 @@ export type OpportunityAvgOrderByAggregateInput = { locationCwId?: Prisma.SortOrder departmentCwId?: Prisma.SortOrder closedByCwId?: Prisma.SortOrder + productSequence?: Prisma.SortOrder } export type OpportunityMaxOrderByAggregateInput = { @@ -1351,6 +1380,7 @@ export type OpportunitySumOrderByAggregateInput = { locationCwId?: Prisma.SortOrder departmentCwId?: Prisma.SortOrder closedByCwId?: Prisma.SortOrder + productSequence?: Prisma.SortOrder } export type OpportunityCreateNestedManyWithoutCompanyInput = { @@ -1395,6 +1425,15 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyNestedInput = { deleteMany?: Prisma.OpportunityScalarWhereInput | Prisma.OpportunityScalarWhereInput[] } +export type OpportunityCreateproductSequenceInput = { + set: number[] +} + +export type OpportunityUpdateproductSequenceInput = { + set?: number[] + push?: number | number[] +} + export type OpportunityCreateWithoutCompanyInput = { id?: string cwOpportunityId: number @@ -1438,6 +1477,7 @@ export type OpportunityCreateWithoutCompanyInput = { closedFlag?: boolean closedByName?: string | null closedByCwId?: number | null + productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null createdAt?: Date | string updatedAt?: Date | string @@ -1486,6 +1526,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = { closedFlag?: boolean closedByName?: string | null closedByCwId?: number | null + productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null createdAt?: Date | string updatedAt?: Date | string @@ -1564,6 +1605,7 @@ export type OpportunityScalarWhereInput = { closedByName?: Prisma.StringNullableFilter<"Opportunity"> | string | null closedByCwId?: Prisma.IntNullableFilter<"Opportunity"> | number | null companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null + productSequence?: Prisma.IntNullableListFilter<"Opportunity"> cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string @@ -1612,6 +1654,7 @@ export type OpportunityCreateManyCompanyInput = { closedFlag?: boolean closedByName?: string | null closedByCwId?: number | null + productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[] cwLastUpdated?: Date | string | null createdAt?: Date | string updatedAt?: Date | string @@ -1660,6 +1703,7 @@ export type OpportunityUpdateWithoutCompanyInput = { closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string @@ -1708,6 +1752,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = { closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string @@ -1756,6 +1801,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = { closedFlag?: Prisma.BoolFieldUpdateOperationsInput | boolean closedByName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null + productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[] cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string @@ -1807,6 +1853,7 @@ export type OpportunitySelect = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]> +export type OpportunityOmit = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "productSequence" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]> export type OpportunityInclude = { company?: boolean | Prisma.Opportunity$companyArgs } @@ -2022,6 +2072,7 @@ export type $OpportunityPayload readonly closedByCwId: Prisma.FieldRef<"Opportunity", 'Int'> readonly companyId: Prisma.FieldRef<"Opportunity", 'String'> + readonly productSequence: Prisma.FieldRef<"Opportunity", 'Int[]'> readonly cwLastUpdated: Prisma.FieldRef<"Opportunity", 'DateTime'> readonly createdAt: Prisma.FieldRef<"Opportunity", 'DateTime'> readonly updatedAt: Prisma.FieldRef<"Opportunity", 'DateTime'> diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aeecc38..ae11322 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -185,6 +185,10 @@ model Opportunity { companyId String? company Company? @relation(fields: [companyId], references: [id]) + // Local product sequence — array of CW forecast item IDs in display order. + // When present, fetchProducts() uses this order instead of CW sequenceNumber. + productSequence Int[] @default([]) + cwLastUpdated DateTime? createdAt DateTime @default(now()) diff --git a/src/api/sales/[id]/addProduct.ts b/src/api/sales/[id]/addProduct.ts new file mode 100644 index 0000000..5764410 --- /dev/null +++ b/src/api/sales/[id]/addProduct.ts @@ -0,0 +1,69 @@ +import { createRoute } from "../../../modules/api-utils/createRoute"; +import { opportunities } from "../../../managers/opportunities"; +import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { ContentfulStatusCode } from "hono/utils/http-status"; +import { authMiddleware } from "../../middleware/authorization"; +import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions"; +import { z } from "zod"; + +const productItemSchema = z + .object({ + catalogItem: z.object({ id: z.number().int().positive() }).optional(), + forecastDescription: z.string().optional(), + productDescription: z.string().optional(), + quantity: z.number().positive().optional(), + status: z.object({ id: z.number().int().positive() }).optional(), + productClass: z.string().optional(), + forecastType: z.string().optional(), + revenue: z.number().optional(), + cost: z.number().optional(), + includeFlag: z.boolean().optional(), + linkFlag: z.boolean().optional(), + recurringFlag: z.boolean().optional(), + taxableFlag: z.boolean().optional(), + recurringRevenue: z.number().optional(), + recurringCost: z.number().optional(), + cycles: z.number().int().min(0).optional(), + sequenceNumber: z.number().int().min(0).optional(), + }) + .strict(); + +const addProductSchema = z.union([ + productItemSchema, + z.array(productItemSchema).min(1, "At least one product is required"), +]); + +/* POST /v1/sales/opportunities/:identifier/products */ +export default createRoute( + "post", + ["/opportunities/:identifier/products"], + async (c) => { + const identifier = c.req.param("identifier"); + const body = await c.req.json(); + + const validated = addProductSchema.parse(body); + const inputItems = Array.isArray(validated) ? validated : [validated]; + + // Gate each submitted field against user permissions. + // Only fields the user has permission for are forwarded to ConnectWise. + const user = c.get("user"); + const gatedItems = await Promise.all( + inputItems.map((item) => + processObjectValuePerms(item, "sales.opportunity.product.field", user), + ), + ); + + const item = await opportunities.fetchItem(identifier); + const created = await item.addProducts(gatedItems); + + const isBatch = Array.isArray(body); + const response = apiResponse.created( + isBatch + ? `${created.length} product(s) added to opportunity successfully!` + : "Product added to opportunity successfully!", + isBatch ? created.map((p) => p.toJson()) : created[0]!.toJson(), + ); + return c.json(response, response.status as ContentfulStatusCode); + }, + authMiddleware({ permissions: ["sales.opportunity.product.add"] }), +); diff --git a/src/api/sales/[id]/resequenceProducts.ts b/src/api/sales/[id]/resequenceProducts.ts index 15f174c..3994938 100644 --- a/src/api/sales/[id]/resequenceProducts.ts +++ b/src/api/sales/[id]/resequenceProducts.ts @@ -24,17 +24,10 @@ export default createRoute( const item = await opportunities.fetchItem(identifier); const updated = await item.resequenceProducts(orderedIds); - // Map original IDs to the new IDs returned by ConnectWise - const idMap: Record = {}; - for (let i = 0; i < orderedIds.length; i++) { - idMap[orderedIds[i]!] = updated[i]!.cwForecastId; - } - const response = apiResponse.successful( "Product sequence updated successfully!", { products: updated.map((p) => p.toJson()), - idMap, }, ); diff --git a/src/api/sales/index.ts b/src/api/sales/index.ts index 5772067..d723e36 100644 --- a/src/api/sales/index.ts +++ b/src/api/sales/index.ts @@ -4,6 +4,7 @@ import { default as count } from "./count"; import { default as fetch } from "./[id]/fetch"; import { default as refresh } from "./[id]/refresh"; import { default as products } from "./[id]/products"; +import { default as addProduct } from "./[id]/addProduct"; import { default as resequenceProducts } from "./[id]/resequenceProducts"; import { default as notes } from "./[id]/notes"; import { default as fetchNote } from "./[id]/fetchNote"; @@ -13,6 +14,7 @@ import { default as deleteNote } from "./[id]/deleteNote"; import { default as contacts } from "./[id]/contacts"; export { + addProduct, count, fetch, fetchAll, diff --git a/src/controllers/OpportunityController.ts b/src/controllers/OpportunityController.ts index 26687c7..0bc5e03 100644 --- a/src/controllers/OpportunityController.ts +++ b/src/controllers/OpportunityController.ts @@ -11,6 +11,7 @@ import { } from "../modules/cw-utils/sites/companySites"; import { CWCustomField, + CWForecastItemCreate, CWOpportunity, CWOpportunityNote, } from "../modules/cw-utils/opportunities/opportunity.types"; @@ -78,6 +79,10 @@ export class OpportunityController { public companyId: string | null; public cwLastUpdated: Date | null; + // Local product display order — array of CW forecast item IDs. + // When non-empty, fetchProducts() uses this instead of CW sequenceNumber. + public productSequence: number[]; + public readonly createdAt: Date; public updatedAt: Date; @@ -145,6 +150,7 @@ export class OpportunityController { this.companyId = data.companyId; this.cwLastUpdated = data.cwLastUpdated; + this.productSequence = data.productSequence; this.createdAt = data.createdAt; this.updatedAt = data.updatedAt; @@ -400,16 +406,36 @@ export class OpportunityController { } } - const controllers = (forecast.forecastItems ?? []) - .sort((a, b) => a.sequenceNumber - b.sequenceNumber) - .map((item) => { - const ctrl = new ForecastProductController(item); - const procData = cancellationMap.get(item.id); - if (procData) { - ctrl.applyCancellationData(procData as any); - } - return ctrl; - }); + // Apply local ordering if productSequence is set, otherwise fall back + // to CW sequenceNumber. + const forecastItems = forecast.forecastItems ?? []; + let ordered: typeof forecastItems; + + if (this.productSequence.length > 0) { + const itemById = new Map(forecastItems.map((fi) => [fi.id, fi])); + // Items in the specified order first, then any new items not yet sequenced + const sequenced = this.productSequence + .map((id) => itemById.get(id)) + .filter((fi): fi is NonNullable => fi !== undefined); + const sequencedIds = new Set(this.productSequence); + const unsequenced = forecastItems + .filter((fi) => !sequencedIds.has(fi.id)) + .sort((a, b) => a.sequenceNumber - b.sequenceNumber); + ordered = [...sequenced, ...unsequenced]; + } else { + ordered = [...forecastItems].sort( + (a, b) => a.sequenceNumber - b.sequenceNumber, + ); + } + + const controllers = ordered.map((item) => { + const ctrl = new ForecastProductController(item); + const procData = cancellationMap.get(item.id); + if (procData) { + ctrl.applyCancellationData(procData as any); + } + return ctrl; + }); // Enrich with internal inventory data from local CatalogItem DB const catalogCwIds = controllers @@ -556,25 +582,22 @@ export class OpportunityController { /** * Resequence Products * - * Updates the sequenceNumber on each forecast item to match the - * order provided. Fetches the current items first so the PUT - * includes all required fields. Expects an array of forecast item - * IDs in the desired order. + * Stores the desired display order of forecast item IDs locally in + * the database. No CW API calls are made — CW item IDs are stable + * and ordering is applied when `fetchProducts()` is called. * - * @param orderedIds - Forecast item IDs in the desired sequence order + * @param orderedIds - Forecast item IDs in the desired display order */ public async resequenceProducts( orderedIds: number[], ): Promise { - // Fetch existing items so we can include required fields in the PUT + // Validate all IDs exist in CW const forecast = await opportunityCw.fetchProducts(this.cwOpportunityId); - const itemMap = new Map( - (forecast.forecastItems ?? []).map((fi) => [fi.id, fi]), + const existingIds = new Set( + (forecast.forecastItems ?? []).map((fi) => fi.id), ); - - // Validate all IDs exist before making any updates for (const id of orderedIds) { - if (!itemMap.has(id)) { + if (!existingIds.has(id)) { throw new GenericError({ status: 404, name: "ForecastItemNotFound", @@ -583,43 +606,60 @@ export class OpportunityController { } } - // Run updates in reverse order to CW - const results: ForecastProductController[] = new Array(orderedIds.length); - for (let index = orderedIds.length - 1; index >= 0; index--) { - const id = orderedIds[index]!; - const existing = itemMap.get(id)!; - const raw = JSON.parse(JSON.stringify(existing)) as Record< - string, - unknown - >; + // Persist the sequence locally + await prisma.opportunity.update({ + where: { id: this.id }, + data: { productSequence: orderedIds }, + }); + this.productSequence = orderedIds; - // Strip read-only _info fields at top level and nested sub-objects - delete raw._info; - for (const key of ["opportunity", "status", "catalogItem"]) { - if (raw[key] && typeof raw[key] === "object") { - delete (raw[key] as Record)._info; - } - } - - const newSeq = index + 1; - - const result = await this.updateProduct(id, { - ...raw, - sequenceNumber: newSeq, - }); - - results[index] = result; - } - return results; + // Return items in the new order + return this.fetchProducts(); } /** - * Add Product + * Add Products * - * Adds a new product/line item to this opportunity. + * Adds one or more products/line items to this opportunity via the + * ConnectWise forecast endpoint. The caller passes only the fields + * the user is permitted to set (already filtered by field-level + * permission gating in the route handler). + * + * Accepts a single item or an array of items. */ - public async addProduct(): Promise { - // TODO: implement + public async addProducts( + data: CWForecastItemCreate | CWForecastItemCreate[], + ): Promise { + try { + const created = await opportunityCw.createProducts( + this.cwOpportunityId, + data, + ); + return created.map((item) => new ForecastProductController(item)); + } catch (err: any) { + console.error( + `[addProducts] Failed to create forecast item(s) on opportunity ${this.cwOpportunityId}`, + JSON.stringify( + { + data, + status: err?.response?.status, + statusText: err?.response?.statusText, + responseData: err?.response?.data, + message: err?.message, + }, + null, + 2, + ), + ); + throw new GenericError({ + status: err?.response?.status ?? 500, + name: "AddProductFailed", + message: + err?.response?.data?.message ?? + "Failed to add product(s) to opportunity", + cause: err?.message, + }); + } } /** @@ -751,6 +791,7 @@ export class OpportunityController { : null, companyId: this.companyId, cwLastUpdated: this.cwLastUpdated, + productSequence: this.productSequence, createdAt: this.createdAt, updatedAt: this.updatedAt, customFields: this._customFields ?? [], diff --git a/src/modules/cw-utils/opportunities/opportunities.ts b/src/modules/cw-utils/opportunities/opportunities.ts index 503384d..9c5e8ea 100644 --- a/src/modules/cw-utils/opportunities/opportunities.ts +++ b/src/modules/cw-utils/opportunities/opportunities.ts @@ -5,6 +5,7 @@ import { CWOpportunitySummary, CWForecast, CWForecastItem, + CWForecastItemCreate, CWOpportunityNote, CWOpportunityNoteCreate, CWOpportunityNoteUpdate, @@ -118,27 +119,137 @@ export const opportunityCw = { const response = await connectWiseApi.get( `/sales/opportunities/${opportunityId}/forecast`, ); - - console.log( - `[CW fetchProducts] Opportunity ${opportunityId} forecast raw data:`, - JSON.stringify(response.data, null, 2), - ); return response.data; }, + /** + * Create Forecast Items + * + * Adds one or more forecast items (products) to an opportunity using + * POST. The CW forecast endpoint expects a Forecast object with a + * `forecastItems` array — we wrap just the new items inside that + * structure so existing items are never sent or touched. + */ + createProducts: async ( + opportunityId: number, + data: CWForecastItemCreate | CWForecastItemCreate[], + ): Promise => { + const items_to_add = Array.isArray(data) ? data : [data]; + const url = `/sales/opportunities/${opportunityId}/forecast`; + + // 1. Fetch existing forecast to derive defaults & diff IDs later + const existing = await opportunityCw.fetchProducts(opportunityId); + const existingIds = new Set( + (existing.forecastItems ?? []).map((fi) => fi.id), + ); + + // Derive sensible defaults from an existing item when available + const templateItem = (existing.forecastItems ?? [])[0]; + const defaultStatus = templateItem?.status + ? { id: templateItem.status.id } + : { id: 1 }; + const defaultForecastType = templateItem?.forecastType ?? "Product"; + + // 2. Build forecast items with required CW fields filled in + const forecastItems = items_to_add.map((newItem) => ({ + opportunity: { id: opportunityId }, + status: defaultStatus, + forecastType: defaultForecastType, + ...(newItem as Record), + })); + + // 3. POST a Forecast wrapper containing only the new items + const response = await connectWiseApi.post(url, { forecastItems }); + const updatedForecast: CWForecast = response.data; + + // 4. Find newly-created item(s) by diffing IDs + const newItems = (updatedForecast.forecastItems ?? []).filter( + (fi) => !existingIds.has(fi.id), + ); + + // Fall back to the last N items if ID diffing finds nothing + return newItems.length > 0 + ? newItems + : (updatedForecast.forecastItems ?? []).slice(-items_to_add.length); + }, + /** * Update Forecast Item * - * Updates a single forecast item (product) on an opportunity using PUT. + * PATCHes a single forecast item on the parent `/forecast` endpoint. + * CW supports JSON Patch with paths like `/forecastItems/{index}/field`. + * This preserves item IDs (unlike PUT which always regenerates them) + * and does NOT recalculate revenue/cost from linked catalog items. + * + * NOTE: Not all fields are patchable — `sequenceNumber` and `quantity` + * are read-only on forecast items. Product ordering is managed locally + * via `OpportunityController.resequenceProducts()` and stored in the + * database `productSequence` field. */ updateProduct: async ( opportunityId: number, forecastItemId: number, data: Record, ): Promise => { - const url = `/sales/opportunities/${opportunityId}/forecast/${forecastItemId}`; - const response = await connectWiseApi.put(url, data); - return response.data; + const forecast = await opportunityCw.fetchProducts(opportunityId); + const items = forecast.forecastItems ?? []; + const idx = items.findIndex((fi) => fi.id === forecastItemId); + if (idx === -1) { + throw new Error( + `Forecast item ${forecastItemId} not found on opportunity ${opportunityId}`, + ); + } + + const operations = Object.entries(data).map(([key, value]) => ({ + op: "replace" as const, + path: `/forecastItems/${idx}/${key}`, + value, + })); + + const url = `/sales/opportunities/${opportunityId}/forecast`; + const response = await connectWiseApi.patch(url, operations); + const updated: CWForecast = response.data; + return (updated.forecastItems ?? [])[idx]!; + }, + + /** + * Bulk-update Forecast Items + * + * PATCHes multiple forecast items in a single request via the parent + * `/forecast` endpoint. All patch operations are sent in one array. + */ + bulkUpdateProducts: async ( + opportunityId: number, + updates: Map>, + ): Promise => { + const forecast = await opportunityCw.fetchProducts(opportunityId); + const items = forecast.forecastItems ?? []; + + const operations: { op: "replace"; path: string; value: unknown }[] = []; + const touchedIndices: number[] = []; + + for (const [itemId, changes] of updates) { + const idx = items.findIndex((fi) => fi.id === itemId); + if (idx === -1) { + throw new Error( + `Forecast item ${itemId} not found on opportunity ${opportunityId}`, + ); + } + touchedIndices.push(idx); + for (const [key, value] of Object.entries(changes)) { + operations.push({ + op: "replace", + path: `/forecastItems/${idx}/${key}`, + value, + }); + } + } + + const url = `/sales/opportunities/${opportunityId}/forecast`; + const response = await connectWiseApi.patch(url, operations); + const updated: CWForecast = response.data; + + return touchedIndices.map((i) => (updated.forecastItems ?? [])[i]!); }, /** diff --git a/src/modules/cw-utils/opportunities/opportunity.types.ts b/src/modules/cw-utils/opportunities/opportunity.types.ts index 73f2474..6279a32 100644 --- a/src/modules/cw-utils/opportunities/opportunity.types.ts +++ b/src/modules/cw-utils/opportunities/opportunity.types.ts @@ -206,6 +206,26 @@ export interface CWOpportunityContact { _info?: Record; } +export interface CWForecastItemCreate { + catalogItem?: { id: number }; + forecastDescription?: string; + productDescription?: string; + quantity?: number; + status?: { id: number }; + productClass?: string; + forecastType?: string; + revenue?: number; + cost?: number; + includeFlag?: boolean; + linkFlag?: boolean; + recurringFlag?: boolean; + taxableFlag?: boolean; + recurringRevenue?: number; + recurringCost?: number; + cycles?: number; + sequenceNumber?: number; +} + export interface CWOpportunitySummary { id: number; _info?: Record; diff --git a/src/types/PermissionNodes.ts b/src/types/PermissionNodes.ts index 4d5f8f2..5c12bd6 100644 --- a/src/types/PermissionNodes.ts +++ b/src/types/PermissionNodes.ts @@ -440,6 +440,32 @@ export const PERMISSION_NODES = { usedIn: ["src/api/sales/[id]/resequenceProducts.ts"], dependencies: ["sales.opportunity.fetch"], }, + { + node: "sales.opportunity.product.add", + description: + "Add a new product (forecast item) to an opportunity. Individual fields are gated by sales.opportunity.product.field. permissions.", + usedIn: ["src/api/sales/[id]/addProduct.ts"], + dependencies: ["sales.opportunity.fetch"], + fieldLevelPermissions: [ + "sales.opportunity.product.field.catalogItem", + "sales.opportunity.product.field.forecastDescription", + "sales.opportunity.product.field.productDescription", + "sales.opportunity.product.field.quantity", + "sales.opportunity.product.field.status", + "sales.opportunity.product.field.productClass", + "sales.opportunity.product.field.forecastType", + "sales.opportunity.product.field.revenue", + "sales.opportunity.product.field.cost", + "sales.opportunity.product.field.includeFlag", + "sales.opportunity.product.field.linkFlag", + "sales.opportunity.product.field.recurringFlag", + "sales.opportunity.product.field.taxableFlag", + "sales.opportunity.product.field.recurringRevenue", + "sales.opportunity.product.field.recurringCost", + "sales.opportunity.product.field.cycles", + "sales.opportunity.product.field.sequenceNumber", + ], + }, ], }, diff --git a/test-forecast-resequence.ts b/test-forecast-resequence.ts new file mode 100644 index 0000000..1975be6 --- /dev/null +++ b/test-forecast-resequence.ts @@ -0,0 +1,442 @@ +/** + * Test Script: Forecast Item Resequencing & Procurement Linkage + * + * Validates the CW forecast API behaviour discovered via probing: + * - `sequenceNumber` is read-only — display order = array position + * - PUT always regenerates all forecast item IDs + * - Revenue & cost are preserved through PUT + * - PATCH on /forecast with `/forecastItems/{idx}/field` paths works + * for some fields (e.g. forecastDescription) and preserves IDs + * + * Test flow: + * 1. Create opportunity under XYZ Test Company + * 2. Add 4 products via POST + * 3. Create procurement products (linked by forecastDetailId) + * 4. Cancel one procurement product + * 5. Reorder forecast items via PUT (reverse order) + * 6. Remap procurement forecastDetailId to new IDs + * 7. Verify: order correct, prices preserved, cancellation data intact + * 8. Clean up + * + * Usage: bun run test-forecast-resequence.ts + */ +import axios from "axios"; + +const cw = axios.create({ + baseURL: "https://ttscw.totaltech.net/v4_6_release/apis/3.0/", + headers: { + Authorization: `Basic ${process.env.CW_BASIC_TOKEN}`, + clientId: `${process.env.CW_CLIENT_ID}`, + "Content-Type": "application/json", + }, +}); + +const log = (label: string, ...args: unknown[]) => + console.log(`\n[${label}]`, ...args); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + // ── 1. Find company ───────────────────────────────────────────────────── + log("SETUP", "Finding XYZ Test Company..."); + const compRes = await cw.get( + `/company/companies?conditions=${encodeURIComponent("name like 'XYZ Test%'")}&fields=id,identifier,name`, + ); + if (compRes.data.length === 0) { + console.error("ERROR: 'XYZ Test Company' not found."); + process.exit(1); + } + const company = compRes.data[0]; + log("SETUP", `Company: ${company.name} (id=${company.id})`); + + // ── 2. Create opportunity ─────────────────────────────────────────────── + log("SETUP", "Creating test opportunity..."); + const oppRes = await cw.post("/sales/opportunities", { + name: `[TEST] Resequence – ${new Date().toISOString().slice(0, 16)}`, + company: { id: company.id }, + contact: { id: 1 }, + primarySalesRep: { id: 153 }, + expectedCloseDate: new Date(Date.now() + 30 * 86_400_000) + .toISOString() + .replace(/\.\d{3}Z$/, "Z"), + }); + const oppId = oppRes.data.id; + log("SETUP", `Created opportunity id=${oppId}`); + + const forecastUrl = `/sales/opportunities/${oppId}/forecast`; + + // Track IDs for cleanup + const procIdsToClean: number[] = []; + + try { + // ── 3. Add 4 products ─────────────────────────────────────────────────── + log("PRODUCTS", "Adding 4 products..."); + const postRes = await cw.post(forecastUrl, { + forecastItems: [ + { + opportunity: { id: oppId }, + status: { id: 1 }, + forecastDescription: "Alpha", + revenue: 100, + cost: 50, + forecastType: "Product", + }, + { + opportunity: { id: oppId }, + status: { id: 1 }, + forecastDescription: "Bravo", + revenue: 250, + cost: 125, + forecastType: "Product", + }, + { + opportunity: { id: oppId }, + status: { id: 1 }, + forecastDescription: "Charlie", + revenue: 30, + cost: 10, + forecastType: "Product", + }, + { + opportunity: { id: oppId }, + status: { id: 1 }, + forecastDescription: "Delta", + revenue: 75, + cost: 40, + forecastType: "Product", + }, + ], + }); + const items: any[] = postRes.data.forecastItems ?? []; + log("PRODUCTS", `Created ${items.length} items:`); + for (const it of items) { + console.log( + ` id=${it.id} desc="${it.forecastDescription}" rev=${it.revenue} cost=${it.cost}`, + ); + } + + // Snapshot prices + const priceSnap = new Map( + items.map((i) => [ + i.forecastDescription, + { rev: i.revenue, cost: i.cost }, + ]), + ); + + // ── 4. Create procurement products ────────────────────────────────────── + log("PROCUREMENT", "Creating procurement products..."); + const procProducts: any[] = []; + for (const item of items) { + try { + const pr = await cw.post("/procurement/products", { + catalogItem: { id: 87 }, + description: item.forecastDescription, + quantity: 1, + price: item.revenue, + cost: item.cost, + billableOption: "Billable", + opportunity: { id: oppId }, + forecastDetailId: item.id, + }); + procProducts.push(pr.data); + procIdsToClean.push(pr.data.id); + console.log( + ` ✓ Proc ${pr.data.id} → forecastDetailId=${pr.data.forecastDetailId} "${item.forecastDescription}"`, + ); + } catch (e: any) { + console.log( + ` ✗ Failed: ${e.response?.status} ${JSON.stringify(e.response?.data)}`, + ); + } + } + + if (procProducts.length === 0) { + log( + "PROCUREMENT", + "Could not create procurement products (permission issue?).", + ); + log( + "PROCUREMENT", + "Will run reorder test without cancellation verification.", + ); + } + + // ── 5. Cancel "Bravo" procurement product ─────────────────────────────── + const bravoProc = procProducts.find((p: any) => p.description === "Bravo"); + if (bravoProc) { + log("CANCEL", `Cancelling Bravo (proc id=${bravoProc.id})...`); + try { + await cw.patch(`/procurement/products/${bravoProc.id}`, [ + { op: "replace", path: "cancelledFlag", value: true }, + { op: "replace", path: "quantityCancelled", value: 1 }, + { + op: "replace", + path: "cancelledReason", + value: "Test cancellation", + }, + ]); + log("CANCEL", "✓ Cancelled."); + } catch (e: any) { + log( + "CANCEL", + `✗ ${e.response?.status} ${JSON.stringify(e.response?.data)}`, + ); + } + } + + // ── 5b. Check for auto-created forecast items ───────────────────────── + await sleep(300); + const midForecast = await cw.get(forecastUrl); + const midItems = midForecast.data.forecastItems ?? []; + log( + "OBSERVE", + `Forecast items after procurement creation: ${midItems.length} (was ${items.length})`, + ); + if (midItems.length !== items.length) { + log( + "OBSERVE", + "⚠ Creating procurement products auto-created additional forecast items!", + ); + for (const mi of midItems) { + const isOriginal = items.some((i: any) => i.id === mi.id); + console.log( + ` id=${mi.id} desc="${mi.forecastDescription}" ${isOriginal ? "(original)" : "(AUTO-CREATED by procurement)"}`, + ); + } + } + + // Snapshot procurement state before reorder + const beforeProc = await cw.get( + `/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,description`, + ); + // Build map by description for cross-PUT comparison (IDs will change) + const beforeByDesc = new Map(); + log( + "SNAPSHOT", + `${beforeProc.data.length} procurement products before reorder:`, + ); + for (const p of beforeProc.data) { + beforeByDesc.set(p.description, p); + console.log( + ` Proc ${p.id}: forecastDetailId=${p.forecastDetailId} cancelled=${p.cancelledFlag} qty=${p.quantityCancelled} reason="${p.cancelledReason ?? ""}" "${p.description}"`, + ); + } + + // Record old procurement IDs for later comparison + const oldProcIds = new Set(beforeProc.data.map((p: any) => p.id)); + + // ── 6. Reorder: reverse ONLY the original 4 forecast items ────────────── + log("REORDER", "Reversing forecast item order via PUT..."); + + // Only reorder the original items; keep any auto-created ones in place + const originalDescs = new Set(items.map((i: any) => i.forecastDescription)); + const originals = midItems.filter( + (i: any) => + originalDescs.has(i.forecastDescription) && + items.some((o: any) => o.id === i.id), + ); + const extras = midItems.filter( + (i: any) => !originals.some((o: any) => o.id === i.id), + ); + + const reversedOriginals = [...originals].reverse(); + const reorderedAll = [...reversedOriginals, ...extras]; + + const clone = JSON.parse(JSON.stringify(midForecast.data)); + clone.forecastItems = JSON.parse(JSON.stringify(reorderedAll)); + + const putRes = await cw.put(forecastUrl, clone); + const newItems: any[] = putRes.data.forecastItems ?? []; + + log("REORDER", `After PUT (${newItems.length} items):`); + for (const it of newItems) { + console.log( + ` id=${it.id} desc="${it.forecastDescription}" rev=${it.revenue} cost=${it.cost}`, + ); + } + + // Build old→new ID map by position (for original items only) + const idMap = new Map(); + for (let i = 0; i < reversedOriginals.length && i < newItems.length; i++) { + idMap.set(reversedOriginals[i].id, newItems[i].id); + } + log("ID MAP", "Forecast item Old → New:"); + for (const [oldId, newId] of idMap) { + console.log(` ${oldId} → ${newId}`); + } + + // ── 7. Check if procurement products survived PUT ─────────────────────── + await sleep(300); + const afterProc = await cw.get( + `/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,description`, + ); + const newProcIds = new Set(afterProc.data.map((p: any) => p.id)); + + log( + "PROCUREMENT SURVIVAL", + "Checking if procurement product IDs survived PUT...", + ); + const procSurvived = [...oldProcIds].every((id) => newProcIds.has(id)); + if (procSurvived) { + console.log(" ✓ All original procurement product IDs survived PUT."); + } else { + console.log(" ✗ PUT REGENERATED procurement product IDs!"); + console.log(` Before: [${[...oldProcIds].join(", ")}]`); + console.log(` After: [${[...newProcIds].join(", ")}]`); + } + + // Try remap if old IDs still exist + let remapOk = true; + if (procSurvived) { + log("REMAP", "Updating procurement products forecastDetailId..."); + for (const pp of beforeProc.data) { + const oldFdId = pp.forecastDetailId as number; + const newFdId = idMap.get(oldFdId); + if (!newFdId || newFdId === oldFdId) continue; + try { + await cw.patch(`/procurement/products/${pp.id}`, [ + { op: "replace", path: "forecastDetailId", value: newFdId }, + ]); + console.log( + ` ✓ Proc ${pp.id}: forecastDetailId ${oldFdId} → ${newFdId}`, + ); + } catch (e: any) { + remapOk = false; + console.log( + ` ✗ Proc ${pp.id} remap failed: ${e.response?.status} ${JSON.stringify(e.response?.data)}`, + ); + } + } + } else { + remapOk = false; + log( + "REMAP", + "⚠ SKIPPED — procurement products were regenerated by PUT; old IDs no longer exist.", + ); + } + + // ── 8. Verify ─────────────────────────────────────────────────────────── + await sleep(300); + + // 8a. Verify order (first 4 items) + log("VERIFY ORDER", "Expected reverse: Delta, Charlie, Bravo, Alpha"); + const expectedOrder = ["Delta", "Charlie", "Bravo", "Alpha"]; + let orderOk = true; + for (let i = 0; i < expectedOrder.length; i++) { + const actual = newItems[i]?.forecastDescription; + const ok = actual === expectedOrder[i]; + if (!ok) orderOk = false; + console.log( + ` Position ${i}: ${ok ? "✓" : "✗"} expected "${expectedOrder[i]}", got "${actual}"`, + ); + } + + // 8b. Verify prices (by description) + log("VERIFY PRICES", ""); + let pricesOk = true; + for (const item of newItems) { + const orig = priceSnap.get(item.forecastDescription); + if (!orig) continue; + if (item.revenue !== orig.rev || item.cost !== orig.cost) { + pricesOk = false; + console.log( + ` ✗ "${item.forecastDescription}": rev ${orig.rev}→${item.revenue}, cost ${orig.cost}→${item.cost}`, + ); + } + } + if (pricesOk) console.log(" ✓ All prices preserved."); + + // 8c. Verify cancellation data — match by description since IDs may have changed + let cancelOk = true; + if (procProducts.length > 0) { + log( + "VERIFY CANCELLATION", + "Checking cancellation data on procurement products after PUT...", + ); + const finalProc = await cw.get( + `/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${oppId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,description`, + ); + + // Track by procIdsToClean for cleanup + for (const p of finalProc.data) { + if (!procIdsToClean.includes(p.id)) procIdsToClean.push(p.id); + } + + for (const pp of finalProc.data) { + const orig = beforeByDesc.get(pp.description); + if (!orig) { + console.log( + ` ? Proc ${pp.id} "${pp.description}" — no matching pre-PUT record`, + ); + continue; + } + + const cancelledMatch = + pp.cancelledFlag === orig.cancelledFlag && + pp.quantityCancelled === orig.quantityCancelled && + (pp.cancelledReason ?? "") === (orig.cancelledReason ?? ""); + + if (!cancelledMatch) { + cancelOk = false; + console.log( + ` ✗ Proc ${pp.id} "${pp.description}": CANCELLATION DATA CHANGED\n` + + ` Before: cancelled=${orig.cancelledFlag} qty=${orig.quantityCancelled} reason="${orig.cancelledReason ?? ""}"\n` + + ` After: cancelled=${pp.cancelledFlag} qty=${pp.quantityCancelled} reason="${pp.cancelledReason ?? ""}"`, + ); + } else { + console.log( + ` ✓ Proc ${pp.id} "${pp.description}": cancelled=${pp.cancelledFlag} qty=${pp.quantityCancelled} reason="${pp.cancelledReason ?? ""}"`, + ); + } + } + } + + // ── Summary ───────────────────────────────────────────────────────────── + log("SUMMARY", ""); + console.log( + " Order correct: ", + orderOk ? "✓ PASS" : "✗ FAIL", + ); + console.log( + " Prices preserved: ", + pricesOk ? "✓ PASS" : "✗ FAIL", + ); + console.log( + " Proc IDs survived PUT: ", + procSurvived ? "✓ PASS" : "✗ FAIL", + ); + console.log( + " Procurement remap: ", + remapOk ? "✓ PASS" : "✗ FAIL (skipped or failed)", + ); + console.log( + " Cancellation data preserved:", + cancelOk ? "✓ PASS" : "✗ FAIL", + ); + + const allPass = orderOk && pricesOk && procSurvived && remapOk && cancelOk; + log("RESULT", allPass ? "✓ ALL TESTS PASSED" : "✗ SOME TESTS FAILED"); + } finally { + // ── Cleanup ───────────────────────────────────────────────────────────── + log("CLEANUP", "Deleting procurement products..."); + for (const id of procIdsToClean) { + try { + await cw.delete(`/procurement/products/${id}`); + } catch {} + } + log("CLEANUP", `Deleted ${procIdsToClean.length} procurement products.`); + + log("CLEANUP", `Deleting opportunity ${oppId}...`); + try { + await cw.delete(`/sales/opportunities/${oppId}`); + log("CLEANUP", "✓ Done."); + } catch (e: any) { + log("CLEANUP", `✗ ${e.response?.status ?? e.message}`); + } + } +} + +main().catch((err) => { + console.error("\n[FATAL]", err.response?.data ?? err.message); + process.exit(1); +}); diff --git a/tests/unit/catalogCategories.test.ts b/tests/unit/catalogCategories.test.ts index d74dafd..72f6d2b 100644 --- a/tests/unit/catalogCategories.test.ts +++ b/tests/unit/catalogCategories.test.ts @@ -243,9 +243,9 @@ describe("catalogCategories", () => { // ------------------------------------------------------------------- describe("matchesEcosystem()", () => { test("matches Ubiquiti Network-Switch to Networking", () => { - expect( - matchesEcosystem("Networking", "Ubiquiti", "Network-Switch"), - ).toBe(true); + expect(matchesEcosystem("Networking", "Ubiquiti", "Network-Switch")).toBe( + true, + ); }); test("matches Uniview Surveillance-CamerasIP to Video Surveillance", () => { diff --git a/tests/unit/memberCache.test.ts b/tests/unit/memberCache.test.ts index 44d14e8..2f0d28a 100644 --- a/tests/unit/memberCache.test.ts +++ b/tests/unit/memberCache.test.ts @@ -35,7 +35,15 @@ describe("memberCache", () => { test("stores and retrieves members", async () => { const members = new Collection(); members.set("jroberts", buildTestMember()); - members.set("asmith", buildTestMember({ id: 20, identifier: "asmith", firstName: "Alice", lastName: "Smith" })); + members.set( + "asmith", + buildTestMember({ + id: 20, + identifier: "asmith", + firstName: "Alice", + lastName: "Smith", + }), + ); await setMemberCache(members); const cached = await getMemberCache(); @@ -67,7 +75,10 @@ describe("memberCache", () => { test("falls back to identifier if name parts are empty", async () => { const members = new Collection(); - members.set("empty", buildTestMember({ identifier: "empty", firstName: "", lastName: "" })); + members.set( + "empty", + buildTestMember({ identifier: "empty", firstName: "", lastName: "" }), + ); await setMemberCache(members); expect(resolveMemberName("empty")).toBe("empty");