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
+100
View File
@@ -0,0 +1,100 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsBoolean,
IsEmail,
IsInt,
IsOptional,
IsString,
Max,
Min,
MinLength,
} from 'class-validator';
function trimStringArray({ value }: { value: unknown }) {
if (!Array.isArray(value)) {
return value;
}
return value
.map((item) => (typeof item === 'string' ? item.trim() : item))
.filter((item) => typeof item === 'string' && item.length > 0);
}
export class CreatePublicExpertReviewDto {
@IsString()
@MinLength(1)
productId!: string;
@IsString()
@MinLength(2)
authorName!: string;
@IsOptional()
@IsEmail()
authorEmail?: string;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(10)
rate!: number;
@IsArray()
@IsString({ each: true })
@ArrayMinSize(0)
@ArrayMaxSize(50)
@Transform(trimStringArray)
positivePoints!: string[];
@IsArray()
@IsString({ each: true })
@ArrayMinSize(0)
@ArrayMaxSize(50)
@Transform(trimStringArray)
negativePoints!: string[];
@IsString()
@MinLength(1)
text!: string;
}
export class ListPublicExpertReviewsDto {
@IsString()
@MinLength(1)
productId!: string;
}
export class ListExpertReviewsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@Transform(({ value }) => {
if (value === 'true' || value === true) return true;
if (value === 'false' || value === false) return false;
return value;
})
@IsBoolean()
isApproved?: boolean;
}
export class UpdateExpertReviewApprovalDto {
@IsBoolean()
isApproved!: boolean;
}
@@ -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 {
CreatePublicExpertReviewDto,
ListExpertReviewsDto,
ListPublicExpertReviewsDto,
UpdateExpertReviewApprovalDto,
} from './dto/expert-review.dto';
import { ExpertReviewsService } from './expert-reviews.service';
@Controller('tenants/:host/expert-reviews')
export class PublicExpertReviewsController {
constructor(private readonly service: ExpertReviewsService) {}
@Post()
create(@Param('host') host: string, @Body() dto: CreatePublicExpertReviewDto) {
return this.service.createPublic(host, dto);
}
@Get()
list(@Param('host') host: string, @Query() query: ListPublicExpertReviewsDto) {
return this.service.listPublic(host, query);
}
}
@Controller('businesses/:businessId/expert-reviews')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class ExpertReviewsController {
constructor(private readonly service: ExpertReviewsService) {}
@Get()
@RequireBusinessPermission('expert_reviews.read')
list(
@Param('businessId') businessId: string,
@Query() query: ListExpertReviewsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.listAdmin(businessId, query, user);
}
@Patch(':reviewId')
@RequireBusinessPermission('expert_reviews.approve')
updateApproval(
@Param('businessId') businessId: string,
@Param('reviewId') reviewId: string,
@Body() dto: UpdateExpertReviewApprovalDto,
@CurrentUser() user: AuthUser,
) {
return this.service.updateApproval(businessId, reviewId, dto, user);
}
@Delete(':reviewId')
@RequireBusinessPermission('expert_reviews.delete')
remove(
@Param('businessId') businessId: string,
@Param('reviewId') reviewId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.remove(businessId, reviewId, user);
}
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
import { TenantModule } from '../tenant/tenant.module';
import {
ExpertReviewsController,
PublicExpertReviewsController,
} from './expert-reviews.controller';
import { ExpertReviewsService } from './expert-reviews.service';
@Module({
imports: [AuthModule, BusinessSettingsModule, TenantModule],
controllers: [PublicExpertReviewsController, ExpertReviewsController],
providers: [ExpertReviewsService],
})
export class ExpertReviewsModule {}
@@ -0,0 +1,242 @@
import {
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 { BusinessSettingsService } from '../business-settings/business-settings.service';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import {
CreatePublicExpertReviewDto,
ListExpertReviewsDto,
ListPublicExpertReviewsDto,
UpdateExpertReviewApprovalDto,
} from './dto/expert-review.dto';
type ExpertReviewRecord = Prisma.ExpertReviewGetPayload<{
include: { approver: true; product: { select: { id: true; title: true; slug: true } } };
}>;
@Injectable()
export class ExpertReviewsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly tenant: TenantService,
private readonly businessSettings: BusinessSettingsService,
) {}
async createPublic(host: string, dto: CreatePublicExpertReviewDto) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const productId = BigInt(dto.productId);
await this.assertPublishedProductExists(businessId, productId);
const autoApprove =
await this.businessSettings.isExpertReviewsAutoApprove(businessId);
const approvedAt = autoApprove ? new Date() : null;
const created = await this.prisma.expertReview.create({
data: {
businessId,
productId,
authorName: dto.authorName.trim(),
authorEmail: dto.authorEmail?.trim() || null,
rate: dto.rate,
positivePoints: dto.positivePoints,
negativePoints: dto.negativePoints,
text: dto.text.trim(),
isApproved: autoApprove,
approvedAt,
},
include: this.defaultInclude(),
});
return {
review: this.serialize(created),
message: autoApprove
? 'Expert review submitted and is approved'
: 'Expert review submitted and is pending approval',
};
}
async listPublic(host: string, query: ListPublicExpertReviewsDto) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const productId = BigInt(query.productId);
await this.assertPublishedProductExists(businessId, productId);
const items = await this.prisma.expertReview.findMany({
where: {
businessId,
productId,
isApproved: true,
},
orderBy: { createdAt: 'desc' },
include: this.defaultInclude(),
});
return { items: items.map((item) => this.serialize(item)) };
}
async listAdmin(
businessIdRaw: string,
query: ListExpertReviewsDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'expert_reviews.read');
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where: Prisma.ExpertReviewWhereInput = {
businessId,
...(query.productId ? { productId: BigInt(query.productId) } : {}),
...(query.isApproved !== undefined ? { isApproved: query.isApproved } : {}),
};
const [items, total] = await Promise.all([
this.prisma.expertReview.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
include: this.defaultInclude(),
}),
this.prisma.expertReview.count({ where }),
]);
return {
items: items.map((item) => this.serialize(item)),
total,
page,
pageSize,
};
}
async updateApproval(
businessIdRaw: string,
reviewIdRaw: string,
dto: UpdateExpertReviewApprovalDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const reviewId = BigInt(reviewIdRaw);
await this.assertPermission(businessId, actor.id, 'expert_reviews.approve');
const existing = await this.prisma.expertReview.findFirst({
where: { id: reviewId, businessId },
include: this.defaultInclude(),
});
if (!existing) {
throw new NotFoundException('Expert review not found');
}
const updated = await this.prisma.expertReview.update({
where: { id: reviewId },
data: {
isApproved: dto.isApproved,
approvedAt: dto.isApproved ? new Date() : null,
approvedBy: dto.isApproved ? actor.id : null,
},
include: this.defaultInclude(),
});
return { review: this.serialize(updated) };
}
async remove(businessIdRaw: string, reviewIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const reviewId = BigInt(reviewIdRaw);
await this.assertPermission(businessId, actor.id, 'expert_reviews.delete');
const existing = await this.prisma.expertReview.findFirst({
where: { id: reviewId, businessId },
});
if (!existing) {
throw new NotFoundException('Expert review not found');
}
await this.prisma.expertReview.delete({ where: { id: reviewId } });
return { success: true };
}
private defaultInclude() {
return {
approver: true,
product: { select: { id: true, title: true, slug: true } },
} as const;
}
private async assertPublishedProductExists(businessId: bigint, productId: bigint) {
const product = await this.prisma.product.findFirst({
where: {
id: productId,
businessId,
status: ContentStatus.published,
},
select: { id: true },
});
if (!product) {
throw new NotFoundException('Product not found');
}
}
private async assertPermission(
businessId: bigint,
userId: bigint,
permission: string,
) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
permission,
);
if (!allowed) {
throw new ForbiddenException('Insufficient permissions');
}
}
private serialize(review: ExpertReviewRecord) {
return {
id: review.id.toString(),
businessId: review.businessId.toString(),
productId: review.productId.toString(),
product: {
id: review.product.id.toString(),
title: review.product.title,
slug: review.product.slug,
},
authorName: review.authorName,
authorEmail: review.authorEmail,
rate: review.rate,
positivePoints: review.positivePoints,
negativePoints: review.negativePoints,
text: review.text,
isApproved: review.isApproved,
approvedAt: review.approvedAt,
approvedBy: review.approvedBy?.toString() ?? null,
approver: review.approver
? {
id: review.approver.id.toString(),
firstName: review.approver.firstName,
lastName: review.approver.lastName,
}
: null,
createdAt: review.createdAt,
updatedAt: review.updatedAt,
};
}
}