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,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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user