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