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
+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,
};
}
}