Persist user carts in database

This commit is contained in:
Ruslan Bakiev
2026-04-04 09:08:42 +07:00
parent 6ea9d4e5b7
commit b01b2421b5
4 changed files with 338 additions and 0 deletions

View File

@@ -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);

View File

@@ -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!