Ship legacy migrate APIs, portfolio/blog old-id schema, and admin migrate/purge flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-29 16:10:41 +03:30
co-authored by Cursor
parent 7244b70e90
commit 4598add88c
32 changed files with 4234 additions and 265 deletions
+22 -1
View File
@@ -60,9 +60,20 @@ export class ListPublicPortfoliosDto {
}
export class CreatePortfolioDto {
/** @deprecated Prefer titleFa — kept for older clients. */
@IsOptional()
@IsString()
@MinLength(2)
title!: string;
title?: string;
@IsOptional()
@IsString()
@MinLength(2)
titleFa?: string;
@IsOptional()
@IsString()
titleEn?: string;
@IsOptional()
@IsString()
@@ -107,11 +118,21 @@ export class CreatePortfolioDto {
}
export class UpdatePortfolioDto {
/** @deprecated Prefer titleFa — kept for older clients. */
@IsOptional()
@IsString()
@MinLength(2)
title?: string;
@IsOptional()
@IsString()
@MinLength(2)
titleFa?: string;
@IsOptional()
@IsString()
titleEn?: string | null;
@IsOptional()
@IsString()
abstract?: string | null;
+87 -21
View File
@@ -109,9 +109,15 @@ export class PortfoliosService {
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(dto.title),
dto.slug ?? slugify(titleEn || titleFa),
);
const status = dto.status ?? ContentStatus.draft;
@@ -139,7 +145,9 @@ export class PortfoliosService {
const portfolio = await tx.portfolios.create({
data: {
business_id: businessId,
title: dto.title.trim(),
title: titleFa,
title_fa: titleFa,
title_en: titleEn,
slug,
description: dto.abstract?.trim() || null,
content: this.buildContent(dto.mainTextHtml) as Prisma.InputJsonValue,
@@ -198,12 +206,30 @@ export class PortfoliosService {
}
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 (dto.title && dto.title !== existing.title) {
} 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(dto.title),
slugify(slugSource),
portfolioId,
);
}
@@ -245,23 +271,37 @@ export class PortfoliosService {
}
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.status !== undefined) {
data.status = dto.status;
}
if (featuredMediaId !== undefined) {
data.featured_media_id = featuredMediaId;
}
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: {
...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
...(dto.abstract !== undefined
? { description: dto.abstract?.trim() || null }
: {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
...(featuredMediaId !== undefined
? { featured_media_id: featuredMediaId }
: {}),
...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}),
...(publishedAt !== undefined ? { published_at: publishedAt } : {}),
slug,
content: nextContent as Prisma.InputJsonValue,
metadata: nextMetadata as Prisma.InputJsonValue,
},
data,
include: portfolioInclude,
});
@@ -540,12 +580,36 @@ export class PortfoliosService {
...(entityIds ? { id: { in: entityIds } } : {}),
...(query.title?.trim()
? {
title: { contains: query.title.trim(), mode: 'insensitive' },
OR: [
{
title: { contains: query.title.trim(), mode: 'insensitive' },
},
{
title_fa: {
contains: query.title.trim(),
mode: 'insensitive',
},
},
{
title_en: {
contains: query.title.trim(),
mode: 'insensitive',
},
},
],
}
: {}),
};
}
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 },
@@ -619,7 +683,9 @@ export class PortfoliosService {
return {
id: portfolio.id.toString(),
businessId: portfolio.business_id.toString(),
title: portfolio.title,
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 ?? '',
mainTextHtml: (content.html as string | undefined) ?? '',