mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-12 06:40:58 +04:30
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,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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user