import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { ContentStatus, MediaEntityType, Prisma, VideoProvider, } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; import { BusinessSettingsService } from '../business-settings/business-settings.service'; import { applySeoMetaToMetadata, normalizeSeoMetaInput, readSeoMetaFromMetadata, } from '../common/seo-meta.util'; import { PrismaService } from '../prisma/prisma.service'; import { TenantService } from '../tenant/tenant.service'; import { CreateInstructionCommentDto, CreateInstructionDto, ListPublicInstructionsDto, ListInstructionsDto, UpdateInstructionDto, } from './dto/instruction.dto'; function slugify(value: string): string { return ( value .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') || 'instruction' ); } type InstructionWithRelations = Prisma.instructionsGetPayload<{ include: { media: true; author: { select: { id: true; firstName: true; lastName: true; email: true }; }; }; }>; const instructionInclude = { media: true, author: { select: { id: true, firstName: true, lastName: true, email: true, }, }, } satisfies Prisma.instructionsInclude; @Injectable() export class InstructionsService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, private readonly tenant: TenantService, private readonly businessSettings: BusinessSettingsService, ) {} async list(businessIdRaw: string, query: ListInstructionsDto, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'instructions.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.instructions.findMany({ where, orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }], skip, take: pageSize, include: instructionInclude, }), this.prisma.instructions.count({ where }), ]); const serialized = await Promise.all( items.map((item) => this.serializeInstruction(item, { includeComments: true })), ); return { items: serialized, total, page, pageSize }; } async getOne(businessIdRaw: string, instructionIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); const instructionId = BigInt(instructionIdRaw); await this.assertPermission(businessId, actor.id, 'instructions.read'); const video = await this.findInstructionOrThrow(businessId, instructionId); return { instruction: await this.serializeInstruction(video, { includeComments: true }), }; } async create(businessIdRaw: string, dto: CreateInstructionDto, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'instructions.create'); const titleFa = this.resolveTitleFa(dto.titleFa, dto.title); const titleEn = dto.titleEn?.trim() || null; if (!titleFa) { throw new BadRequestException('titleFa is required'); } const slug = await this.ensureUniqueInstructionSlug( businessId, dto.slug ?? slugify(titleEn || titleFa), ); const status = dto.status ?? ContentStatus.published; 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, actor); if (dto.categoryId) { await this.assertCategoryBelongsToBusiness( businessId, BigInt(dto.categoryId), ); } const embedCode = dto.embedCode?.trim() || null; const videoProvider = embedCode ? dto.videoProvider : null; if (embedCode && !videoProvider) { throw new BadRequestException( 'videoProvider is required when embedCode is set', ); } if (!embedCode && !(dto.descriptionHtml ?? '').trim()) { throw new BadRequestException( 'Provide descriptionHtml or embedCode', ); } const created = await this.prisma.$transaction(async (tx) => { const item = await tx.instructions.create({ data: { business_id: businessId, author_id: authorId, title: titleFa, title_fa: titleFa, title_en: titleEn, slug, excerpt: dto.abstract?.trim() || null, content: this.buildContent(dto.descriptionHtml) as Prisma.InputJsonValue, video_provider: videoProvider, embed_code: embedCode, status, featured_media_id: featuredMediaId, published_at: status === ContentStatus.published ? new Date() : null, metadata: applySeoMetaToMetadata( this.buildMetadata(dto.tags), normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription), ) as Prisma.InputJsonValue, }, include: instructionInclude, }); if (dto.categoryId) { await tx.categoryAssignment.create({ data: { businessId, categoryId: BigInt(dto.categoryId), entityType: MediaEntityType.instruction, entityId: item.id, }, }); } return item; }); return { message: 'Instruction created successfully', instruction: await this.serializeInstruction(created), }; } async update( businessIdRaw: string, instructionIdRaw: string, dto: UpdateInstructionDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const instructionId = BigInt(instructionIdRaw); await this.assertPermission(businessId, actor.id, 'instructions.update'); const existing = await this.prisma.instructions.findFirst({ where: { id: instructionId, business_id: businessId }, }); if (!existing) { throw new NotFoundException('Instruction not found'); } const nextTitleFa = dto.titleFa !== undefined || dto.title !== undefined ? this.resolveTitleFa(dto.titleFa, dto.title) : undefined; if ( (dto.titleFa !== undefined || dto.title !== undefined) && !nextTitleFa ) { throw new BadRequestException('titleFa is required'); } let slug = existing.slug; if (dto.slug) { slug = await this.ensureUniqueInstructionSlug(businessId, dto.slug, instructionId); } else if (nextTitleFa && nextTitleFa !== (existing.title_fa ?? existing.title)) { const slugBase = (dto.titleEn !== undefined ? dto.titleEn?.trim() || null : existing.title_en) || nextTitleFa; slug = await this.ensureUniqueInstructionSlug(businessId, slugify(slugBase), instructionId); } 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, actor); } } const existingContent = this.asRecord(existing.content); const existingMetadata = this.asRecord(existing.metadata); const nextContent = { ...existingContent }; if (dto.descriptionHtml !== undefined) { nextContent.html = dto.descriptionHtml ?? ''; } const nextMetadata = applySeoMetaToMetadata( { ...existingMetadata, ...(dto.tags !== undefined ? { tags: dto.tags } : {}), }, normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription), ); 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 nextEmbed = dto.embedCode !== undefined ? dto.embedCode?.trim() || null : undefined; let nextProvider: VideoProvider | null | undefined = undefined; if (dto.videoProvider !== undefined) { nextProvider = dto.videoProvider ?? null; } else if (nextEmbed !== undefined && !nextEmbed) { nextProvider = null; } const video = await tx.instructions.update({ where: { id: instructionId }, data: { ...(nextTitleFa !== undefined ? { title: nextTitleFa, title_fa: nextTitleFa } : {}), ...(dto.titleEn !== undefined ? { title_en: dto.titleEn?.trim() || null } : {}), ...(dto.abstract !== undefined ? { excerpt: dto.abstract?.trim() || null } : {}), ...(nextProvider !== undefined ? { video_provider: nextProvider } : {}), ...(nextEmbed !== undefined ? { embed_code: nextEmbed } : {}), ...(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: instructionInclude, }); if (dto.categoryId !== undefined) { await tx.categoryAssignment.deleteMany({ where: { businessId, entityType: MediaEntityType.instruction, entityId: instructionId, }, }); if (dto.categoryId) { const categoryId = BigInt(dto.categoryId); await this.assertCategoryBelongsToBusiness(businessId, categoryId); await tx.categoryAssignment.create({ data: { businessId, categoryId, entityType: MediaEntityType.instruction, entityId: instructionId, }, }); } } return video; }); return { message: 'Instruction updated successfully', instruction: await this.serializeInstruction(updated), }; } async remove(businessIdRaw: string, instructionIdRaw: string, actor: AuthUser) { const businessId = BigInt(businessIdRaw); const instructionId = BigInt(instructionIdRaw); await this.assertPermission(businessId, actor.id, 'instructions.delete'); const existing = await this.prisma.instructions.findFirst({ where: { id: instructionId, business_id: businessId }, }); if (!existing) { throw new NotFoundException('Instruction not found'); } await this.prisma.$transaction([ this.prisma.comment.deleteMany({ where: { businessId, entityType: MediaEntityType.instruction, entityId: instructionId, }, }), this.prisma.categoryAssignment.deleteMany({ where: { businessId, entityType: MediaEntityType.instruction, entityId: instructionId, }, }), this.prisma.instructions.delete({ where: { id: instructionId } }), ]); return { message: 'Instruction deleted successfully' }; } async listPublic(host: string, query: ListPublicInstructionsDto) { 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.instructions.findMany({ where, orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }], skip, take: pageSize, include: instructionInclude, }), this.prisma.instructions.count({ where }), ]); const serialized = await Promise.all( items.map((item) => this.serializeInstruction(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 video = await this.prisma.instructions.findFirst({ where: { business_id: businessId, slug, status: ContentStatus.published, }, include: instructionInclude, }); if (!video) { throw new NotFoundException('Instruction not found'); } return { instruction: await this.serializeInstruction(video, { includeComments: true, approvedCommentsOnly: true, }), }; } async listCommentsPublic(host: string, instructionIdRaw: string) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; const instructionId = BigInt(instructionIdRaw); await this.assertPublishedInstructionExists(businessId, instructionId); const items = await this.prisma.comment.findMany({ where: { businessId, entityType: MediaEntityType.instruction, entityId: instructionId, isApproved: true, }, orderBy: { createdAt: 'desc' }, include: { approver: true }, }); return { items: items.map((item) => this.serializeComment(item)) }; } async createCommentPublic( host: string, instructionIdRaw: string, dto: CreateInstructionCommentDto, ) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; const instructionId = BigInt(instructionIdRaw); await this.assertPublishedInstructionExists(businessId, instructionId); const autoApprove = await this.businessSettings.isCommentsAutoApprove(businessId); const approvedAt = autoApprove ? new Date() : null; const created = await this.prisma.comment.create({ data: { businessId, entityType: MediaEntityType.instruction, entityId: instructionId, 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, instructionIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const instructionId = BigInt(instructionIdRaw); await this.assertPermission(businessId, actor.id, 'comments.read'); const video = await this.prisma.instructions.findFirst({ where: { id: instructionId, business_id: businessId }, select: { id: true }, }); if (!video) { throw new NotFoundException('Instruction not found'); } const items = await this.prisma.comment.findMany({ where: { businessId, entityType: MediaEntityType.instruction, entityId: instructionId, }, orderBy: { createdAt: 'desc' }, include: { approver: true }, }); return { items: items.map((item) => this.serializeComment(item)) }; } private resolveTitleFa(titleFa?: string, title?: string): string { return (titleFa ?? title ?? '').trim(); } private async buildWhere( businessId: bigint, query: (ListInstructionsDto | ListPublicInstructionsDto) & { 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.instruction, }, 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.provider ? { video_provider: query.provider } : {}), ...(entityIds ? { id: { in: entityIds } } : {}), ...(query.title?.trim() ? { OR: [ { title_fa: { contains: query.title.trim(), mode: 'insensitive', }, }, { title_en: { contains: query.title.trim(), mode: 'insensitive', }, }, { title: { contains: query.title.trim(), mode: 'insensitive' }, }, ], } : {}), }; } private async findInstructionOrThrow(businessId: bigint, instructionId: bigint) { const video = await this.prisma.instructions.findFirst({ where: { id: instructionId, business_id: businessId }, include: instructionInclude, }); if (!video) { throw new NotFoundException('Instruction not found'); } return video; } private async assertPublishedInstructionExists( businessId: bigint, instructionId: bigint, ) { const video = await this.prisma.instructions.findFirst({ where: { id: instructionId, business_id: businessId, status: ContentStatus.published, }, select: { id: true }, }); if (!video) { throw new NotFoundException('Instruction not found'); } } private async serializeInstruction( video: InstructionWithRelations, options: { includeComments?: boolean; approvedCommentsOnly?: boolean; } = {}, ) { const content = this.asRecord(video.content); const metadata = this.asRecord(video.metadata); const [categoryAssignment, commentData] = await Promise.all([ this.prisma.categoryAssignment.findFirst({ where: { businessId: video.business_id, entityType: MediaEntityType.instruction, entityId: video.id, }, include: { category: true }, }), options.includeComments ? this.loadComments( video.business_id, video.id, options.approvedCommentsOnly, ) : Promise.resolve({ commentCount: 0, comments: [] }), ]); return { id: video.id.toString(), businessId: video.business_id.toString(), title: video.title, titleFa: video.title_fa ?? video.title, titleEn: video.title_en, slug: video.slug, abstract: video.excerpt ?? '', descriptionHtml: (content.html as string | undefined) ?? '', videoProvider: video.video_provider ?? null, embedCode: video.embed_code ?? '', status: video.status, categoryId: categoryAssignment?.categoryId.toString() ?? null, categoryName: categoryAssignment?.category.name ?? '', tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [], ...readSeoMetaFromMetadata(metadata), authorId: video.author_id?.toString() ?? null, author: video.author ? { id: video.author.id.toString(), firstName: video.author.firstName, lastName: video.author.lastName, email: video.author.email, } : null, coverImageUrl: video.media?.publicUrl ?? null, featuredMediaId: video.featured_media_id?.toString() ?? null, commentCount: commentData.commentCount, comments: commentData.comments, publishedAt: video.published_at, createdAt: video.created_at, updatedAt: video.updated_at, }; } private async loadComments( businessId: bigint, instructionId: bigint, approvedOnly?: boolean, ) { const where: Prisma.CommentWhereInput = { businessId, entityType: MediaEntityType.instruction, entityId: instructionId, ...(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(descriptionHtml?: string) { return { html: descriptionHtml ?? '', }; } 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.instruction, isActive: true, }, }); if (!category) { throw new BadRequestException('Instruction category not found for this business'); } } private async assertAuthorBelongsToBusiness( businessId: bigint, authorId: bigint, actor: AuthUser, ) { if (await this.permissions.isSuperAdmin(actor.id)) { return; } 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 ensureUniqueInstructionSlug( businessId: bigint, baseSlug: string, excludeId?: bigint, ) { let slug = baseSlug; let suffix = 1; while (true) { const existing = await this.prisma.instructions.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`, ); } } }