mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +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,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 { CommentsService } from './comments.service';
|
||||
import {
|
||||
CreatePublicCommentDto,
|
||||
ListCommentsDto,
|
||||
ListPublicCommentsDto,
|
||||
UpdateCommentApprovalDto,
|
||||
} from './dto/comment.dto';
|
||||
|
||||
@Controller('tenants/:host/comments')
|
||||
export class PublicCommentsController {
|
||||
constructor(private readonly service: CommentsService) {}
|
||||
|
||||
@Post()
|
||||
create(@Param('host') host: string, @Body() dto: CreatePublicCommentDto) {
|
||||
return this.service.createPublic(host, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string, @Query() query: ListPublicCommentsDto) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('businesses/:businessId/comments')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class CommentsController {
|
||||
constructor(private readonly service: CommentsService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('comments.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListCommentsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.listAdmin(businessId, query, user);
|
||||
}
|
||||
|
||||
@Patch(':commentId')
|
||||
@RequireBusinessPermission('comments.approve')
|
||||
updateApproval(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('commentId') commentId: string,
|
||||
@Body() dto: UpdateCommentApprovalDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.updateApproval(businessId, commentId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':commentId')
|
||||
@RequireBusinessPermission('comments.delete')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('commentId') commentId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, commentId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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 { CommentsController, PublicCommentsController } from './comments.controller';
|
||||
import { CommentsService } from './comments.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BusinessSettingsModule, TenantModule],
|
||||
controllers: [PublicCommentsController, CommentsController],
|
||||
providers: [CommentsService],
|
||||
})
|
||||
export class CommentsModule {}
|
||||
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
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 { BusinessSettingsService } from '../business-settings/business-settings.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import {
|
||||
CreatePublicCommentDto,
|
||||
ListCommentsDto,
|
||||
ListPublicCommentsDto,
|
||||
UpdateCommentApprovalDto,
|
||||
} from './dto/comment.dto';
|
||||
|
||||
type CommentRecord = Prisma.CommentGetPayload<{
|
||||
include: { approver: true };
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
private readonly businessSettings: BusinessSettingsService,
|
||||
) {}
|
||||
|
||||
async createPublic(host: string, dto: CreatePublicCommentDto) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
const entityId = BigInt(dto.entityId);
|
||||
|
||||
await this.assertPublishedEntityExists(businessId, dto.entityType, entityId);
|
||||
|
||||
const autoApprove = await this.businessSettings.isCommentsAutoApprove(businessId);
|
||||
const approvedAt = autoApprove ? new Date() : null;
|
||||
|
||||
const created = await this.prisma.comment.create({
|
||||
data: {
|
||||
businessId,
|
||||
entityType: dto.entityType,
|
||||
entityId,
|
||||
authorName: dto.authorName.trim(),
|
||||
authorEmail: dto.authorEmail?.trim() || null,
|
||||
text: dto.text.trim(),
|
||||
isApproved: autoApprove,
|
||||
approvedAt,
|
||||
},
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return {
|
||||
comment: this.serialize(created),
|
||||
message: autoApprove
|
||||
? 'Comment submitted and is approved'
|
||||
: 'Comment submitted and is pending approval',
|
||||
};
|
||||
}
|
||||
|
||||
async listPublic(host: string, query: ListPublicCommentsDto) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const businessId = business.id;
|
||||
const entityId = BigInt(query.entityId);
|
||||
|
||||
await this.assertPublishedEntityExists(businessId, query.entityType, entityId);
|
||||
|
||||
const items = await this.prisma.comment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: query.entityType,
|
||||
entityId,
|
||||
isApproved: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return { items: items.map((item) => this.serialize(item)) };
|
||||
}
|
||||
|
||||
async listAdmin(businessIdRaw: string, query: ListCommentsDto, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'comments.read');
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.CommentWhereInput = {
|
||||
businessId,
|
||||
...(query.entityType ? { entityType: query.entityType } : {}),
|
||||
...(query.entityId ? { entityId: BigInt(query.entityId) } : {}),
|
||||
...(query.isApproved !== undefined ? { isApproved: query.isApproved } : {}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.comment.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: { approver: true },
|
||||
}),
|
||||
this.prisma.comment.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => this.serialize(item)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async updateApproval(
|
||||
businessIdRaw: string,
|
||||
commentIdRaw: string,
|
||||
dto: UpdateCommentApprovalDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const commentId = BigInt(commentIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'comments.approve');
|
||||
|
||||
const existing = await this.prisma.comment.findFirst({
|
||||
where: { id: commentId, businessId },
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.comment.update({
|
||||
where: { id: commentId },
|
||||
data: {
|
||||
isApproved: dto.isApproved,
|
||||
approvedAt: dto.isApproved ? new Date() : null,
|
||||
approvedBy: dto.isApproved ? actor.id : null,
|
||||
},
|
||||
include: { approver: true },
|
||||
});
|
||||
|
||||
return { comment: this.serialize(updated) };
|
||||
}
|
||||
|
||||
async remove(businessIdRaw: string, commentIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const commentId = BigInt(commentIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'comments.delete');
|
||||
|
||||
const existing = await this.prisma.comment.findFirst({
|
||||
where: { id: commentId, businessId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
}
|
||||
|
||||
await this.prisma.comment.delete({ where: { id: commentId } });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async assertPublishedEntityExists(
|
||||
businessId: bigint,
|
||||
entityType: MediaEntityType,
|
||||
entityId: bigint,
|
||||
) {
|
||||
if (entityType === MediaEntityType.product) {
|
||||
const product = await this.prisma.product.findFirst({
|
||||
where: {
|
||||
id: entityId,
|
||||
businessId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType === MediaEntityType.blog) {
|
||||
const blog = await this.prisma.blogs.findFirst({
|
||||
where: {
|
||||
id: entityId,
|
||||
business_id: businessId,
|
||||
status: ContentStatus.published,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!blog) {
|
||||
throw new NotFoundException('Blog post not found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = await this.prisma.$queryRaw<{ id: bigint }[]>`
|
||||
SELECT id FROM portfolios
|
||||
WHERE id = ${entityId}
|
||||
AND business_id = ${businessId}
|
||||
AND status = 'published'::content_status
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new NotFoundException('Portfolio item 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(comment: CommentRecord) {
|
||||
return {
|
||||
id: comment.id.toString(),
|
||||
businessId: comment.businessId.toString(),
|
||||
entityType: comment.entityType,
|
||||
entityId: comment.entityId.toString(),
|
||||
authorName: comment.authorName,
|
||||
authorEmail: comment.authorEmail,
|
||||
text: comment.text,
|
||||
isApproved: comment.isApproved,
|
||||
approvedAt: comment.approvedAt,
|
||||
approvedBy: comment.approvedBy?.toString() ?? null,
|
||||
approver: comment.approver
|
||||
? {
|
||||
id: comment.approver.id.toString(),
|
||||
firstName: comment.approver.firstName,
|
||||
lastName: comment.approver.lastName,
|
||||
}
|
||||
: null,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { MediaEntityType } from '@prisma/client';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreatePublicCommentDto {
|
||||
@IsEnum(MediaEntityType)
|
||||
entityType!: MediaEntityType;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
entityId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
authorName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
authorEmail?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class ListPublicCommentsDto {
|
||||
@IsEnum(MediaEntityType)
|
||||
entityType!: MediaEntityType;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
entityId!: string;
|
||||
}
|
||||
|
||||
export class ListCommentsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(MediaEntityType)
|
||||
entityType?: MediaEntityType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
entityId?: string;
|
||||
|
||||
/** Filter by approval: true = approved, false = pending, omit = all */
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === 'true' || value === true) return true;
|
||||
if (value === 'false' || value === false) return false;
|
||||
return value;
|
||||
})
|
||||
@IsBoolean()
|
||||
isApproved?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateCommentApprovalDto {
|
||||
@IsBoolean()
|
||||
isApproved!: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user