From 2cd8d0b6129bc1d62735154fb800338214f98d28 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:03:32 +0700 Subject: [PATCH] Add catalog product type settings --- .../migration.sql | 20 ++++ prisma/schema.prisma | 14 +++ src/resolvers.js | 113 ++++++++++++++++++ src/schema.graphql | 24 ++++ 4 files changed, 171 insertions(+) create mode 100644 prisma/migrations/0011_add_catalog_product_type_settings/migration.sql diff --git a/prisma/migrations/0011_add_catalog_product_type_settings/migration.sql b/prisma/migrations/0011_add_catalog_product_type_settings/migration.sql new file mode 100644 index 0000000..5ccdb2d --- /dev/null +++ b/prisma/migrations/0011_add_catalog_product_type_settings/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "CatalogProductTypeSetting" ( + "id" TEXT NOT NULL, + "productType" TEXT NOT NULL, + "showQuantityPerBox" BOOLEAN NOT NULL DEFAULT false, + "allowCustomLength" BOOLEAN NOT NULL DEFAULT false, + "customLengthMinM" INTEGER, + "customLengthMaxM" INTEGER, + "customLengthStepM" INTEGER, + "allowCustomSleeveBrand" BOOLEAN NOT NULL DEFAULT false, + "allowCustomLabel" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CatalogProductTypeSetting_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CatalogProductTypeSetting_productType_key" ON "CatalogProductTypeSetting"("productType"); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d3de7e0..1c0f18f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -190,6 +190,20 @@ model Product { updatedAt DateTime @updatedAt } +model CatalogProductTypeSetting { + id String @id @default(cuid()) + productType String @unique + showQuantityPerBox Boolean @default(false) + allowCustomLength Boolean @default(false) + customLengthMinM Int? + customLengthMaxM Int? + customLengthStepM Int? + allowCustomSleeveBrand Boolean @default(false) + allowCustomLabel Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Cart { id String @id @default(cuid()) userId String @unique diff --git a/src/resolvers.js b/src/resolvers.js index 23ab472..aa19999 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -352,6 +352,19 @@ function normalizeQuantityValue(value) { return Math.floor(normalized); } +function normalizeOptionalPositiveInt(value) { + if (value == null || value === '') { + return null; + } + + const normalized = Number(value); + if (!Number.isInteger(normalized) || normalized <= 0) { + throw new Error('Catalog setting values must be positive integers.'); + } + + return normalized; +} + function isCounterpartyProfileComplete(profile) { if (!profile) { return false; @@ -418,6 +431,91 @@ function defaultCartParameters(product) { }; } +function presentCatalogProductTypeSetting(setting) { + return { + productType: setting.productType, + showQuantityPerBox: Boolean(setting.showQuantityPerBox), + allowCustomLength: Boolean(setting.allowCustomLength), + customLengthMinM: setting.customLengthMinM ?? null, + customLengthMaxM: setting.customLengthMaxM ?? null, + customLengthStepM: setting.customLengthStepM ?? null, + allowCustomSleeveBrand: Boolean(setting.allowCustomSleeveBrand), + allowCustomLabel: Boolean(setting.allowCustomLabel), + }; +} + +function normalizeCatalogProductTypeSettingInput(input) { + const productType = normalizeText(input.productType); + if (!productType) { + throw new Error('Product type is required.'); + } + + const allowCustomLength = Boolean(input.allowCustomLength); + const customLengthMinM = allowCustomLength ? normalizeOptionalPositiveInt(input.customLengthMinM) : null; + const customLengthMaxM = allowCustomLength ? normalizeOptionalPositiveInt(input.customLengthMaxM) : null; + const customLengthStepM = allowCustomLength ? normalizeOptionalPositiveInt(input.customLengthStepM) : null; + + if (allowCustomLength) { + if (customLengthMinM == null || customLengthMaxM == null || customLengthStepM == null) { + throw new Error('Length customization requires min, max, and step values.'); + } + + if (customLengthMinM > customLengthMaxM) { + throw new Error('Length min must be less than or equal to max.'); + } + } + + return { + productType, + showQuantityPerBox: Boolean(input.showQuantityPerBox), + allowCustomLength, + customLengthMinM, + customLengthMaxM, + customLengthStepM, + allowCustomSleeveBrand: Boolean(input.allowCustomSleeveBrand), + allowCustomLabel: Boolean(input.allowCustomLabel), + }; +} + +async function listCatalogProductTypeSettings(prisma) { + const [products, persistedSettings] = await Promise.all([ + prisma.product.findMany({ + where: { + isActive: true, + NOT: { + productType: null, + }, + }, + select: { productType: true }, + distinct: ['productType'], + orderBy: { productType: 'asc' }, + }), + prisma.catalogProductTypeSetting.findMany({ + orderBy: { productType: 'asc' }, + }), + ]); + + const productTypes = new Set([ + ...products.map((item) => normalizeText(item.productType)).filter(Boolean), + ...persistedSettings.map((item) => item.productType), + ]); + const settingsByType = new Map(persistedSettings.map((item) => [item.productType, item])); + + return [...productTypes] + .sort((a, b) => a.localeCompare(b, 'ru')) + .map((productType) => presentCatalogProductTypeSetting({ + productType, + showQuantityPerBox: false, + allowCustomLength: false, + customLengthMinM: null, + customLengthMaxM: null, + customLengthStepM: null, + allowCustomSleeveBrand: false, + allowCustomLabel: false, + ...settingsByType.get(productType), + })); +} + const cartInclude = { items: { orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }], @@ -864,6 +962,8 @@ export const resolvers = { orderBy: { name: 'asc' }, }), + catalogProductTypeSettings: async (_, __, context) => listCatalogProductTypeSettings(context.prisma), + order: async (_, { id }, context) => { const user = requireUser(context); const order = await context.prisma.order.findUnique({ @@ -1440,6 +1540,19 @@ export const resolvers = { }); }, + upsertCatalogProductTypeSetting: async (_, { input }, context) => { + requireManagerAccess(context); + const payload = normalizeCatalogProductTypeSettingInput(input); + + const persisted = await context.prisma.catalogProductTypeSetting.upsert({ + where: { productType: payload.productType }, + update: payload, + create: payload, + }); + + return presentCatalogProductTypeSetting(persisted); + }, + addProductToCart: async (_, { productId }, context) => { const user = requireUser(context); const normalizedProductId = normalizeText(productId); diff --git a/src/schema.graphql b/src/schema.graphql index e1b6d96..38bb0bd 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -248,6 +248,17 @@ type Product { availableInWarehouses: [ProductWarehouseBalance!]! } +type CatalogProductTypeSetting { + productType: String! + showQuantityPerBox: Boolean! + allowCustomLength: Boolean! + customLengthMinM: Int + customLengthMaxM: Int + customLengthStepM: Int + allowCustomSleeveBrand: Boolean! + allowCustomLabel: Boolean! +} + type CartItem { id: ID! productId: ID! @@ -411,6 +422,7 @@ type Query { integrationSyncDashboard: IntegrationSyncDashboard! managerNotificationHistory(userId: ID!, channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]! clientProducts: [Product!]! + catalogProductTypeSettings: [CatalogProductTypeSetting!]! order(id: ID!): Order myOrders: [Order!]! myCurrentOrders: [Order!]! @@ -491,6 +503,17 @@ input UpdateCartItemQuantityInput { quantity: Float! } +input UpsertCatalogProductTypeSettingInput { + productType: String! + showQuantityPerBox: Boolean! + allowCustomLength: Boolean! + customLengthMinM: Int + customLengthMaxM: Int + customLengthStepM: Int + allowCustomSleeveBrand: Boolean! + allowCustomLabel: Boolean! +} + input ReadyOrderItemInput { productId: ID! quantity: Float! @@ -554,6 +577,7 @@ type Mutation { connectMessenger(input: ConnectMessengerInput!): MessengerConnection! deleteMyMessengerConnection(connectionId: ID!): Boolean! upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile! + upsertCatalogProductTypeSetting(input: UpsertCatalogProductTypeSettingInput!): CatalogProductTypeSetting! addProductToCart(productId: ID!): Cart! updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart! removeCartItem(productId: ID!): Cart!