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
@@ -0,0 +1,73 @@
import {
ArrayMinSize,
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsString,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class ShoppingCardItemInputDto {
@IsString()
@MinLength(1)
storeItemVariantId!: string;
@IsInt()
@Min(1)
@Type(() => Number)
quantity!: number;
}
export class CreateShoppingCardDto {
@IsString()
@MinLength(1)
customerUserId!: string;
@ValidateNested({ each: true })
@Type(() => ShoppingCardItemInputDto)
@ArrayMinSize(1)
items!: ShoppingCardItemInputDto[];
}
export class ListShoppingCardsDto {
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
pageSize?: number;
@IsOptional()
@IsString()
@MinLength(1)
customerQuery?: string;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
minTotal?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Type(() => Number)
maxTotal?: number;
}
@@ -0,0 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
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 {
CreateShoppingCardDto,
ListShoppingCardsDto,
} from './dto/shopping-card.dto';
import { ShoppingCardsService } from './shopping-cards.service';
@Controller('businesses/:businessId/shopping-cards')
@UseGuards(JwtAuthGuard)
export class ShoppingCardsController {
constructor(private readonly service: ShoppingCardsService) {}
@Get()
list(
@Param('businessId') businessId: string,
@Query() query: ListShoppingCardsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.list(businessId, query, user);
}
@Get(':cardId')
getOne(
@Param('businessId') businessId: string,
@Param('cardId') cardId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.getOne(businessId, cardId, user);
}
@Post()
@UseGuards(BusinessPermissionGuard)
@RequireBusinessPermission('orders.create')
create(
@Param('businessId') businessId: string,
@Body() dto: CreateShoppingCardDto,
@CurrentUser() user: AuthUser,
) {
return this.service.create(businessId, dto, user);
}
@Delete(':cardId')
@UseGuards(BusinessPermissionGuard)
@RequireBusinessPermission('orders.update')
remove(
@Param('businessId') businessId: string,
@Param('cardId') cardId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.remove(businessId, cardId, user);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ShoppingCardsController } from './shopping-cards.controller';
import { ShoppingCardsService } from './shopping-cards.service';
@Module({
imports: [AuthModule],
controllers: [ShoppingCardsController],
providers: [ShoppingCardsService],
exports: [ShoppingCardsService],
})
export class ShoppingCardsModule {}
@@ -0,0 +1,420 @@
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 {
CreateShoppingCardDto,
ListShoppingCardsDto,
} from './dto/shopping-card.dto';
type ShoppingCardWithItems = Prisma.ShoppingCardGetPayload<{
include: {
items: true;
customer: {
select: {
id: true;
firstName: true;
lastName: true;
cellNumber: true;
email: true;
};
};
};
}>;
type PreparedShoppingCardItem = {
storeItemVariantId: bigint;
productId: bigint;
productTitle: string;
variantSku: string | null;
unitPrice: number;
compareAtPrice: number;
quantity: number;
lineTotal: number;
selectionsSnapshot: {
variationId: string;
variationName: string;
optionId: string;
value: string;
}[];
};
@Injectable()
export class ShoppingCardsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
async list(
businessIdRaw: string,
query: ListShoppingCardsDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.read');
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where: Prisma.ShoppingCardWhereInput = {
businessId,
...(query.dateFrom || query.dateTo
? {
createdAt: {
...(query.dateFrom ? { gte: new Date(query.dateFrom) } : {}),
...(query.dateTo
? {
lte: (() => {
const end = new Date(query.dateTo);
end.setHours(23, 59, 59, 999);
return end;
})(),
}
: {}),
},
}
: {}),
...(query.minTotal !== undefined || query.maxTotal !== undefined
? {
total: {
...(query.minTotal !== undefined ? { gte: query.minTotal } : {}),
...(query.maxTotal !== undefined ? { lte: query.maxTotal } : {}),
},
}
: {}),
...(query.customerQuery?.trim()
? {
customer: this.buildCustomerSearchFilter(query.customerQuery.trim()),
}
: {}),
};
const [items, total] = await Promise.all([
this.prisma.shoppingCard.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
include: {
items: true,
customer: {
select: {
id: true,
firstName: true,
lastName: true,
cellNumber: true,
email: true,
},
},
},
}),
this.prisma.shoppingCard.count({ where }),
]);
return {
items: items.map((item) => this.serialize(item)),
total,
page,
pageSize,
};
}
async getOne(businessIdRaw: string, cardIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const cardId = BigInt(cardIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.read');
const card = await this.prisma.shoppingCard.findFirst({
where: { id: cardId, businessId },
include: {
items: true,
customer: {
select: {
id: true,
firstName: true,
lastName: true,
cellNumber: true,
email: true,
},
},
},
});
if (!card) {
throw new NotFoundException('Shopping card not found');
}
return { card: this.serialize(card) };
}
async create(
businessIdRaw: string,
dto: CreateShoppingCardDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.create');
const customerUserId = BigInt(dto.customerUserId);
await this.assertBusinessCustomer(businessId, customerUserId);
const preparedItems = await this.prepareItems(
businessId,
dto.items.map((item) => ({
storeItemVariantId: BigInt(item.storeItemVariantId),
quantity: item.quantity,
})),
);
const subtotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0);
const total = subtotal;
const created = await this.prisma.shoppingCard.create({
data: {
businessId,
userId: customerUserId,
subtotal,
total,
createdBy: actor.id,
items: {
create: preparedItems.map((item) => ({
storeItemVariantId: item.storeItemVariantId,
productId: item.productId,
productTitle: item.productTitle,
variantSku: item.variantSku,
unitPrice: item.unitPrice,
compareAtPrice: item.compareAtPrice,
quantity: item.quantity,
lineTotal: item.lineTotal,
selectionsSnapshot: item.selectionsSnapshot,
})),
},
},
include: {
items: true,
customer: {
select: {
id: true,
firstName: true,
lastName: true,
cellNumber: true,
email: true,
},
},
},
});
return {
message: 'Shopping card saved',
card: this.serialize(created),
};
}
async remove(businessIdRaw: string, cardIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const cardId = BigInt(cardIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.update');
const existing = await this.prisma.shoppingCard.findFirst({
where: { id: cardId, businessId },
select: { id: true },
});
if (!existing) {
throw new NotFoundException('Shopping card not found');
}
await this.prisma.shoppingCard.delete({ where: { id: cardId } });
return { message: 'Shopping card removed' };
}
private async prepareItems(
businessId: bigint,
items: { storeItemVariantId: bigint; quantity: number }[],
) {
if (!items.length) {
throw new BadRequestException('At least one item is required');
}
const prepared: PreparedShoppingCardItem[] = [];
for (const item of items) {
const variant = await this.prisma.storeItemVariant.findFirst({
where: {
id: item.storeItemVariantId,
businessId,
isActive: true,
},
include: {
storeItem: {
include: {
product: true,
},
},
selections: {
include: {
variation: true,
option: true,
},
},
},
});
if (!variant) {
throw new NotFoundException(
`Store item variant ${item.storeItemVariantId.toString()} not found or unavailable`,
);
}
const product = variant.storeItem.product;
if (product.status !== ContentStatus.published) {
throw new BadRequestException(
`Product "${product.title}" is not available for purchase`,
);
}
if (variant.price === null) {
throw new BadRequestException(
`Store item variant ${item.storeItemVariantId.toString()} has no price configured`,
);
}
const price = Number(variant.price);
const compareAtPrice =
variant.compareAtPrice === null ? null : Number(variant.compareAtPrice);
const unitPrice =
compareAtPrice !== null && compareAtPrice < price ? compareAtPrice : price;
const selectionsSnapshot = variant.selections.map((selection) => ({
variationId: selection.variation.id.toString(),
variationName: selection.variation.name,
optionId: selection.option.id.toString(),
value: selection.option.label,
}));
prepared.push({
storeItemVariantId: variant.id,
productId: product.id,
productTitle: product.title,
variantSku: variant.sku,
unitPrice,
compareAtPrice: price,
quantity: item.quantity,
lineTotal: unitPrice * item.quantity,
selectionsSnapshot,
});
}
return prepared;
}
private serialize(card: ShoppingCardWithItems) {
return {
id: card.id.toString(),
businessId: card.businessId.toString(),
subtotal: Number(card.subtotal),
total: Number(card.total),
createdBy: card.createdBy?.toString() ?? null,
createdAt: card.createdAt,
updatedAt: card.updatedAt,
customer: {
id: card.customer.id.toString(),
firstName: card.customer.firstName,
lastName: card.customer.lastName,
cellNumber: card.customer.cellNumber,
email: card.customer.email,
},
items: card.items.map((item) => ({
id: item.id.toString(),
storeItemVariantId: item.storeItemVariantId?.toString() ?? null,
productId: item.productId.toString(),
productTitle: item.productTitle,
variantSku: item.variantSku,
unitPrice: Number(item.unitPrice),
compareAtPrice:
item.compareAtPrice === null ? null : Number(item.compareAtPrice),
quantity: item.quantity,
lineTotal: Number(item.lineTotal),
selections: this.readSelections(item.selectionsSnapshot),
})),
};
}
private readSelections(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.map((entry) => {
if (!entry || typeof entry !== 'object') return null;
const record = entry as Record<string, unknown>;
const variationId =
typeof record.variationId === 'string' ? record.variationId : '';
const variationName =
typeof record.variationName === 'string' ? record.variationName : '';
const optionId = typeof record.optionId === 'string' ? record.optionId : '';
const label = typeof record.value === 'string' ? record.value : '';
if (!variationId || !optionId) return null;
return { variationId, variationName, optionId, value: label };
})
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
}
private buildCustomerSearchFilter(query: string): Prisma.UserWhereInput {
const digits = query.replace(/\D/g, '');
const or: Prisma.UserWhereInput[] = [
{ firstName: { contains: query, mode: 'insensitive' } },
{ lastName: { contains: query, mode: 'insensitive' } },
];
if (digits.length >= 3) {
or.push({ cellNumber: { contains: digits } });
if (digits.startsWith('0')) {
or.push({ cellNumber: { contains: `+98${digits.slice(1)}` } });
} else if (digits.startsWith('98')) {
or.push({ cellNumber: { contains: `+${digits}` } });
} else if (digits.length === 10 && digits.startsWith('9')) {
or.push({ cellNumber: { contains: `+98${digits}` } });
}
}
return { OR: or };
}
private async assertBusinessCustomer(businessId: bigint, userId: bigint) {
const membership = await this.prisma.businessCustomer.findUnique({
where: {
businessId_userId: { businessId, userId },
},
});
if (!membership) {
throw new BadRequestException('Customer is not registered for this business');
}
}
private async assertPermission(
businessId: bigint,
userId: bigint,
permission: string,
) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
permission,
);
if (!allowed) {
throw new ForbiddenException(
`Missing permission: ${permission} for this business`,
);
}
}
}