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