Expose Organization/LocalBusiness and Product/BlogPosting jsonLd, optional seoMeta fields on content/categories, and continue mass SMS after per-recipient failures. Co-authored-by: Cursor <cursoragent@cursor.com>
1017 lines
28 KiB
TypeScript
1017 lines
28 KiB
TypeScript
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<Prisma.portfoliosWhereInput> {
|
|
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<number> {
|
|
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<string, unknown> {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
return value as Record<string, unknown>;
|
|
}
|
|
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`,
|
|
);
|
|
}
|
|
}
|
|
}
|