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
+22
View File
@@ -0,0 +1,22 @@
import { IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator';
import { Type } from 'class-transformer';
export class ListFavoritesDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
}
export class AddFavoriteDto {
@IsString()
@MinLength(1)
productId!: string;
}
+48
View File
@@ -0,0 +1,48 @@
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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AddFavoriteDto, ListFavoritesDto } from './dto/favorite.dto';
import { FavoritesService } from './favorites.service';
@Controller('businesses/:businessId/favorites')
@UseGuards(JwtAuthGuard)
export class FavoritesController {
constructor(private readonly service: FavoritesService) {}
@Get()
list(
@Param('businessId') businessId: string,
@Query() query: ListFavoritesDto,
@CurrentUser() user: AuthUser,
) {
return this.service.list(businessId, query, user);
}
@Post()
add(
@Param('businessId') businessId: string,
@Body() dto: AddFavoriteDto,
@CurrentUser() user: AuthUser,
) {
return this.service.add(businessId, dto, user);
}
@Delete(':productId')
remove(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.remove(businessId, productId, user);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { FavoritesController } from './favorites.controller';
import { FavoritesService } from './favorites.service';
@Module({
imports: [AuthModule],
controllers: [FavoritesController],
providers: [FavoritesService],
})
export class FavoritesModule {}
+351
View File
@@ -0,0 +1,351 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ContentStatus, MediaEntityType, Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { AddFavoriteDto, ListFavoritesDto } from './dto/favorite.dto';
const favoriteProductInclude = {
product: {
include: {
featuredMedia: true,
},
},
} satisfies Prisma.FavoriteInclude;
type FavoriteWithProduct = Prisma.FavoriteGetPayload<{
include: typeof favoriteProductInclude;
}>;
type StoreSummary = {
variantCount: number;
productTotalStock: number;
displayPrice: number | null;
displayDiscountedPrice: number | null;
showFestival: boolean;
};
@Injectable()
export class FavoritesService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
async list(
businessIdRaw: string,
query: ListFavoritesDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertCustomerAccess(businessId, actor);
const page = query.page ?? 1;
const pageSize = Math.min(Math.max(query.pageSize ?? 20, 1), 100);
const skip = (page - 1) * pageSize;
const where = {
businessId,
userId: actor.id,
};
const [items, total] = await Promise.all([
this.prisma.favorite.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
include: favoriteProductInclude,
}),
this.prisma.favorite.count({ where }),
]);
const productIds = items.map((item) => item.productId);
const galleryProductIds = items
.filter((item) => !item.product.featuredMedia?.publicUrl)
.map((item) => item.productId);
const [storeSummaries, galleryByProduct] = await Promise.all([
this.loadStoreSummaries(businessId, productIds),
this.loadFirstGalleryUrls(businessId, [...new Set(galleryProductIds)]),
]);
return {
items: items.map((item) =>
this.serialize(
item,
storeSummaries.get(item.productId.toString()),
galleryByProduct,
),
),
total,
page,
pageSize,
};
}
async add(businessIdRaw: string, dto: AddFavoriteDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const productId = BigInt(dto.productId);
await this.assertCustomerAccess(businessId, actor);
await this.assertFavoritableProduct(businessId, productId);
const existing = await this.prisma.favorite.findUnique({
where: {
businessId_userId_productId: {
businessId,
userId: actor.id,
productId,
},
},
include: favoriteProductInclude,
});
if (existing) {
throw new ConflictException('Product is already in favorites');
}
const created = await this.prisma.favorite.create({
data: {
businessId,
userId: actor.id,
productId,
},
include: favoriteProductInclude,
});
const [storeSummaries, galleryByProduct] = await Promise.all([
this.loadStoreSummaries(businessId, [productId]),
created.product.featuredMedia?.publicUrl
? Promise.resolve(new Map<string, string>())
: this.loadFirstGalleryUrls(businessId, [productId]),
]);
return {
favorite: this.serialize(
created,
storeSummaries.get(productId.toString()),
galleryByProduct,
),
message: 'Product added to favorites',
};
}
async remove(businessIdRaw: string, productIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const productId = BigInt(productIdRaw);
await this.assertCustomerAccess(businessId, actor);
const favorite = await this.prisma.favorite.findUnique({
where: {
businessId_userId_productId: {
businessId,
userId: actor.id,
productId,
},
},
});
if (!favorite) {
throw new NotFoundException('Favorite not found');
}
await this.prisma.favorite.delete({
where: { id: favorite.id },
});
return { message: 'Product removed from favorites' };
}
private async assertFavoritableProduct(businessId: bigint, productId: bigint) {
const product = await this.prisma.product.findFirst({
where: { id: productId, businessId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
if (product.status !== ContentStatus.published) {
throw new BadRequestException('Only published products can be favorited');
}
}
private async loadStoreSummaries(businessId: bigint, productIds: bigint[]) {
const summaries = new Map<string, StoreSummary>();
if (!productIds.length) {
return summaries;
}
const rows = await this.prisma.storeItemVariant.findMany({
where: {
businessId,
isActive: true,
storeItem: {
productId: { in: productIds },
},
},
select: {
price: true,
compareAtPrice: true,
stockQuantity: true,
isFestival: true,
rewardPoints: true,
storeItem: {
select: {
productId: true,
},
},
},
});
const grouped = new Map<string, typeof rows>();
for (const row of rows) {
const key = row.storeItem.productId.toString();
const variants = grouped.get(key) ?? [];
variants.push(row);
grouped.set(key, variants);
}
for (const [productKey, variants] of grouped) {
let productTotalStock = 0;
let showFestival = false;
let displayPrice: number | null = null;
let displayDiscountedPrice: number | null = null;
let minEffective = Infinity;
for (const variant of variants) {
productTotalStock += variant.stockQuantity ?? 0;
if (variant.isFestival || (variant.rewardPoints ?? 0) > 0) {
showFestival = true;
}
const price = variant.price === null ? null : Number(variant.price);
const compareAtPrice =
variant.compareAtPrice === null ? null : Number(variant.compareAtPrice);
const effectivePrice =
price !== null &&
compareAtPrice !== null &&
compareAtPrice < price
? compareAtPrice
: price;
if (effectivePrice === null || effectivePrice >= minEffective) {
continue;
}
minEffective = effectivePrice;
if (
price !== null &&
compareAtPrice !== null &&
compareAtPrice < price
) {
displayPrice = price;
displayDiscountedPrice = compareAtPrice;
} else {
displayPrice = price;
displayDiscountedPrice = null;
}
}
if (minEffective === Infinity) {
const first = variants[0];
displayPrice = first?.price === null ? null : Number(first.price);
displayDiscountedPrice =
first?.compareAtPrice === null ? null : Number(first.compareAtPrice);
}
summaries.set(productKey, {
variantCount: variants.length,
productTotalStock,
displayPrice,
displayDiscountedPrice,
showFestival,
});
}
return summaries;
}
private async loadFirstGalleryUrls(businessId: bigint, productIds: bigint[]) {
const map = new Map<string, string>();
if (!productIds.length) {
return map;
}
const attachments = await this.prisma.mediaAttachment.findMany({
where: {
businessId,
entityType: MediaEntityType.product,
entityId: { in: productIds },
isFeatured: false,
},
orderBy: [{ entityId: 'asc' }, { sortOrder: 'asc' }],
include: { media: true },
});
for (const attachment of attachments) {
const key = attachment.entityId.toString();
if (!map.has(key)) {
map.set(key, attachment.media.publicUrl);
}
}
return map;
}
private serialize(
favorite: FavoriteWithProduct,
summary?: StoreSummary,
galleryByProduct: Map<string, string> = new Map(),
) {
const product = favorite.product;
const content = this.asRecord(product.content);
const productKey = product.id.toString();
const thumbnailUrl = product.featuredMedia?.publicUrl ?? null;
return {
favoriteId: favorite.id.toString(),
productId: productKey,
createdAt: favorite.createdAt,
productTitle: product.title,
productNameFa: (content.nameFa as string | null | undefined) ?? '',
productImage: thumbnailUrl ?? galleryByProduct.get(productKey) ?? null,
productTotalStock: summary?.productTotalStock ?? 0,
variantCount: summary?.variantCount ?? 0,
displayPrice: summary?.displayPrice ?? null,
displayDiscountedPrice: summary?.displayDiscountedPrice ?? null,
showFestival: summary?.showFestival ?? false,
};
}
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');
}
}
}