diff --git a/prisma/migrations/0006_cart/migration.sql b/prisma/migrations/0006_cart/migration.sql new file mode 100644 index 0000000..016aae5 --- /dev/null +++ b/prisma/migrations/0006_cart/migration.sql @@ -0,0 +1,34 @@ +CREATE TABLE "Cart" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "deliveryAddressId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cart_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "CartItem" ( + "id" TEXT NOT NULL, + "cartId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "productName" TEXT NOT NULL, + "sku" TEXT NOT NULL, + "isCustomizable" BOOLEAN NOT NULL DEFAULT false, + "quantity" DECIMAL(14,3) NOT NULL, + "parameters" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CartItem_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "Cart_userId_key" ON "Cart"("userId"); +CREATE INDEX "Cart_deliveryAddressId_idx" ON "Cart"("deliveryAddressId"); +CREATE UNIQUE INDEX "CartItem_cartId_productId_key" ON "CartItem"("cartId", "productId"); +CREATE INDEX "CartItem_productId_idx" ON "CartItem"("productId"); + +ALTER TABLE "Cart" ADD CONSTRAINT "Cart_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Cart" ADD CONSTRAINT "Cart_deliveryAddressId_fkey" FOREIGN KEY ("deliveryAddressId") REFERENCES "DeliveryAddress"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "CartItem" ADD CONSTRAINT "CartItem_cartId_fkey" FOREIGN KEY ("cartId") REFERENCES "Cart"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "CartItem" ADD CONSTRAINT "CartItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index be391b5..6a98e47 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -70,6 +70,7 @@ model User { sentInvitations Invitation[] @relation("InvitationManager") acceptedInvitations Invitation[] @relation("InvitationAcceptor") messengerConnections MessengerConnection[] + cart Cart? clientOrders Order[] @relation("OrderClient") managerOrders Order[] @relation("OrderManager") orderStatusEvents OrderStatusEvent[] @@ -91,6 +92,7 @@ model DeliveryAddress { unrestrictedValue String? fiasId String? defaultForUsers User[] @relation("UserDefaultDeliveryAddress") + carts Cart[] orders Order[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -179,11 +181,43 @@ model Product { isCustomizable Boolean @default(false) isActive Boolean @default(true) inventory ProductStock[] + cartItems CartItem[] orderItems OrderItem[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } +model Cart { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + deliveryAddressId String? + deliveryAddress DeliveryAddress? @relation(fields: [deliveryAddressId], references: [id], onDelete: SetNull) + items CartItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([deliveryAddressId]) +} + +model CartItem { + id String @id @default(cuid()) + cartId String + cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) + productId String + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + productName String + sku String + isCustomizable Boolean @default(false) + quantity Decimal @db.Decimal(14, 3) + parameters Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([cartId, productId]) + @@index([productId]) +} + model Warehouse { id String @id @default(cuid()) code String @unique diff --git a/src/resolvers.js b/src/resolvers.js index 22e042a..c1be655 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -76,6 +76,15 @@ function normalizeOptionalText(value) { return normalized ? normalized : null; } +function normalizeQuantityValue(value) { + const normalized = Number(value); + if (!Number.isFinite(normalized) || normalized <= 0) { + return 0; + } + + return Math.floor(normalized); +} + function isCounterpartyProfileComplete(profile) { if (!profile) { return false; @@ -134,6 +143,38 @@ function withDeliveryAddressDefaultFlag(address, defaultDeliveryAddressId) { }; } +function defaultCartParameters(product) { + return { + width: product.widthMm ?? 100, + thickness: product.thicknessMicron ?? 50, + color: 'прозрачный', + }; +} + +const cartInclude = { + items: { + orderBy: [{ updatedAt: 'desc' }, { createdAt: 'desc' }], + }, + deliveryAddress: true, +}; + +async function getOrCreateCart(context, userId) { + const account = await context.prisma.user.findUnique({ + where: { id: userId }, + select: { defaultDeliveryAddressId: true }, + }); + + return context.prisma.cart.upsert({ + where: { userId }, + update: {}, + create: { + userId, + deliveryAddressId: account?.defaultDeliveryAddressId ?? null, + }, + include: cartInclude, + }); +} + async function enrichMessengerConnectionProfile(prisma, connection) { if ( connection.type !== 'TELEGRAM' || @@ -306,6 +347,32 @@ export const resolvers = { DeliveryAddress: { isDefault: (address) => Boolean(address.isDefault), }, + Cart: { + deliveryAddress: async (cart, _, context) => { + if (!cart.deliveryAddressId) { + return null; + } + + const [account, address] = await Promise.all([ + context.prisma.user.findUnique({ + where: { id: cart.userId }, + select: { defaultDeliveryAddressId: true }, + }), + context.prisma.deliveryAddress.findUnique({ + where: { id: cart.deliveryAddressId }, + }), + ]); + + if (!address) { + return null; + } + + return withDeliveryAddressDefaultFlag(address, account?.defaultDeliveryAddressId ?? null); + }, + }, + CartItem: { + quantity: (item) => toFloat(item.quantity), + }, Query: { healthcheck: () => 'ok', @@ -319,6 +386,11 @@ export const resolvers = { }); }, + myCart: async (_, __, context) => { + const user = requireUser(context); + return getOrCreateCart(context, user.id); + }, + myDeliveryAddresses: async (_, __, context) => { const user = requireUser(context); const [account, addresses] = await Promise.all([ @@ -678,6 +750,171 @@ export const resolvers = { }); }, + addProductToCart: async (_, { productId }, context) => { + const user = requireUser(context); + const normalizedProductId = normalizeText(productId); + if (!normalizedProductId) { + throw new Error('Product id is required.'); + } + + const product = await context.prisma.product.findFirst({ + where: { + id: normalizedProductId, + isActive: true, + }, + }); + + if (!product) { + throw new Error('Product is not available.'); + } + + const cart = await getOrCreateCart(context, user.id); + const existingItem = await context.prisma.cartItem.findFirst({ + where: { + cartId: cart.id, + productId: normalizedProductId, + }, + }); + + if (existingItem) { + await context.prisma.cartItem.update({ + where: { id: existingItem.id }, + data: { + quantity: Number(existingItem.quantity) + 1, + productName: product.name, + sku: product.sku, + isCustomizable: product.isCustomizable, + }, + }); + } else { + await context.prisma.cartItem.create({ + data: { + cartId: cart.id, + productId: product.id, + productName: product.name, + sku: product.sku, + isCustomizable: product.isCustomizable, + quantity: 1, + parameters: defaultCartParameters(product), + }, + }); + } + + return context.prisma.cart.findUnique({ + where: { id: cart.id }, + include: cartInclude, + }); + }, + + updateCartItemQuantity: async (_, { input }, context) => { + const user = requireUser(context); + const normalizedProductId = normalizeText(input.productId); + if (!normalizedProductId) { + throw new Error('Product id is required.'); + } + + const quantity = normalizeQuantityValue(input.quantity); + const cart = await getOrCreateCart(context, user.id); + const existingItem = await context.prisma.cartItem.findFirst({ + where: { + cartId: cart.id, + productId: normalizedProductId, + }, + }); + + if (!existingItem) { + return cart; + } + + if (quantity === 0) { + await context.prisma.cartItem.delete({ + where: { id: existingItem.id }, + }); + } else { + await context.prisma.cartItem.update({ + where: { id: existingItem.id }, + data: { quantity }, + }); + } + + return context.prisma.cart.findUnique({ + where: { id: cart.id }, + include: cartInclude, + }); + }, + + removeCartItem: async (_, { productId }, context) => { + const user = requireUser(context); + const normalizedProductId = normalizeText(productId); + if (!normalizedProductId) { + throw new Error('Product id is required.'); + } + + const cart = await getOrCreateCart(context, user.id); + const existingItem = await context.prisma.cartItem.findFirst({ + where: { + cartId: cart.id, + productId: normalizedProductId, + }, + }); + + if (existingItem) { + await context.prisma.cartItem.delete({ + where: { id: existingItem.id }, + }); + } + + return context.prisma.cart.findUnique({ + where: { id: cart.id }, + include: cartInclude, + }); + }, + + setCartDeliveryAddress: async (_, { addressId }, context) => { + const user = requireUser(context); + const cart = await getOrCreateCart(context, user.id); + const normalizedAddressId = normalizeOptionalText(addressId); + + if (!normalizedAddressId) { + return context.prisma.cart.update({ + where: { id: cart.id }, + data: { deliveryAddressId: null }, + include: cartInclude, + }); + } + + const address = await context.prisma.deliveryAddress.findFirst({ + where: { + id: normalizedAddressId, + userId: user.id, + }, + }); + + if (!address) { + throw new Error('Delivery address is not available for this user.'); + } + + return context.prisma.cart.update({ + where: { id: cart.id }, + data: { deliveryAddressId: address.id }, + include: cartInclude, + }); + }, + + clearCart: async (_, __, context) => { + const user = requireUser(context); + const cart = await getOrCreateCart(context, user.id); + + await context.prisma.cartItem.deleteMany({ + where: { cartId: cart.id }, + }); + + return context.prisma.cart.findUnique({ + where: { id: cart.id }, + include: cartInclude, + }); + }, + createMyDeliveryAddress: async (_, { input }, context) => { const user = requireUser(context); const payload = toDeliveryAddressInputData(input); diff --git a/src/schema.graphql b/src/schema.graphql index a0c2b32..2da2997 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -191,6 +191,28 @@ type Product { availableInWarehouses: [ProductWarehouseBalance!]! } +type CartItem { + id: ID! + productId: ID! + productName: String! + sku: String! + isCustomizable: Boolean! + quantity: Float! + parameters: JSON! + createdAt: DateTime! + updatedAt: DateTime! +} + +type Cart { + id: ID! + userId: ID! + deliveryAddressId: ID + deliveryAddress: DeliveryAddress + items: [CartItem!]! + createdAt: DateTime! + updatedAt: DateTime! +} + type OrderItem { id: ID! productId: ID @@ -266,6 +288,7 @@ type Query { healthcheck: String! me: User myCounterpartyProfile: CounterpartyProfile + myCart: Cart! myDeliveryAddresses: [DeliveryAddress!]! myMessengerConnections: [MessengerConnection!]! myNotificationHistory(channel: MessengerType!, limit: Int = 50): [NotificationHistoryItem!]! @@ -340,6 +363,11 @@ input CreateMyDeliveryAddressInput { fiasId: String } +input UpdateCartItemQuantityInput { + productId: ID! + quantity: Float! +} + input ReadyOrderItemInput { productId: ID! quantity: Float! @@ -400,6 +428,11 @@ type Mutation { acceptInvitation(input: AcceptInvitationInput!): User! connectMessenger(input: ConnectMessengerInput!): MessengerConnection! upsertMyCounterpartyProfile(input: UpsertMyCounterpartyProfileInput!): CounterpartyProfile! + addProductToCart(productId: ID!): Cart! + updateCartItemQuantity(input: UpdateCartItemQuantityInput!): Cart! + removeCartItem(productId: ID!): Cart! + setCartDeliveryAddress(addressId: ID): Cart! + clearCart: Cart! createMyDeliveryAddress(input: CreateMyDeliveryAddressInput!): DeliveryAddress! setMyDefaultDeliveryAddress(addressId: ID!): DeliveryAddress! deleteMyDeliveryAddress(addressId: ID!): Boolean!