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
+75
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+258
View File
@@ -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,
};
}
}
+79
View File
@@ -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;
}