Initial commit: Meshkee CMS API
NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ContentStatus, Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
AddCartItemDto,
|
||||
CheckoutCartDto,
|
||||
ShippingAddressDto,
|
||||
UpdateCartItemDto,
|
||||
} from './dto/cart.dto';
|
||||
import { OrdersService } from '../orders/orders.service';
|
||||
|
||||
const cartVariantInclude = {
|
||||
storeItem: {
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
featuredMedia: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
selections: {
|
||||
include: {
|
||||
variation: true,
|
||||
option: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.StoreItemVariantInclude;
|
||||
|
||||
type CartWithItems = Prisma.CartGetPayload<{
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
storeItemVariant: {
|
||||
include: typeof cartVariantInclude;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class CartService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly orders: OrdersService,
|
||||
) {}
|
||||
|
||||
async getCart(businessIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||
return { cart: this.serializeCart(cart) };
|
||||
}
|
||||
|
||||
async addItem(businessIdRaw: string, dto: AddCartItemDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const storeItemVariantId = BigInt(dto.storeItemVariantId);
|
||||
const quantity = dto.quantity ?? 1;
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
const variant = await this.loadPurchasableVariant(businessId, storeItemVariantId);
|
||||
this.assertStockAvailable(variant.stockQuantity, quantity);
|
||||
|
||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||
const existing = cart.items.find(
|
||||
(item) => item.storeItemVariantId === storeItemVariantId,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
const newQuantity = existing.quantity + quantity;
|
||||
this.assertStockAvailable(variant.stockQuantity, newQuantity);
|
||||
|
||||
await this.prisma.cartItem.update({
|
||||
where: { id: existing.id },
|
||||
data: { quantity: newQuantity },
|
||||
});
|
||||
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Cart item quantity updated',
|
||||
cart: this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
await this.prisma.cartItem.create({
|
||||
data: {
|
||||
cartId: cart.id,
|
||||
storeItemVariantId,
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Item added to cart',
|
||||
cart: this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
async updateItem(
|
||||
businessIdRaw: string,
|
||||
itemIdRaw: string,
|
||||
dto: UpdateCartItemDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const itemId = BigInt(itemIdRaw);
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||
const item = cart.items.find((entry) => entry.id === itemId);
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException('Cart item not found');
|
||||
}
|
||||
|
||||
this.assertStockAvailable(item.storeItemVariant.stockQuantity, dto.quantity);
|
||||
|
||||
await this.prisma.cartItem.update({
|
||||
where: { id: itemId },
|
||||
data: { quantity: dto.quantity },
|
||||
});
|
||||
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Cart item updated',
|
||||
cart: this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
async removeItem(businessIdRaw: string, itemIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const itemId = BigInt(itemIdRaw);
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||
const item = cart.items.find((entry) => entry.id === itemId);
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException('Cart item not found');
|
||||
}
|
||||
|
||||
await this.prisma.cartItem.delete({ where: { id: itemId } });
|
||||
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Cart item removed',
|
||||
cart: this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
async clear(businessIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||
await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } });
|
||||
|
||||
const refreshed = await this.loadCart(cart.id);
|
||||
return {
|
||||
message: 'Cart cleared',
|
||||
cart: this.serializeCart(refreshed),
|
||||
};
|
||||
}
|
||||
|
||||
async checkout(
|
||||
businessIdRaw: string,
|
||||
dto: CheckoutCartDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertCustomerAccess(businessId, actor);
|
||||
|
||||
const cart = await this.getOrCreateCart(businessId, actor.id);
|
||||
|
||||
if (!cart.items.length) {
|
||||
throw new BadRequestException('Cart is empty');
|
||||
}
|
||||
|
||||
const shippingAddress = await this.resolveShippingAddress(
|
||||
actor.id,
|
||||
dto.addressId,
|
||||
dto.shippingAddress,
|
||||
);
|
||||
|
||||
const order = await this.orders.createFromCart({
|
||||
businessId,
|
||||
userId: actor.id,
|
||||
createdBy: actor.id,
|
||||
source: 'website',
|
||||
cartItems: cart.items,
|
||||
shippingAddress,
|
||||
addressId: dto.addressId ? BigInt(dto.addressId) : null,
|
||||
customerNotes: dto.customerNotes?.trim() || null,
|
||||
adminNotes: null,
|
||||
status: 'pending',
|
||||
payment: {
|
||||
type: dto.payment.type,
|
||||
posType: dto.payment.posType,
|
||||
gatewayType: dto.payment.gatewayType,
|
||||
transferAccount: dto.payment.transferAccount,
|
||||
transferRefNumber: dto.payment.transferRefNumber,
|
||||
notes: dto.payment.notes?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } });
|
||||
|
||||
return {
|
||||
message: 'Order placed successfully',
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
private async getOrCreateCart(businessId: bigint, userId: bigint) {
|
||||
const existing = await this.prisma.cart.findUnique({
|
||||
where: {
|
||||
businessId_userId: { businessId, userId },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return this.loadCart(existing.id);
|
||||
}
|
||||
|
||||
const created = await this.prisma.cart.create({
|
||||
data: { businessId, userId },
|
||||
});
|
||||
|
||||
return this.loadCart(created.id);
|
||||
}
|
||||
|
||||
private async loadCart(cartId: bigint): Promise<CartWithItems> {
|
||||
return this.prisma.cart.findUniqueOrThrow({
|
||||
where: { id: cartId },
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
storeItemVariant: {
|
||||
include: cartVariantInclude,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async loadPurchasableVariant(
|
||||
businessId: bigint,
|
||||
storeItemVariantId: bigint,
|
||||
) {
|
||||
const variant = await this.prisma.storeItemVariant.findFirst({
|
||||
where: { id: storeItemVariantId, businessId, isActive: true },
|
||||
include: {
|
||||
storeItem: {
|
||||
include: {
|
||||
product: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!variant) {
|
||||
throw new NotFoundException('Store item variant not found or unavailable');
|
||||
}
|
||||
|
||||
if (variant.storeItem.product.status !== ContentStatus.published) {
|
||||
throw new BadRequestException('Product is not available for purchase');
|
||||
}
|
||||
|
||||
if (variant.price === null) {
|
||||
throw new BadRequestException('Store item variant has no price configured');
|
||||
}
|
||||
|
||||
return variant;
|
||||
}
|
||||
|
||||
private async resolveShippingAddress(
|
||||
userId: bigint,
|
||||
addressIdRaw: string | undefined,
|
||||
inline: ShippingAddressDto | undefined,
|
||||
) {
|
||||
if (addressIdRaw) {
|
||||
const address = await this.prisma.address.findFirst({
|
||||
where: { id: BigInt(addressIdRaw), userId },
|
||||
});
|
||||
|
||||
if (!address) {
|
||||
throw new NotFoundException('Shipping address not found');
|
||||
}
|
||||
|
||||
return {
|
||||
province: address.province,
|
||||
city: address.city,
|
||||
address: address.address,
|
||||
postalCode: address.postalCode,
|
||||
landline: address.landline,
|
||||
};
|
||||
}
|
||||
|
||||
if (!inline) {
|
||||
throw new BadRequestException(
|
||||
'Provide addressId or shippingAddress for checkout',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
province: inline.province.trim(),
|
||||
city: inline.city.trim(),
|
||||
address: inline.address.trim(),
|
||||
postalCode: inline.postalCode?.trim() || null,
|
||||
landline: inline.landline?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
private assertStockAvailable(stockQuantity: number | null, requested: number) {
|
||||
if (stockQuantity !== null && requested > stockQuantity) {
|
||||
throw new BadRequestException('Insufficient stock for this quantity');
|
||||
}
|
||||
}
|
||||
|
||||
private serializeCart(cart: CartWithItems) {
|
||||
const items = cart.items.map((item) => this.serializeCartItem(item));
|
||||
const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0);
|
||||
|
||||
return {
|
||||
id: cart.id.toString(),
|
||||
businessId: cart.businessId.toString(),
|
||||
items,
|
||||
itemCount: items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
subtotal,
|
||||
updatedAt: cart.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private serializeCartItem(item: CartWithItems['items'][number]) {
|
||||
const variant = item.storeItemVariant;
|
||||
const product = variant.storeItem.product;
|
||||
const content = this.asRecord(product.content);
|
||||
const price = Number(variant.price);
|
||||
const compareAtPrice =
|
||||
variant.compareAtPrice === null ? null : Number(variant.compareAtPrice);
|
||||
const effectivePrice =
|
||||
compareAtPrice !== null && compareAtPrice < price ? compareAtPrice : price;
|
||||
const selections = variant.selections.map((selection) => ({
|
||||
variationId: selection.variation.id.toString(),
|
||||
variationName: selection.variation.name,
|
||||
optionId: selection.option.id.toString(),
|
||||
value: selection.option.label,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
storeItemId: variant.storeItemId.toString(),
|
||||
storeItemVariantId: variant.id.toString(),
|
||||
productId: product.id.toString(),
|
||||
productTitle: product.title,
|
||||
productNameFa: (content.nameFa as string | null | undefined) ?? '',
|
||||
productImage: product.featuredMedia?.publicUrl ?? null,
|
||||
sku: variant.sku,
|
||||
selections,
|
||||
label: selections.map((entry) => entry.value).join(' · ') || product.title,
|
||||
quantity: item.quantity,
|
||||
unitPrice: effectivePrice,
|
||||
compareAtPrice: price,
|
||||
lineTotal: effectivePrice * item.quantity,
|
||||
stockQuantity: variant.stockQuantity,
|
||||
};
|
||||
}
|
||||
|
||||
private asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private async assertCustomerAccess(businessId: bigint, actor: AuthUser) {
|
||||
if (await this.permissions.isSuperAdmin(actor.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const membership = await this.prisma.businessCustomer.findUnique({
|
||||
where: {
|
||||
businessId_userId: { businessId, userId: actor.id },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a customer of this business');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user