import { BadRequestException, 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 { CreateBlogCommentDto, CreateBlogDto, ListBlogsDto, ListPublicBlogsDto, UpdateBlogDto, } from './dto/blog.dto'; function slugify(value: string): string { return ( value .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') || 'blog' ); } type BlogWithRelations = Prisma.blogsGetPayload<{ include: { media: true; users: { select: { id: true; firstName: true; lastName: true; email: true } }; }; }>; const blogInclude = { media: true, users: { select: { id: true, firstName: true, lastName: true, email: true, }, }, } satisfies Prisma.blogsInclude; @Injectable() export class BlogsService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, private readonly tenant: TenantService, private readonly businessSettings: BusinessSettingsService, ) {} async list(businessIdRaw: string, query: ListBlogsDto, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'blogs.read'); const page = query.page ?? 1; const pageSize = query.pageSize ?? 12; const skip = (page - 1) * pageSize; const where = await this.buildWhere(businessId, query); const [items, total] = await Promise.all([ this.prisma.blogs.findMany({ where, orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }], skip, take: pageSize, include: blogInclude, }), this.prisma.blogs.count({ where }), ]); const serialized = await Promise.all( items.map((item) => this.serializeBlog(item, { includeComments: true })), ); return { items: serialized, total, page, pageSize }; } async getOne(businessIdRaw: string, blogIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); const blogId = BigInt(blogIdRaw); await this.assertPermission(businessId, actor.id, 'blogs.read'); const blog = await this.findBlogOrThrow(businessId, blogId); return { blog: await this.serializeBlog(blog, { includeComments: true }) }; } async create(businessIdRaw: string, dto: CreateBlogDto, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'blogs.create'); const slug = await this.ensureUniqueSlug( businessId, dto.slug ?? slugify(dto.title), ); const status = dto.status ?? ContentStatus.draft; const featuredMediaId = dto.featuredMediaId ? BigInt(dto.featuredMediaId) : null; if (featuredMediaId) { await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); } const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id; await this.assertAuthorBelongsToBusiness(businessId, authorId); if (dto.categoryId) { await this.assertCategoryBelongsToBusiness(businessId, BigInt(dto.categoryId)); } const created = await this.prisma.$transaction(async (tx) => { const blog = await tx.blogs.create({ data: { business_id: businessId, author_id: authorId, title: dto.title.trim(), slug, excerpt: dto.abstract?.trim() || null, content: this.buildContent(dto.mainTextHtml) as Prisma.InputJsonValue, post_type: dto.type, status, featured_media_id: featuredMediaId, published_at: status === ContentStatus.published ? new Date() : null, metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue, }, include: blogInclude, }); if (dto.categoryId) { await tx.categoryAssignment.create({ data: { businessId, categoryId: BigInt(dto.categoryId), entityType: MediaEntityType.blog, entityId: blog.id, }, }); } return blog; }); return { message: 'Blog post created successfully', blog: await this.serializeBlog(created), }; } async update( businessIdRaw: string, blogIdRaw: string, dto: UpdateBlogDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const blogId = BigInt(blogIdRaw); await this.assertPermission(businessId, actor.id, 'blogs.update'); const existing = await this.prisma.blogs.findFirst({ where: { id: blogId, business_id: businessId }, }); if (!existing) { throw new NotFoundException('Blog post not found'); } let slug = existing.slug; if (dto.slug) { slug = await this.ensureUniqueSlug(businessId, dto.slug, blogId); } else if (dto.title && dto.title !== existing.title) { slug = await this.ensureUniqueSlug(businessId, slugify(dto.title), blogId); } let featuredMediaId: bigint | null | undefined = undefined; if (dto.featuredMediaId !== undefined) { if (dto.featuredMediaId === null || dto.featuredMediaId === '') { featuredMediaId = null; } else { featuredMediaId = BigInt(dto.featuredMediaId); await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); } } let authorId: bigint | null | undefined = undefined; if (dto.authorId !== undefined) { if (dto.authorId === null || dto.authorId === '') { authorId = null; } else { authorId = BigInt(dto.authorId); await this.assertAuthorBelongsToBusiness(businessId, authorId); } } const existingContent = this.asRecord(existing.content); const existingMetadata = this.asRecord(existing.metadata); const nextContent = { ...existingContent }; if (dto.mainTextHtml !== undefined) { nextContent.html = dto.mainTextHtml ?? ''; } const nextMetadata = { ...existingMetadata }; if (dto.tags !== undefined) { nextMetadata.tags = dto.tags; } let publishedAt: Date | null | undefined = undefined; if (dto.status !== undefined) { if (dto.status === ContentStatus.published && existing.status !== ContentStatus.published) { publishedAt = new Date(); } if (dto.status !== ContentStatus.published) { publishedAt = null; } } const updated = await this.prisma.$transaction(async (tx) => { const blog = await tx.blogs.update({ where: { id: blogId }, data: { ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), ...(dto.abstract !== undefined ? { excerpt: dto.abstract?.trim() || null } : {}), ...(dto.type !== undefined ? { post_type: dto.type } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), ...(featuredMediaId !== undefined ? { featured_media_id: featuredMediaId } : {}), ...(authorId !== undefined ? { author_id: authorId } : {}), ...(publishedAt !== undefined ? { published_at: publishedAt } : {}), slug, content: nextContent as Prisma.InputJsonValue, metadata: nextMetadata as Prisma.InputJsonValue, }, include: blogInclude, }); if (dto.categoryId !== undefined) { await tx.categoryAssignment.deleteMany({ where: { businessId, entityType: MediaEntityType.blog, entityId: blogId, }, }); if (dto.categoryId) { const categoryId = BigInt(dto.categoryId); await this.assertCategoryBelongsToBusiness(businessId, categoryId); await tx.categoryAssignment.create({ data: { businessId, categoryId, entityType: MediaEntityType.blog, entityId: blogId, }, }); } } return blog; }); return { message: 'Blog post updated successfully', blog: await this.serializeBlog(updated), }; } async remove(businessIdRaw: string, blogIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); const blogId = BigInt(blogIdRaw); await this.assertPermission(businessId, actor.id, 'blogs.delete'); const existing = await this.prisma.blogs.findFirst({ where: { id: blogId, business_id: businessId }, }); if (!existing) { throw new NotFoundException('Blog post not found'); } await this.prisma.$transaction([ this.prisma.comment.deleteMany({ where: { businessId, entityType: MediaEntityType.blog, entityId: blogId, }, }), this.prisma.categoryAssignment.deleteMany({ where: { businessId, entityType: MediaEntityType.blog, entityId: blogId, }, }), this.prisma.blogs.delete({ where: { id: blogId } }), ]); return { message: 'Blog post deleted successfully' }; } async listPublic(host: string, query: ListPublicBlogsDto) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; const page = query.page ?? 1; const pageSize = query.pageSize ?? 12; const skip = (page - 1) * pageSize; const where = await this.buildWhere(businessId, { ...query, status: ContentStatus.published, }); const [items, total] = await Promise.all([ this.prisma.blogs.findMany({ where, orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }], skip, take: pageSize, include: blogInclude, }), this.prisma.blogs.count({ where }), ]); const serialized = await Promise.all( items.map((item) => this.serializeBlog(item, { includeComments: true, approvedCommentsOnly: true }), ), ); return { items: serialized, total, page, pageSize }; } async getPublicBySlug(host: string, slug: string) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; const blog = await this.prisma.blogs.findFirst({ where: { business_id: businessId, slug, status: ContentStatus.published, }, include: blogInclude, }); if (!blog) { throw new NotFoundException('Blog post not found'); } return { blog: await this.serializeBlog(blog, { includeComments: true, approvedCommentsOnly: true, }), }; } async listCommentsPublic(host: string, blogIdRaw: string) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; const blogId = BigInt(blogIdRaw); await this.assertPublishedBlogExists(businessId, blogId); const items = await this.prisma.comment.findMany({ where: { businessId, entityType: MediaEntityType.blog, entityId: blogId, isApproved: true, }, orderBy: { createdAt: 'desc' }, include: { approver: true }, }); return { items: items.map((item) => this.serializeComment(item)) }; } async createCommentPublic( host: string, blogIdRaw: string, dto: CreateBlogCommentDto, ) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; const blogId = BigInt(blogIdRaw); await this.assertPublishedBlogExists(businessId, blogId); const autoApprove = await this.businessSettings.isCommentsAutoApprove(businessId); const approvedAt = autoApprove ? new Date() : null; const created = await this.prisma.comment.create({ data: { businessId, entityType: MediaEntityType.blog, entityId: blogId, authorName: dto.authorName.trim(), authorEmail: dto.authorEmail?.trim() || null, text: dto.text.trim(), isApproved: autoApprove, approvedAt, }, include: { approver: true }, }); return { comment: this.serializeComment(created), message: autoApprove ? 'Comment submitted and is approved' : 'Comment submitted and is pending approval', }; } async listCommentsAdmin( businessIdRaw: string, blogIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const blogId = BigInt(blogIdRaw); await this.assertPermission(businessId, actor.id, 'comments.read'); const blog = await this.prisma.blogs.findFirst({ where: { id: blogId, business_id: businessId }, select: { id: true }, }); if (!blog) { throw new NotFoundException('Blog post not found'); } const items = await this.prisma.comment.findMany({ where: { businessId, entityType: MediaEntityType.blog, entityId: blogId, }, orderBy: { createdAt: 'desc' }, include: { approver: true }, }); return { items: items.map((item) => this.serializeComment(item)) }; } private async buildWhere( businessId: bigint, query: (ListBlogsDto | ListPublicBlogsDto) & { status?: ContentStatus }, ): Promise { let entityIds: bigint[] | undefined; if (query.categoryId) { const assignments = await this.prisma.categoryAssignment.findMany({ where: { businessId, categoryId: BigInt(query.categoryId), entityType: MediaEntityType.blog, }, select: { entityId: true }, }); entityIds = assignments.map((item) => item.entityId); if (entityIds.length === 0) { return { id: { in: [] } }; } } return { business_id: businessId, ...(query.status ? { status: query.status } : {}), ...(query.type ? { post_type: query.type } : {}), ...(entityIds ? { id: { in: entityIds } } : {}), ...(query.title?.trim() ? { title: { contains: query.title.trim(), mode: 'insensitive' }, } : {}), }; } private async findBlogOrThrow(businessId: bigint, blogId: bigint) { const blog = await this.prisma.blogs.findFirst({ where: { id: blogId, business_id: businessId }, include: blogInclude, }); if (!blog) { throw new NotFoundException('Blog post not found'); } return blog; } private async assertPublishedBlogExists(businessId: bigint, blogId: bigint) { const blog = await this.prisma.blogs.findFirst({ where: { id: blogId, business_id: businessId, status: ContentStatus.published, }, select: { id: true }, }); if (!blog) { throw new NotFoundException('Blog post not found'); } } private async serializeBlog( blog: BlogWithRelations, options: { includeComments?: boolean; approvedCommentsOnly?: boolean; } = {}, ) { const content = this.asRecord(blog.content); const metadata = this.asRecord(blog.metadata); const [categoryAssignment, commentData] = await Promise.all([ this.prisma.categoryAssignment.findFirst({ where: { businessId: blog.business_id, entityType: MediaEntityType.blog, entityId: blog.id, }, include: { category: true }, }), options.includeComments ? this.loadComments(blog.business_id, blog.id, options.approvedCommentsOnly) : Promise.resolve({ commentCount: 0, comments: [] }), ]); return { id: blog.id.toString(), businessId: blog.business_id.toString(), title: blog.title, slug: blog.slug, type: blog.post_type, abstract: blog.excerpt ?? '', mainTextHtml: (content.html as string | undefined) ?? '', status: blog.status, categoryId: categoryAssignment?.categoryId.toString() ?? null, categoryName: categoryAssignment?.category.name ?? '', tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [], authorId: blog.author_id?.toString() ?? null, author: blog.users ? { id: blog.users.id.toString(), firstName: blog.users.firstName, lastName: blog.users.lastName, email: blog.users.email, } : null, titleImageUrl: blog.media?.publicUrl ?? null, featuredMediaId: blog.featured_media_id?.toString() ?? null, commentCount: commentData.commentCount, comments: commentData.comments, publishedAt: blog.published_at, createdAt: blog.created_at, updatedAt: blog.updated_at, }; } private async loadComments( businessId: bigint, blogId: bigint, approvedOnly?: boolean, ) { const where: Prisma.CommentWhereInput = { businessId, entityType: MediaEntityType.blog, entityId: blogId, ...(approvedOnly ? { isApproved: true } : {}), }; const [commentCount, comments] = await Promise.all([ this.prisma.comment.count({ where }), this.prisma.comment.findMany({ where, orderBy: { createdAt: 'desc' }, take: approvedOnly ? 50 : undefined, include: { approver: true }, }), ]); return { commentCount, comments: comments.map((item) => this.serializeComment(item)), }; } private serializeComment( comment: Prisma.CommentGetPayload<{ include: { approver: true } }>, ) { 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, }; } private buildContent(mainTextHtml?: string) { return { html: mainTextHtml ?? '', }; } private buildMetadata(tags?: string[]) { return { tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [], }; } private asRecord(value: Prisma.JsonValue): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { return value as Record; } return {}; } private async assertMediaBelongsToBusiness(businessId: bigint, mediaId: bigint) { const media = await this.prisma.media.findFirst({ where: { id: mediaId, businessId }, }); if (!media) { throw new BadRequestException('Media not found for this business'); } } private async assertCategoryBelongsToBusiness( businessId: bigint, categoryId: bigint, ) { const category = await this.prisma.category.findFirst({ where: { id: categoryId, businessId, entityType: MediaEntityType.blog, isActive: true, }, }); if (!category) { throw new BadRequestException('Blog category not found for this business'); } } private async assertAuthorBelongsToBusiness(businessId: bigint, authorId: bigint) { const member = await this.prisma.businessUser.findFirst({ where: { businessId, userId: authorId }, }); if (!member) { throw new BadRequestException('Author must be a team member of this business'); } } private async ensureUniqueSlug( businessId: bigint, baseSlug: string, excludeId?: bigint, ) { let slug = baseSlug; let suffix = 1; while (true) { const existing = await this.prisma.blogs.findFirst({ where: { business_id: businessId, slug, ...(excludeId ? { NOT: { id: excludeId } } : {}), }, }); if (!existing) { return slug; } suffix += 1; slug = `${baseSlug}-${suffix}`; } } private async assertPermission( businessId: bigint, userId: bigint, permission: string, ) { const allowed = await this.permissions.hasBusinessPermission( userId, businessId, permission, ); if (!allowed) { throw new ForbiddenException(`Missing permission: ${permission} for this business`); } } }