mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
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:
co-authored by
Cursor
parent
7244b70e90
commit
4598add88c
@@ -17,6 +17,10 @@ import { DisableBusinessDto } from './dto/disable-business.dto';
|
||||
import { UpdateBusinessDto } from './dto/update-business.dto';
|
||||
import { ListBusinessesDto } from './dto/list-businesses.dto';
|
||||
import { SearchBusinessesDto } from './dto/search-businesses.dto';
|
||||
import { MigrateFromOldDto } from './dto/migrate-from-old.dto';
|
||||
import { PurgeBusinessDataDto } from './dto/purge-business-data.dto';
|
||||
import { LegacyMigrateService } from './legacy-migrate.service';
|
||||
import { LegacyPurgeService } from './legacy-purge.service';
|
||||
import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||
|
||||
@@ -28,6 +32,7 @@ type BusinessRow = {
|
||||
slug: string;
|
||||
createdAt: Date;
|
||||
isActive: boolean;
|
||||
oldBusinessId: bigint | null;
|
||||
domainId: bigint | null;
|
||||
domain: string | null;
|
||||
sslEnabled: boolean | null;
|
||||
@@ -50,6 +55,8 @@ export class BusinessAdminService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly legacyMigrate: LegacyMigrateService,
|
||||
private readonly legacyPurge: LegacyPurgeService,
|
||||
) {}
|
||||
|
||||
private async assertSuperAdmin(actor: AuthUser) {
|
||||
@@ -99,6 +106,7 @@ export class BusinessAdminService {
|
||||
b.slug AS "slug",
|
||||
b.created_at AS "createdAt",
|
||||
b.is_active AS "isActive",
|
||||
b.old_business_id AS "oldBusinessId",
|
||||
dom.id AS "domainId",
|
||||
dom.host AS "domain",
|
||||
dom.ssl_enabled AS "sslEnabled",
|
||||
@@ -279,29 +287,46 @@ export class BusinessAdminService {
|
||||
await this.assertSlugAvailable(slug);
|
||||
await this.validateCategoryIds(dto.categoryIds);
|
||||
|
||||
const business = await this.prisma.$transaction(async (tx) => {
|
||||
const owner = await this.createOwnerUser(tx, dto);
|
||||
if (dto.oldBusinessId !== undefined) {
|
||||
await this.assertOldBusinessIdAvailable(BigInt(dto.oldBusinessId));
|
||||
}
|
||||
|
||||
const created = await tx.business.create({
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
nameFa: dto.nameFa.trim(),
|
||||
about: dto.about?.trim() ?? null,
|
||||
slug,
|
||||
},
|
||||
let business;
|
||||
try {
|
||||
business = await this.prisma.$transaction(async (tx) => {
|
||||
const owner = await this.createOwnerUser(tx, dto);
|
||||
|
||||
const created = await tx.business.create({
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
nameFa: dto.nameFa.trim(),
|
||||
about: dto.about?.trim() ?? null,
|
||||
slug,
|
||||
oldBusinessId:
|
||||
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.businessCategoryAssignment.createMany({
|
||||
data: dto.categoryIds.map((id) => ({
|
||||
businessId: created.id,
|
||||
categoryId: BigInt(id),
|
||||
})),
|
||||
});
|
||||
|
||||
await this.assignOwner(tx, created.id, owner.id, actor.id);
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
await tx.businessCategoryAssignment.createMany({
|
||||
data: dto.categoryIds.map((id) => ({
|
||||
businessId: created.id,
|
||||
categoryId: BigInt(id),
|
||||
})),
|
||||
});
|
||||
|
||||
await this.assignOwner(tx, created.id, owner.id, actor.id);
|
||||
|
||||
return created;
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('Business slug or old business id is already taken');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return this.getOne(business.id.toString(), actor);
|
||||
}
|
||||
@@ -335,6 +360,10 @@ export class BusinessAdminService {
|
||||
await this.findOwnerUser(dto.ownerUserId);
|
||||
}
|
||||
|
||||
if (dto.oldBusinessId !== undefined && dto.oldBusinessId !== null) {
|
||||
await this.assertOldBusinessIdAvailable(BigInt(dto.oldBusinessId), businessId);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.business.update({
|
||||
where: { id: businessId },
|
||||
@@ -343,6 +372,12 @@ export class BusinessAdminService {
|
||||
nameFa: nextNameFa,
|
||||
about: dto.about !== undefined ? dto.about.trim() || null : undefined,
|
||||
slug: nextSlug,
|
||||
...(dto.oldBusinessId !== undefined
|
||||
? {
|
||||
oldBusinessId:
|
||||
dto.oldBusinessId === null ? null : BigInt(dto.oldBusinessId),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -464,6 +499,288 @@ export class BusinessAdminService {
|
||||
return { message: 'Business removed' };
|
||||
}
|
||||
|
||||
async migrateFromOld(businessIdRaw: string, dto: MigrateFromOldDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const oldBusinessId = BigInt(dto.oldBusinessId);
|
||||
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
await this.assertOldBusinessIdAvailable(oldBusinessId, businessId);
|
||||
|
||||
let updated;
|
||||
try {
|
||||
updated = await this.prisma.business.update({
|
||||
where: { id: businessId },
|
||||
data: { oldBusinessId },
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`Old business id ${dto.oldBusinessId} is already linked to another business`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Data copy for selected entities (portfolio categories implemented first).
|
||||
const results = await this.legacyMigrate.migrateEntities(
|
||||
businessId,
|
||||
oldBusinessId,
|
||||
dto.entities,
|
||||
);
|
||||
|
||||
const implemented = dto.entities.filter((entity) => {
|
||||
const result = results[entity];
|
||||
return result && !('status' in result && result.status === 'not_implemented');
|
||||
});
|
||||
const pending = dto.entities.filter((entity) => {
|
||||
const result = results[entity];
|
||||
return result && 'status' in result && result.status === 'not_implemented';
|
||||
});
|
||||
|
||||
const parts: string[] = [];
|
||||
const portfolioCats = results.portfolio_categories;
|
||||
if (
|
||||
portfolioCats &&
|
||||
!('status' in portfolioCats) &&
|
||||
'created' in portfolioCats
|
||||
) {
|
||||
parts.push(
|
||||
`portfolio categories: ${portfolioCats.created} created, ${portfolioCats.skipped} skipped (${portfolioCats.total} total)`,
|
||||
);
|
||||
}
|
||||
const portfolios = results.portfolio;
|
||||
if (portfolios && !('status' in portfolios) && 'created' in portfolios) {
|
||||
const imgBits = [
|
||||
portfolios.imagesCopied != null
|
||||
? `${portfolios.imagesCopied} images copied`
|
||||
: null,
|
||||
portfolios.imagesResized != null && portfolios.imagesResized > 0
|
||||
? `${portfolios.imagesResized} resized (≤1280px)`
|
||||
: null,
|
||||
portfolios.titlesUpdated != null && portfolios.titlesUpdated > 0
|
||||
? `${portfolios.titlesUpdated} titles updated`
|
||||
: null,
|
||||
portfolios.imagesFailed != null && portfolios.imagesFailed > 0
|
||||
? `${portfolios.imagesFailed} images failed`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
parts.push(
|
||||
`portfolios: ${portfolios.created} created, ${portfolios.skipped} skipped (${portfolios.total} total)` +
|
||||
(imgBits.length ? `; ${imgBits.join(', ')}` : ''),
|
||||
);
|
||||
}
|
||||
const blogCats = results.blog_categories;
|
||||
if (blogCats && !('status' in blogCats) && 'created' in blogCats) {
|
||||
parts.push(
|
||||
`blog categories: ${blogCats.created} created, ${blogCats.skipped} skipped (${blogCats.total} total)`,
|
||||
);
|
||||
}
|
||||
const blogs = results.blog;
|
||||
if (blogs && !('status' in blogs) && 'created' in blogs) {
|
||||
const imgBits = [
|
||||
blogs.imagesCopied != null ? `${blogs.imagesCopied} images copied` : null,
|
||||
blogs.imagesResized != null && blogs.imagesResized > 0
|
||||
? `${blogs.imagesResized} resized (≤1280px)`
|
||||
: null,
|
||||
blogs.imagesFailed != null && blogs.imagesFailed > 0
|
||||
? `${blogs.imagesFailed} images failed`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
parts.push(
|
||||
`blogs: ${blogs.created} created, ${blogs.skipped} skipped (${blogs.total} total; articles + news)` +
|
||||
(imgBits.length ? `; ${imgBits.join(', ')}` : ''),
|
||||
);
|
||||
}
|
||||
const customerCats = results.customer_categories;
|
||||
if (
|
||||
customerCats &&
|
||||
!('status' in customerCats) &&
|
||||
'created' in customerCats
|
||||
) {
|
||||
parts.push(
|
||||
`customer categories: ${customerCats.created} created, ${customerCats.skipped} skipped (${customerCats.total} total)`,
|
||||
);
|
||||
}
|
||||
|
||||
const productCats = results.product_categories;
|
||||
if (
|
||||
productCats &&
|
||||
!('status' in productCats) &&
|
||||
'created' in productCats
|
||||
) {
|
||||
parts.push(
|
||||
`product categories: ${productCats.created} created, ${productCats.skipped} skipped (${productCats.total} total)`,
|
||||
);
|
||||
}
|
||||
|
||||
const customers = results.customer;
|
||||
if (customers && !('status' in customers) && 'created' in customers) {
|
||||
const skipBits = [
|
||||
customers.skippedInvalidCell != null && customers.skippedInvalidCell > 0
|
||||
? `${customers.skippedInvalidCell} invalid/missing cell`
|
||||
: null,
|
||||
customers.skippedAlreadyLinked != null &&
|
||||
customers.skippedAlreadyLinked > 0
|
||||
? `${customers.skippedAlreadyLinked} already linked`
|
||||
: null,
|
||||
customers.skippedCreateFailed != null &&
|
||||
customers.skippedCreateFailed > 0
|
||||
? `${customers.skippedCreateFailed} create failed`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
parts.push(
|
||||
`customers: ${customers.created} created, ${customers.skipped} skipped (${customers.total} total)` +
|
||||
(skipBits.length ? `; ${skipBits.join(', ')}` : ''),
|
||||
);
|
||||
}
|
||||
|
||||
const products = results.product;
|
||||
if (products && !('status' in products) && 'created' in products) {
|
||||
const productImageBits = [
|
||||
products.imagesCopied != null
|
||||
? `${products.imagesCopied} images copied`
|
||||
: null,
|
||||
products.imagesResized != null && products.imagesResized > 0
|
||||
? `${products.imagesResized} resized`
|
||||
: null,
|
||||
products.imagesFailed != null && products.imagesFailed > 0
|
||||
? `${products.imagesFailed} image failures`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
parts.push(
|
||||
`products: ${products.created} created, ${products.skipped} skipped (${products.total} total)` +
|
||||
(productImageBits.length ? `; ${productImageBits.join(', ')}` : ''),
|
||||
);
|
||||
}
|
||||
if (pending.length) {
|
||||
parts.push(`not implemented yet: ${pending.join(', ')}`);
|
||||
}
|
||||
|
||||
const status =
|
||||
implemented.length === 0
|
||||
? ('linked' as const)
|
||||
: pending.length > 0
|
||||
? ('partial' as const)
|
||||
: ('ok' as const);
|
||||
|
||||
return {
|
||||
businessId: updated.id.toString(),
|
||||
oldBusinessId: updated.oldBusinessId?.toString() ?? String(dto.oldBusinessId),
|
||||
entities: dto.entities,
|
||||
status,
|
||||
results,
|
||||
message:
|
||||
parts.length > 0
|
||||
? `Old business linked. ${parts.join('. ')}.`
|
||||
: 'Old business id saved.',
|
||||
};
|
||||
}
|
||||
|
||||
async purgeData(businessIdRaw: string, dto: PurgeBusinessDataDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
const results = await this.legacyPurge.purgeEntities(businessId, dto.entities);
|
||||
|
||||
const implemented = dto.entities.filter((entity) => {
|
||||
const result = results[entity];
|
||||
return result && !('status' in result && result.status === 'not_implemented');
|
||||
});
|
||||
const pending = dto.entities.filter((entity) => {
|
||||
const result = results[entity];
|
||||
return result && 'status' in result && result.status === 'not_implemented';
|
||||
});
|
||||
|
||||
const parts: string[] = [];
|
||||
const productCats = results.product_categories;
|
||||
if (productCats && !('status' in productCats) && 'deleted' in productCats) {
|
||||
parts.push(`product categories: ${productCats.deleted} deleted`);
|
||||
}
|
||||
const products = results.product;
|
||||
if (products && !('status' in products) && 'deleted' in products) {
|
||||
const img =
|
||||
products.imagesDeleted != null
|
||||
? `, ${products.imagesDeleted} images deleted`
|
||||
: '';
|
||||
parts.push(`products: ${products.deleted} deleted${img}`);
|
||||
}
|
||||
const portfolioCats = results.portfolio_categories;
|
||||
if (
|
||||
portfolioCats &&
|
||||
!('status' in portfolioCats) &&
|
||||
'deleted' in portfolioCats
|
||||
) {
|
||||
parts.push(`portfolio categories: ${portfolioCats.deleted} deleted`);
|
||||
}
|
||||
const portfolios = results.portfolio;
|
||||
if (portfolios && !('status' in portfolios) && 'deleted' in portfolios) {
|
||||
const img =
|
||||
portfolios.imagesDeleted != null
|
||||
? `, ${portfolios.imagesDeleted} images deleted`
|
||||
: '';
|
||||
parts.push(`portfolios: ${portfolios.deleted} deleted${img}`);
|
||||
}
|
||||
const blogCats = results.blog_categories;
|
||||
if (blogCats && !('status' in blogCats) && 'deleted' in blogCats) {
|
||||
parts.push(`blog categories: ${blogCats.deleted} deleted`);
|
||||
}
|
||||
const blogs = results.blog;
|
||||
if (blogs && !('status' in blogs) && 'deleted' in blogs) {
|
||||
const img =
|
||||
blogs.imagesDeleted != null
|
||||
? `, ${blogs.imagesDeleted} images deleted`
|
||||
: '';
|
||||
parts.push(`blogs: ${blogs.deleted} deleted${img}`);
|
||||
}
|
||||
const customerCats = results.customer_categories;
|
||||
if (customerCats && !('status' in customerCats) && 'deleted' in customerCats) {
|
||||
parts.push(`customer categories: ${customerCats.deleted} deleted`);
|
||||
}
|
||||
const customers = results.customer;
|
||||
if (customers && !('status' in customers) && 'deleted' in customers) {
|
||||
parts.push(`customers: ${customers.deleted} deleted`);
|
||||
}
|
||||
if (pending.length) {
|
||||
parts.push(`not implemented yet: ${pending.join(', ')}`);
|
||||
}
|
||||
|
||||
const status =
|
||||
implemented.length === 0
|
||||
? ('noop' as const)
|
||||
: pending.length > 0
|
||||
? ('partial' as const)
|
||||
: ('ok' as const);
|
||||
|
||||
return {
|
||||
businessId: business.id.toString(),
|
||||
entities: dto.entities,
|
||||
status,
|
||||
results,
|
||||
message:
|
||||
parts.length > 0
|
||||
? `Data removed. ${parts.join('. ')}.`
|
||||
: 'No matching data to remove.',
|
||||
};
|
||||
}
|
||||
|
||||
private async assertSlugAvailable(slug: string, excludeId?: bigint) {
|
||||
const existing = await this.prisma.business.findUnique({ where: { slug } });
|
||||
if (existing && existing.id !== excludeId) {
|
||||
@@ -471,6 +788,18 @@ export class BusinessAdminService {
|
||||
}
|
||||
}
|
||||
|
||||
private async assertOldBusinessIdAvailable(oldBusinessId: bigint, excludeId?: bigint) {
|
||||
const existing = await this.prisma.business.findFirst({
|
||||
where: { oldBusinessId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (existing && existing.id !== excludeId) {
|
||||
throw new ConflictException(
|
||||
`Old business id ${oldBusinessId} is already linked to "${existing.name}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateCategoryIds(categoryIds: number[]) {
|
||||
const ids = [...new Set(categoryIds)].map((id) => BigInt(id));
|
||||
const count = await this.prisma.businessCategory.count({
|
||||
@@ -578,6 +907,7 @@ export class BusinessAdminService {
|
||||
about: string | null;
|
||||
slug: string;
|
||||
isActive: boolean;
|
||||
oldBusinessId: bigint | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
categoryAssignments: {
|
||||
@@ -615,6 +945,8 @@ export class BusinessAdminService {
|
||||
about: business.about,
|
||||
slug: business.slug,
|
||||
isActive: business.isActive,
|
||||
oldBusinessId:
|
||||
business.oldBusinessId != null ? business.oldBusinessId.toString() : null,
|
||||
createdAt: business.createdAt,
|
||||
updatedAt: business.updatedAt,
|
||||
categories: business.categoryAssignments.map((a) => ({
|
||||
|
||||
Reference in New Issue
Block a user