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 { applySeoMetaToMetadata, normalizeSeoMetaInput, readSeoMetaFromMetadata, } from '../common/seo-meta.util'; import { MediaService } from '../media/media.service'; import { PrismaService } from '../prisma/prisma.service'; import { SitemapService } from '../sitemap/sitemap.service'; import { TenantService } from '../tenant/tenant.service'; import { CreatePortfolioCommentDto, CreatePortfolioDto, ListPortfoliosDto, ListPublicPortfoliosDto, UpdatePortfolioDto, } from './dto/portfolio.dto'; function slugify(value: string): string { return ( value .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') || 'portfolio' ); } type PortfolioWithRelations = Prisma.portfoliosGetPayload<{ include: { media: true }; }>; const portfolioInclude = { media: true, } satisfies Prisma.portfoliosInclude; /** Higher `sort_order` first (newest / moved-up), then newest dates. */ const portfolioListOrderBy = [ { sort_order: 'desc' }, { published_at: 'desc' }, { created_at: 'desc' }, ] as const; @Injectable() export class PortfoliosService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, private readonly tenant: TenantService, private readonly businessSettings: BusinessSettingsService, private readonly sitemap: SitemapService, private readonly mediaService: MediaService, ) {} async list(businessIdRaw: string, query: ListPortfoliosDto, actor: AuthUser) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'portfolios.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.portfolios.findMany({ where, orderBy: [...portfolioListOrderBy], skip, take: pageSize, include: portfolioInclude, }), this.prisma.portfolios.count({ where }), ]); const serialized = await Promise.all( items.map((item) => this.serializePortfolio(item, { includeComments: true }), ), ); return { items: serialized, total, page, pageSize }; } async getOne( businessIdRaw: string, portfolioIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const portfolioId = BigInt(portfolioIdRaw); await this.assertPermission(businessId, actor.id, 'portfolios.read'); const portfolio = await this.findPortfolioOrThrow(businessId, portfolioId); return { portfolio: await this.serializePortfolio(portfolio, { includeComments: true, }), }; } async create( businessIdRaw: string, dto: CreatePortfolioDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'portfolios.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.ensureUniqueSlug( businessId, dto.slug ?? slugify(titleEn || titleFa), ); const status = dto.status ?? ContentStatus.draft; const featuredMediaId = dto.featuredMediaId ? BigInt(dto.featuredMediaId) : null; if (featuredMediaId) { await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); } const galleryMediaIds = await this.resolveGalleryMediaIds( businessId, dto.galleryMediaIds ?? [], ); if (dto.categoryId) { await this.assertCategoryBelongsToBusiness( businessId, BigInt(dto.categoryId), ); } const sortOrder = dto.sortOrder ?? (await this.nextSortOrder(businessId)); const created = await this.prisma.$transaction(async (tx) => { const portfolio = await tx.portfolios.create({ data: { business_id: businessId, title: titleFa, title_fa: titleFa, title_en: titleEn, slug, description: dto.abstract?.trim() || null, content: this.buildContent(dto.mainTextHtml) as Prisma.InputJsonValue, project_url: this.normalizeProjectUrl(dto.projectUrl), status, featured_media_id: featuredMediaId, sort_order: sortOrder, published_at: status === ContentStatus.published ? new Date() : null, metadata: applySeoMetaToMetadata( this.buildMetadata(dto.tags), normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription), ) as Prisma.InputJsonValue, }, include: portfolioInclude, }); if (dto.categoryId) { await tx.categoryAssignment.create({ data: { businessId, categoryId: BigInt(dto.categoryId), entityType: MediaEntityType.portfolio, entityId: portfolio.id, }, }); } await this.syncGalleryAttachments( tx, businessId, portfolio.id, galleryMediaIds, ); return portfolio; }); await this.sitemap.invalidateForBusiness(businessId); return { message: 'Portfolio created successfully', portfolio: await this.serializePortfolio(created), }; } async update( businessIdRaw: string, portfolioIdRaw: string, dto: UpdatePortfolioDto, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const portfolioId = BigInt(portfolioIdRaw); await this.assertPermission(businessId, actor.id, 'portfolios.update'); const existing = await this.prisma.portfolios.findFirst({ where: { id: portfolioId, business_id: businessId }, }); if (!existing) { throw new NotFoundException('Portfolio not found'); } let slug = existing.slug; const nextTitleFaRaw = dto.titleFa !== undefined || dto.title !== undefined ? this.resolveTitleFa(dto.titleFa, dto.title) : undefined; if ( (dto.titleFa !== undefined || dto.title !== undefined) && !nextTitleFaRaw ) { throw new BadRequestException('titleFa is required'); } const nextTitleFa = nextTitleFaRaw ?? undefined; if (dto.slug) { slug = await this.ensureUniqueSlug(businessId, dto.slug, portfolioId); } else if ( nextTitleFa && nextTitleFa !== (existing.title_fa ?? existing.title) ) { const slugSource = (dto.titleEn !== undefined ? dto.titleEn?.trim() || null : existing.title_en) || nextTitleFa; slug = await this.ensureUniqueSlug( businessId, slugify(slugSource), portfolioId, ); } 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); } } 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 = 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 pinnedSortOrder = dto.moveToTop ? await this.nextSortOrder(businessId) : undefined; const previousGalleryIds = await this.mediaService.galleryMediaIds( businessId, MediaEntityType.portfolio, portfolioId, ); let nextGalleryIds: bigint[] | undefined; const updated = await this.prisma.$transaction(async (tx) => { const data: Prisma.portfoliosUncheckedUpdateInput = { slug, content: nextContent as Prisma.InputJsonValue, metadata: nextMetadata as Prisma.InputJsonValue, }; if (nextTitleFa !== undefined) { data.title = nextTitleFa; data.title_fa = nextTitleFa; } if (dto.titleEn !== undefined) { data.title_en = dto.titleEn?.trim() || null; } if (dto.abstract !== undefined) { data.description = dto.abstract?.trim() || null; } if (dto.projectUrl !== undefined) { data.project_url = this.normalizeProjectUrl(dto.projectUrl); } if (dto.status !== undefined) { data.status = dto.status; } if (featuredMediaId !== undefined) { data.featured_media_id = featuredMediaId; } if (pinnedSortOrder !== undefined) { data.sort_order = pinnedSortOrder; } else if (dto.sortOrder !== undefined) { data.sort_order = dto.sortOrder; } if (publishedAt !== undefined) { data.published_at = publishedAt; } const portfolio = await tx.portfolios.update({ where: { id: portfolioId }, data, include: portfolioInclude, }); if (dto.categoryId !== undefined) { await tx.categoryAssignment.deleteMany({ where: { businessId, entityType: MediaEntityType.portfolio, entityId: portfolioId, }, }); if (dto.categoryId) { const categoryId = BigInt(dto.categoryId); await this.assertCategoryBelongsToBusiness(businessId, categoryId); await tx.categoryAssignment.create({ data: { businessId, categoryId, entityType: MediaEntityType.portfolio, entityId: portfolioId, }, }); } } if (dto.galleryMediaIds !== undefined) { const galleryMediaIds = await this.resolveGalleryMediaIds( businessId, dto.galleryMediaIds, ); nextGalleryIds = galleryMediaIds; await this.syncGalleryAttachments( tx, businessId, portfolioId, galleryMediaIds, ); } return portfolio; }); await this.sitemap.invalidateForBusiness(businessId); const dropped: bigint[] = []; if (nextGalleryIds) { dropped.push( ...this.mediaService.idsNotIn(previousGalleryIds, nextGalleryIds), ); } if ( featuredMediaId !== undefined && existing.featured_media_id != null && existing.featured_media_id !== featuredMediaId ) { dropped.push(existing.featured_media_id); } await this.mediaService.deleteUnused(businessId, dropped); return { message: 'Portfolio updated successfully', portfolio: await this.serializePortfolio(updated), }; } async remove( businessIdRaw: string, portfolioIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const portfolioId = BigInt(portfolioIdRaw); await this.assertPermission(businessId, actor.id, 'portfolios.delete'); const existing = await this.prisma.portfolios.findFirst({ where: { id: portfolioId, business_id: businessId }, }); if (!existing) { throw new NotFoundException('Portfolio not found'); } const mediaIds = await this.mediaService.allLinkedMediaIds( businessId, MediaEntityType.portfolio, portfolioId, existing.featured_media_id, ); await this.prisma.$transaction([ this.prisma.comment.deleteMany({ where: { businessId, entityType: MediaEntityType.portfolio, entityId: portfolioId, }, }), this.prisma.categoryAssignment.deleteMany({ where: { businessId, entityType: MediaEntityType.portfolio, entityId: portfolioId, }, }), this.prisma.mediaAttachment.deleteMany({ where: { businessId, entityType: MediaEntityType.portfolio, entityId: portfolioId, }, }), this.prisma.portfolios.delete({ where: { id: portfolioId } }), ]); await this.sitemap.invalidateForBusiness(businessId); await this.mediaService.deleteUnused(businessId, mediaIds); return { message: 'Portfolio deleted successfully' }; } async listPublic(host: string, query: ListPublicPortfoliosDto) { 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.portfolios.findMany({ where, orderBy: [...portfolioListOrderBy], skip, take: pageSize, include: portfolioInclude, }), this.prisma.portfolios.count({ where }), ]); const serialized = await Promise.all( items.map((item) => this.serializePortfolio(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 portfolio = await this.prisma.portfolios.findFirst({ where: { business_id: businessId, slug, status: ContentStatus.published, }, include: portfolioInclude, }); if (!portfolio) { throw new NotFoundException('Portfolio not found'); } return { portfolio: await this.serializePortfolio(portfolio, { includeComments: true, approvedCommentsOnly: true, }), }; } async getPublicById(host: string, portfolioIdRaw: string) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; let portfolioId: bigint; try { portfolioId = BigInt(portfolioIdRaw); } catch { throw new NotFoundException('Portfolio not found'); } const portfolio = await this.prisma.portfolios.findFirst({ where: { business_id: businessId, id: portfolioId, status: ContentStatus.published, }, include: portfolioInclude, }); if (!portfolio) { throw new NotFoundException('Portfolio not found'); } return { portfolio: await this.serializePortfolio(portfolio, { includeComments: true, approvedCommentsOnly: true, }), }; } async listCommentsPublic(host: string, portfolioIdRaw: string) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; const portfolioId = BigInt(portfolioIdRaw); await this.assertPublishedPortfolioExists(businessId, portfolioId); const items = await this.prisma.comment.findMany({ where: { businessId, entityType: MediaEntityType.portfolio, entityId: portfolioId, isApproved: true, }, orderBy: { createdAt: 'desc' }, include: { approver: true }, }); return { items: items.map((item) => this.serializeComment(item)) }; } async createCommentPublic( host: string, portfolioIdRaw: string, dto: CreatePortfolioCommentDto, ) { const business = await this.tenant.resolveBusinessByDomain(host); const businessId = business.id; const portfolioId = BigInt(portfolioIdRaw); await this.assertPublishedPortfolioExists(businessId, portfolioId); const autoApprove = await this.businessSettings.isCommentsAutoApprove(businessId); const approvedAt = autoApprove ? new Date() : null; const created = await this.prisma.comment.create({ data: { businessId, entityType: MediaEntityType.portfolio, entityId: portfolioId, 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, portfolioIdRaw: string, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); const portfolioId = BigInt(portfolioIdRaw); await this.assertPermission(businessId, actor.id, 'comments.read'); const portfolio = await this.prisma.portfolios.findFirst({ where: { id: portfolioId, business_id: businessId }, select: { id: true }, }); if (!portfolio) { throw new NotFoundException('Portfolio not found'); } const items = await this.prisma.comment.findMany({ where: { businessId, entityType: MediaEntityType.portfolio, entityId: portfolioId, }, orderBy: { createdAt: 'desc' }, include: { approver: true }, }); return { items: items.map((item) => this.serializeComment(item)) }; } private async buildWhere( businessId: bigint, query: (ListPortfoliosDto | ListPublicPortfoliosDto) & { 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.portfolio, }, 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 } : {}), ...(entityIds ? { id: { in: entityIds } } : {}), ...(query.title?.trim() ? { OR: [ { title: { contains: query.title.trim(), mode: 'insensitive' }, }, { title_fa: { contains: query.title.trim(), mode: 'insensitive', }, }, { title_en: { contains: query.title.trim(), mode: 'insensitive', }, }, ], } : {}), ...(query.tag?.trim() ? { metadata: { path: ['tags'], array_contains: query.tag.trim(), }, } : {}), }; } private resolveTitleFa( titleFa?: string | null, title?: string | null, ): string | null { const value = (titleFa ?? title)?.trim() || ''; return value.length >= 2 ? value : null; } private async findPortfolioOrThrow(businessId: bigint, portfolioId: bigint) { const portfolio = await this.prisma.portfolios.findFirst({ where: { id: portfolioId, business_id: businessId }, include: portfolioInclude, }); if (!portfolio) { throw new NotFoundException('Portfolio not found'); } return portfolio; } private async assertPublishedPortfolioExists( businessId: bigint, portfolioId: bigint, ) { const portfolio = await this.prisma.portfolios.findFirst({ where: { id: portfolioId, business_id: businessId, status: ContentStatus.published, }, select: { id: true }, }); if (!portfolio) { throw new NotFoundException('Portfolio not found'); } } private async serializePortfolio( portfolio: PortfolioWithRelations, options: { includeComments?: boolean; approvedCommentsOnly?: boolean; } = {}, ) { const content = this.asRecord(portfolio.content); const metadata = this.asRecord(portfolio.metadata); const [categoryAssignment, galleryAttachments, commentData] = await Promise.all([ this.prisma.categoryAssignment.findFirst({ where: { businessId: portfolio.business_id, entityType: MediaEntityType.portfolio, entityId: portfolio.id, }, include: { category: true }, }), this.prisma.mediaAttachment.findMany({ where: { businessId: portfolio.business_id, entityType: MediaEntityType.portfolio, entityId: portfolio.id, isFeatured: false, }, orderBy: { sortOrder: 'asc' }, include: { media: true }, }), options.includeComments ? this.loadComments( portfolio.business_id, portfolio.id, options.approvedCommentsOnly, ) : Promise.resolve({ commentCount: 0, comments: [] }), ]); return { id: portfolio.id.toString(), businessId: portfolio.business_id.toString(), title: portfolio.title_fa?.trim() || portfolio.title, titleFa: portfolio.title_fa?.trim() || portfolio.title, titleEn: portfolio.title_en?.trim() || null, slug: portfolio.slug, abstract: portfolio.description ?? '', projectUrl: portfolio.project_url?.trim() || null, mainTextHtml: (content.html as string | undefined) ?? '', status: portfolio.status, categoryId: categoryAssignment?.categoryId.toString() ?? null, categoryName: categoryAssignment?.category.name ?? '', tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [], ...readSeoMetaFromMetadata(metadata), titleImageUrl: portfolio.media?.publicUrl ?? galleryAttachments[0]?.media.publicUrl ?? null, featuredMediaId: portfolio.featured_media_id?.toString() ?? null, gallery: galleryAttachments.map((item) => ({ mediaId: item.mediaId.toString(), url: item.media.publicUrl, })), galleryMediaIds: galleryAttachments.map((item) => item.mediaId.toString(), ), sortOrder: portfolio.sort_order, commentCount: commentData.commentCount, comments: commentData.comments, publishedAt: portfolio.published_at, createdAt: portfolio.created_at, updatedAt: portfolio.updated_at, }; } private async loadComments( businessId: bigint, portfolioId: bigint, approvedOnly?: boolean, ) { const where: Prisma.CommentWhereInput = { businessId, entityType: MediaEntityType.portfolio, entityId: portfolioId, ...(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 normalizeProjectUrl(value?: string | null): string | null { const trimmed = value?.trim() || ''; return trimmed.length > 0 ? trimmed : null; } private async nextSortOrder(businessId: bigint): Promise { const result = await this.prisma.portfolios.aggregate({ where: { business_id: businessId }, _max: { sort_order: true }, }); return (result._max.sort_order ?? -1) + 1; } private asRecord(value: Prisma.JsonValue): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { return value as Record; } return {}; } private async syncGalleryAttachments( tx: Prisma.TransactionClient, businessId: bigint, portfolioId: bigint, mediaIds: bigint[], ) { await tx.mediaAttachment.deleteMany({ where: { businessId, entityType: MediaEntityType.portfolio, entityId: portfolioId, isFeatured: false, }, }); for (const [index, mediaId] of mediaIds.entries()) { await tx.mediaAttachment.create({ data: { businessId, mediaId, entityType: MediaEntityType.portfolio, entityId: portfolioId, sortOrder: index, isFeatured: false, }, }); } } private async resolveGalleryMediaIds(businessId: bigint, rawIds: string[]) { const ids = rawIds.map((id) => BigInt(id)); for (const mediaId of ids) { await this.assertMediaBelongsToBusiness(businessId, mediaId); } return ids; } 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.portfolio, isActive: true, }, }); if (!category) { throw new BadRequestException( 'Portfolio category not found for this business', ); } } private async ensureUniqueSlug( businessId: bigint, baseSlug: string, excludeId?: bigint, ) { let slug = baseSlug; let suffix = 1; while (true) { const existing = await this.prisma.portfolios.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`, ); } } }