mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
259 lines
7.2 KiB
TypeScript
259 lines
7.2 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|