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:
Ali Reza
2026-07-21 17:52:36 +03:30
commit bb59d5e9ba
254 changed files with 37031 additions and 0 deletions
+404
View File
@@ -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');
}
}
}