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
+75
View File
@@ -0,0 +1,75 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import {
AddCartItemDto,
CheckoutCartDto,
UpdateCartItemDto,
} from './dto/cart.dto';
import { CartService } from './cart.service';
@Controller('businesses/:businessId/cart')
@UseGuards(JwtAuthGuard)
export class CartController {
constructor(private readonly service: CartService) {}
@Get()
getCart(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
return this.service.getCart(businessId, user);
}
@Post('items')
addItem(
@Param('businessId') businessId: string,
@Body() dto: AddCartItemDto,
@CurrentUser() user: AuthUser,
) {
return this.service.addItem(businessId, dto, user);
}
@Patch('items/:itemId')
updateItem(
@Param('businessId') businessId: string,
@Param('itemId') itemId: string,
@Body() dto: UpdateCartItemDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateItem(businessId, itemId, dto, user);
}
@Delete('items/:itemId')
removeItem(
@Param('businessId') businessId: string,
@Param('itemId') itemId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.removeItem(businessId, itemId, user);
}
@Delete()
clear(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
return this.service.clear(businessId, user);
}
@Post('checkout')
checkout(
@Param('businessId') businessId: string,
@Body() dto: CheckoutCartDto,
@CurrentUser() user: AuthUser,
) {
return this.service.checkout(businessId, dto, user);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { OrdersModule } from '../orders/orders.module';
import { CartController } from './cart.controller';
import { CartService } from './cart.service';
@Module({
imports: [AuthModule, OrdersModule],
controllers: [CartController],
providers: [CartService],
})
export class CartModule {}
+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');
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import {
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { CreateTransactionPaymentDto } from '../../transactions/dto/transaction.dto';
export class AddCartItemDto {
@IsString()
@MinLength(1)
storeItemVariantId!: string;
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
quantity?: number;
}
export class UpdateCartItemDto {
@IsInt()
@Min(1)
@Type(() => Number)
quantity!: number;
}
export class ShippingAddressDto {
@IsString()
@MinLength(1)
province!: string;
@IsString()
@MinLength(1)
city!: string;
@IsString()
@MinLength(1)
address!: string;
@IsOptional()
@IsString()
@MaxLength(20)
postalCode?: string;
@IsOptional()
@IsString()
landline?: string;
}
export class CheckoutCartDto {
@IsOptional()
@IsString()
@MinLength(1)
addressId?: string;
@IsOptional()
@ValidateNested()
@Type(() => ShippingAddressDto)
shippingAddress?: ShippingAddressDto;
@IsOptional()
@IsString()
customerNotes?: string;
@ValidateNested()
@Type(() => CreateTransactionPaymentDto)
payment!: CreateTransactionPaymentDto;
}