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
@@ -32,12 +32,14 @@ import { WebsiteModule } from './website/website.module';
|
||||
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
|
||||
import { WebsiteDocsModule } from './website-docs/website-docs.module';
|
||||
import { InvoicesModule } from './invoices/invoices.module';
|
||||
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
LegacyMysqlModule,
|
||||
AuthModule,
|
||||
BusinessTeamModule,
|
||||
BusinessAdminModule,
|
||||
|
||||
@@ -35,13 +35,13 @@ function slugify(value: string): string {
|
||||
type BlogWithRelations = Prisma.blogsGetPayload<{
|
||||
include: {
|
||||
media: true;
|
||||
users: { select: { id: true; firstName: true; lastName: true; email: true } };
|
||||
author: { select: { id: true; firstName: true; lastName: true; email: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
const blogInclude = {
|
||||
media: true,
|
||||
users: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
@@ -562,12 +562,12 @@ export class BlogsService {
|
||||
categoryName: categoryAssignment?.category.name ?? '',
|
||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||
authorId: blog.author_id?.toString() ?? null,
|
||||
author: blog.users
|
||||
author: blog.author
|
||||
? {
|
||||
id: blog.users.id.toString(),
|
||||
firstName: blog.users.firstName,
|
||||
lastName: blog.users.lastName,
|
||||
email: blog.users.email,
|
||||
id: blog.author.id.toString(),
|
||||
firstName: blog.author.firstName,
|
||||
lastName: blog.author.lastName,
|
||||
email: blog.author.email,
|
||||
}
|
||||
: null,
|
||||
titleImageUrl: blog.media?.publicUrl ?? null,
|
||||
|
||||
@@ -9,6 +9,8 @@ import { AddDomainDto } from './dto/add-domain.dto';
|
||||
import { UpdateDomainDto } from './dto/update-domain.dto';
|
||||
import { DisableBusinessDto } from './dto/disable-business.dto';
|
||||
import { UpdateBusinessDto } from './dto/update-business.dto';
|
||||
import { MigrateFromOldDto } from './dto/migrate-from-old.dto';
|
||||
import { PurgeBusinessDataDto } from './dto/purge-business-data.dto';
|
||||
import { BusinessAdminService } from './business-admin.service';
|
||||
|
||||
@Controller('businesses')
|
||||
@@ -55,6 +57,26 @@ export class BusinessAdminController {
|
||||
return this.service.update(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Post(':businessId/migrate-from-old')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
migrateFromOld(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: MigrateFromOldDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.migrateFromOld(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Post(':businessId/purge-data')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
purgeData(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: PurgeBusinessDataDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.purgeData(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Post(':businessId/domains')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
addDomain(
|
||||
|
||||
@@ -4,11 +4,17 @@ import { BusinessCategoriesController } from './business-categories.controller';
|
||||
import { BusinessCategoriesService } from './business-categories.service';
|
||||
import { BusinessAdminController } from './business-admin.controller';
|
||||
import { BusinessAdminService } from './business-admin.service';
|
||||
import { LegacyMigrateService } from './legacy-migrate.service';
|
||||
import { LegacyPurgeService } from './legacy-purge.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [BusinessAdminController, BusinessCategoriesController],
|
||||
providers: [BusinessAdminService, BusinessCategoriesService],
|
||||
providers: [
|
||||
BusinessAdminService,
|
||||
BusinessCategoriesService,
|
||||
LegacyMigrateService,
|
||||
LegacyPurgeService,
|
||||
],
|
||||
})
|
||||
export class BusinessAdminModule {}
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
@@ -52,4 +53,11 @@ export class CreateBusinessDto {
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
ownerPassword!: string;
|
||||
|
||||
/** Legacy WillaEngine businesses.id for later selective migration. */
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
oldBusinessId?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, IsIn, IsInt, IsPositive } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export const MIGRATE_FROM_OLD_ENTITIES = [
|
||||
'product_categories',
|
||||
'product',
|
||||
'customer_categories',
|
||||
'customer',
|
||||
'blog_categories',
|
||||
'blog',
|
||||
'portfolio_categories',
|
||||
'portfolio',
|
||||
] as const;
|
||||
|
||||
export type MigrateFromOldEntity = (typeof MIGRATE_FROM_OLD_ENTITIES)[number];
|
||||
|
||||
export class MigrateFromOldDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
oldBusinessId!: number;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(1)
|
||||
@ArrayUnique()
|
||||
@IsIn(MIGRATE_FROM_OLD_ENTITIES, { each: true })
|
||||
entities!: MigrateFromOldEntity[];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, IsIn } from 'class-validator';
|
||||
import {
|
||||
MIGRATE_FROM_OLD_ENTITIES,
|
||||
MigrateFromOldEntity,
|
||||
} from './migrate-from-old.dto';
|
||||
|
||||
/** Same entity keys as migrate-from-old — selective delete for re-migration. */
|
||||
export const PURGE_BUSINESS_DATA_ENTITIES = MIGRATE_FROM_OLD_ENTITIES;
|
||||
|
||||
export type PurgeBusinessDataEntity = MigrateFromOldEntity;
|
||||
|
||||
export class PurgeBusinessDataDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(1)
|
||||
@ArrayUnique()
|
||||
@IsIn(PURGE_BUSINESS_DATA_ENTITIES, { each: true })
|
||||
entities!: PurgeBusinessDataEntity[];
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
MinLength,
|
||||
@@ -44,4 +45,12 @@ export class UpdateBusinessDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
ownerUserId?: number | null;
|
||||
|
||||
/** Legacy WillaEngine businesses.id; null clears the link. */
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => value !== null)
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
oldBusinessId?: number | null;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,480 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MediaEntityType } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { PurgeBusinessDataEntity } from './dto/purge-business-data.dto';
|
||||
|
||||
type ContentCategoryEntity =
|
||||
| typeof MediaEntityType.portfolio
|
||||
| typeof MediaEntityType.blog
|
||||
| typeof MediaEntityType.customer
|
||||
| typeof MediaEntityType.product;
|
||||
|
||||
export type PurgeEntityCounts = {
|
||||
deleted: number;
|
||||
imagesDeleted?: number;
|
||||
};
|
||||
|
||||
export type PurgeEntityResult =
|
||||
| PurgeEntityCounts
|
||||
| { status: 'not_implemented' };
|
||||
|
||||
@Injectable()
|
||||
export class LegacyPurgeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storage: StorageService,
|
||||
) {}
|
||||
|
||||
async purgeEntities(
|
||||
businessId: bigint,
|
||||
entities: PurgeBusinessDataEntity[],
|
||||
): Promise<Partial<Record<PurgeBusinessDataEntity, PurgeEntityResult>>> {
|
||||
const selected = new Set(entities);
|
||||
const results: Partial<
|
||||
Record<PurgeBusinessDataEntity, PurgeEntityResult>
|
||||
> = {};
|
||||
|
||||
// Items before categories so assignments clear cleanly with parent rows.
|
||||
if (selected.has('product')) {
|
||||
results.product = await this.purgeProducts(businessId);
|
||||
}
|
||||
if (selected.has('product_categories')) {
|
||||
results.product_categories = await this.purgeProductCategories(businessId);
|
||||
}
|
||||
if (selected.has('portfolio')) {
|
||||
results.portfolio = await this.purgePortfolios(businessId);
|
||||
}
|
||||
if (selected.has('portfolio_categories')) {
|
||||
results.portfolio_categories =
|
||||
await this.purgePortfolioCategories(businessId);
|
||||
}
|
||||
if (selected.has('blog')) {
|
||||
results.blog = await this.purgeBlogs(businessId);
|
||||
}
|
||||
if (selected.has('blog_categories')) {
|
||||
results.blog_categories = await this.purgeBlogCategories(businessId);
|
||||
}
|
||||
if (selected.has('customer')) {
|
||||
results.customer = await this.purgeCustomers(businessId);
|
||||
}
|
||||
if (selected.has('customer_categories')) {
|
||||
results.customer_categories =
|
||||
await this.purgeCustomerCategories(businessId);
|
||||
}
|
||||
|
||||
for (const entity of entities) {
|
||||
if (!results[entity]) {
|
||||
results[entity] = { status: 'not_implemented' };
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async purgeProducts(businessId: bigint): Promise<PurgeEntityCounts> {
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: { businessId },
|
||||
select: { id: true, featuredMediaId: true },
|
||||
});
|
||||
const productIds = products.map((p) => p.id);
|
||||
|
||||
if (!productIds.length) {
|
||||
return { deleted: 0, imagesDeleted: 0 };
|
||||
}
|
||||
|
||||
const attachments = await this.prisma.mediaAttachment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: { in: productIds },
|
||||
},
|
||||
select: { mediaId: true },
|
||||
});
|
||||
|
||||
const mediaIds = new Set<bigint>();
|
||||
for (const p of products) {
|
||||
if (p.featuredMediaId != null) mediaIds.add(p.featuredMediaId);
|
||||
}
|
||||
for (const a of attachments) {
|
||||
mediaIds.add(a.mediaId);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.mediaAttachment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: { in: productIds },
|
||||
},
|
||||
});
|
||||
await tx.categoryAssignment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
entityId: { in: productIds },
|
||||
},
|
||||
});
|
||||
// order_items references products — must remove before deleting products.
|
||||
await tx.orderItem.deleteMany({
|
||||
where: { productId: { in: productIds } },
|
||||
});
|
||||
// shopping_card_items references products — must remove before deleting products.
|
||||
await tx.shoppingCardItem.deleteMany({
|
||||
where: { productId: { in: productIds } },
|
||||
});
|
||||
await tx.product.updateMany({
|
||||
where: { businessId, id: { in: productIds } },
|
||||
data: { featuredMediaId: null },
|
||||
});
|
||||
await tx.product.deleteMany({
|
||||
where: { businessId, id: { in: productIds } },
|
||||
});
|
||||
});
|
||||
|
||||
const imagesDeleted = await this.deleteMediaIds(businessId, [...mediaIds]);
|
||||
|
||||
return { deleted: productIds.length, imagesDeleted };
|
||||
}
|
||||
|
||||
private async purgeProductCategories(
|
||||
businessId: bigint,
|
||||
): Promise<PurgeEntityCounts> {
|
||||
return this.purgeCategories(businessId, MediaEntityType.product);
|
||||
}
|
||||
|
||||
private async purgePortfolios(businessId: bigint): Promise<PurgeEntityCounts> {
|
||||
const portfolios = await this.prisma.portfolios.findMany({
|
||||
where: { business_id: businessId },
|
||||
select: { id: true, featured_media_id: true },
|
||||
});
|
||||
const portfolioIds = portfolios.map((p) => p.id);
|
||||
|
||||
if (!portfolioIds.length) {
|
||||
return { deleted: 0, imagesDeleted: 0 };
|
||||
}
|
||||
|
||||
const attachments = await this.prisma.mediaAttachment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.portfolio,
|
||||
entityId: { in: portfolioIds },
|
||||
},
|
||||
select: { mediaId: true },
|
||||
});
|
||||
|
||||
const mediaIds = new Set<bigint>();
|
||||
for (const row of portfolios) {
|
||||
if (row.featured_media_id != null) {
|
||||
mediaIds.add(row.featured_media_id);
|
||||
}
|
||||
}
|
||||
for (const row of attachments) {
|
||||
mediaIds.add(row.mediaId);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.mediaAttachment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.portfolio,
|
||||
entityId: { in: portfolioIds },
|
||||
},
|
||||
});
|
||||
await tx.categoryAssignment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.portfolio,
|
||||
entityId: { in: portfolioIds },
|
||||
},
|
||||
});
|
||||
await tx.portfolios.updateMany({
|
||||
where: { business_id: businessId, id: { in: portfolioIds } },
|
||||
data: { featured_media_id: null },
|
||||
});
|
||||
await tx.portfolios.deleteMany({
|
||||
where: { business_id: businessId, id: { in: portfolioIds } },
|
||||
});
|
||||
});
|
||||
|
||||
const imagesDeleted = await this.deleteMediaIds(businessId, [...mediaIds]);
|
||||
|
||||
return {
|
||||
deleted: portfolioIds.length,
|
||||
imagesDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
private async purgePortfolioCategories(
|
||||
businessId: bigint,
|
||||
): Promise<PurgeEntityCounts> {
|
||||
return this.purgeCategories(businessId, MediaEntityType.portfolio);
|
||||
}
|
||||
|
||||
private async purgeBlogs(businessId: bigint): Promise<PurgeEntityCounts> {
|
||||
const blogs = await this.prisma.blogs.findMany({
|
||||
where: { business_id: businessId },
|
||||
select: { id: true, featured_media_id: true },
|
||||
});
|
||||
const blogIds = blogs.map((b) => b.id);
|
||||
|
||||
if (!blogIds.length) {
|
||||
return { deleted: 0, imagesDeleted: 0 };
|
||||
}
|
||||
|
||||
const attachments = await this.prisma.mediaAttachment.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.blog,
|
||||
entityId: { in: blogIds },
|
||||
},
|
||||
select: { mediaId: true },
|
||||
});
|
||||
|
||||
const mediaIds = new Set<bigint>();
|
||||
for (const row of blogs) {
|
||||
if (row.featured_media_id != null) {
|
||||
mediaIds.add(row.featured_media_id);
|
||||
}
|
||||
}
|
||||
for (const row of attachments) {
|
||||
mediaIds.add(row.mediaId);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.mediaAttachment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.blog,
|
||||
entityId: { in: blogIds },
|
||||
},
|
||||
});
|
||||
await tx.categoryAssignment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.blog,
|
||||
entityId: { in: blogIds },
|
||||
},
|
||||
});
|
||||
await tx.blogs.updateMany({
|
||||
where: { business_id: businessId, id: { in: blogIds } },
|
||||
data: { featured_media_id: null },
|
||||
});
|
||||
await tx.blogs.deleteMany({
|
||||
where: { business_id: businessId, id: { in: blogIds } },
|
||||
});
|
||||
});
|
||||
|
||||
const imagesDeleted = await this.deleteMediaIds(businessId, [...mediaIds]);
|
||||
|
||||
return {
|
||||
deleted: blogIds.length,
|
||||
imagesDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
private async purgeBlogCategories(
|
||||
businessId: bigint,
|
||||
): Promise<PurgeEntityCounts> {
|
||||
return this.purgeCategories(businessId, MediaEntityType.blog);
|
||||
}
|
||||
|
||||
private async purgeCustomers(businessId: bigint): Promise<PurgeEntityCounts> {
|
||||
const links = await this.prisma.businessCustomer.findMany({
|
||||
where: { businessId },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
const userIds = links.map((l) => l.userId);
|
||||
|
||||
if (!userIds.length) {
|
||||
return { deleted: 0 };
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.categoryAssignment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.customer,
|
||||
entityId: { in: userIds },
|
||||
},
|
||||
});
|
||||
await tx.businessCustomer.deleteMany({
|
||||
where: { businessId, userId: { in: userIds } },
|
||||
});
|
||||
});
|
||||
|
||||
return { deleted: links.length };
|
||||
}
|
||||
|
||||
private async purgeCustomerCategories(
|
||||
businessId: bigint,
|
||||
): Promise<PurgeEntityCounts> {
|
||||
return this.purgeCategories(businessId, MediaEntityType.customer);
|
||||
}
|
||||
|
||||
private async purgeCategories(
|
||||
businessId: bigint,
|
||||
entityType: ContentCategoryEntity,
|
||||
): Promise<PurgeEntityCounts> {
|
||||
const categories = await this.prisma.category.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const categoryIds = categories.map((c) => c.id);
|
||||
|
||||
if (!categoryIds.length) {
|
||||
return { deleted: 0 };
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.categoryAssignment.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
categoryId: { in: categoryIds },
|
||||
},
|
||||
});
|
||||
// Break self-FK so deleteMany can remove the whole tree.
|
||||
await tx.category.updateMany({
|
||||
where: { id: { in: categoryIds } },
|
||||
data: { parentId: null },
|
||||
});
|
||||
await tx.category.deleteMany({
|
||||
where: { id: { in: categoryIds } },
|
||||
});
|
||||
});
|
||||
|
||||
return { deleted: categoryIds.length };
|
||||
}
|
||||
|
||||
private async deleteMediaIds(
|
||||
businessId: bigint,
|
||||
mediaIds: bigint[],
|
||||
): Promise<number> {
|
||||
if (!mediaIds.length) return 0;
|
||||
|
||||
const uniqueIds = [...new Set(mediaIds.map((id) => id.toString()))].map(
|
||||
(id) => BigInt(id),
|
||||
);
|
||||
|
||||
const stillLinked = new Set<string>();
|
||||
|
||||
const remainingAttachments = await this.prisma.mediaAttachment.findMany({
|
||||
where: { businessId, mediaId: { in: uniqueIds } },
|
||||
select: { mediaId: true },
|
||||
});
|
||||
for (const row of remainingAttachments) {
|
||||
stillLinked.add(row.mediaId.toString());
|
||||
}
|
||||
|
||||
const featuredOnPortfolios = await this.prisma.portfolios.findMany({
|
||||
where: {
|
||||
business_id: businessId,
|
||||
featured_media_id: { in: uniqueIds },
|
||||
},
|
||||
select: { featured_media_id: true },
|
||||
});
|
||||
for (const row of featuredOnPortfolios) {
|
||||
if (row.featured_media_id != null) {
|
||||
stillLinked.add(row.featured_media_id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const featuredOnProducts = await this.prisma.product.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
featuredMediaId: { in: uniqueIds },
|
||||
},
|
||||
select: { featuredMediaId: true },
|
||||
});
|
||||
for (const row of featuredOnProducts) {
|
||||
if (row.featuredMediaId != null) {
|
||||
stillLinked.add(row.featuredMediaId.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const featuredOnBlogs = await this.prisma.blogs.findMany({
|
||||
where: {
|
||||
business_id: businessId,
|
||||
featured_media_id: { in: uniqueIds },
|
||||
},
|
||||
select: { featured_media_id: true },
|
||||
});
|
||||
for (const row of featuredOnBlogs) {
|
||||
if (row.featured_media_id != null) {
|
||||
stillLinked.add(row.featured_media_id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const brandImages = await this.prisma.brand.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
imageMediaId: { in: uniqueIds },
|
||||
},
|
||||
select: { imageMediaId: true },
|
||||
});
|
||||
for (const row of brandImages) {
|
||||
if (row.imageMediaId != null) {
|
||||
stillLinked.add(row.imageMediaId.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const businessMedia = await this.prisma.business.findFirst({
|
||||
where: {
|
||||
id: businessId,
|
||||
OR: [
|
||||
{ logoMediaId: { in: uniqueIds } },
|
||||
{ faviconMediaId: { in: uniqueIds } },
|
||||
],
|
||||
},
|
||||
select: { logoMediaId: true, faviconMediaId: true },
|
||||
});
|
||||
if (businessMedia?.logoMediaId != null) {
|
||||
stillLinked.add(businessMedia.logoMediaId.toString());
|
||||
}
|
||||
if (businessMedia?.faviconMediaId != null) {
|
||||
stillLinked.add(businessMedia.faviconMediaId.toString());
|
||||
}
|
||||
|
||||
const sliderSlides = await this.prisma.website_slider_slides.findMany({
|
||||
where: {
|
||||
image_media_id: { in: uniqueIds },
|
||||
website_sliders: { business_id: businessId },
|
||||
},
|
||||
select: { image_media_id: true },
|
||||
});
|
||||
for (const row of sliderSlides) {
|
||||
stillLinked.add(row.image_media_id.toString());
|
||||
}
|
||||
|
||||
const deletableIds = uniqueIds.filter(
|
||||
(id) => !stillLinked.has(id.toString()),
|
||||
);
|
||||
if (!deletableIds.length) return 0;
|
||||
|
||||
const mediaRows = await this.prisma.media.findMany({
|
||||
where: { businessId, id: { in: deletableIds } },
|
||||
select: {
|
||||
id: true,
|
||||
storagePath: true,
|
||||
storageDisk: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.media.deleteMany({
|
||||
where: { businessId, id: { in: mediaRows.map((m) => m.id) } },
|
||||
});
|
||||
|
||||
for (const row of mediaRows) {
|
||||
try {
|
||||
await this.storage.delete(row.storagePath, row.storageDisk);
|
||||
} catch {
|
||||
// DB row removed; orphaned object can be cleaned later
|
||||
}
|
||||
}
|
||||
|
||||
return mediaRows.length;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import sharp from 'sharp';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { businessBrandFaviconKey } from '../storage/storage-keys';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto';
|
||||
import {
|
||||
@@ -251,7 +252,7 @@ export class BusinessProfileService {
|
||||
) {
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { slug: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!business) return;
|
||||
|
||||
@@ -302,7 +303,7 @@ export class BusinessProfileService {
|
||||
.toBuffer();
|
||||
|
||||
const fileName = `${randomUUID()}-favicon.png`;
|
||||
const storageKey = `businesses/${business.slug}/${businessId}/media/${fileName}`;
|
||||
const storageKey = businessBrandFaviconKey(businessId, fileName);
|
||||
const stored = await this.storage.upload({
|
||||
key: storageKey,
|
||||
body: faviconBuffer,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { LegacyMysqlService } from './legacy-mysql.service';
|
||||
import { LegacySourceS3Service } from './legacy-source-s3.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [LegacyMysqlService, LegacySourceS3Service],
|
||||
exports: [LegacyMysqlService, LegacySourceS3Service],
|
||||
})
|
||||
export class LegacyMysqlModule {}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Injectable,
|
||||
OnModuleDestroy,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import mysql, { Pool, PoolOptions, RowDataPacket } from 'mysql2/promise';
|
||||
|
||||
@Injectable()
|
||||
export class LegacyMysqlService implements OnModuleDestroy {
|
||||
private pool: Pool | null = null;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.pool) {
|
||||
await this.pool.end();
|
||||
this.pool = null;
|
||||
}
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return Boolean(
|
||||
this.config.get<string>('OLD_MYSQL_HOST') &&
|
||||
this.config.get<string>('OLD_MYSQL_USER') &&
|
||||
this.config.get<string>('OLD_MYSQL_DATABASE'),
|
||||
);
|
||||
}
|
||||
|
||||
async query<T extends RowDataPacket[]>(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T> {
|
||||
const pool = this.getPool();
|
||||
try {
|
||||
const [rows] = await pool.query<T>(sql, params);
|
||||
return rows;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown MySQL error';
|
||||
throw new ServiceUnavailableException(
|
||||
`Old CMS MySQL query failed: ${message}. Ensure the SSH tunnel is up and OLD_MYSQL_* is set.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getPool(): Pool {
|
||||
if (this.pool) {
|
||||
return this.pool;
|
||||
}
|
||||
|
||||
if (!this.isConfigured()) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Old CMS MySQL is not configured. Set OLD_MYSQL_HOST, OLD_MYSQL_USER, OLD_MYSQL_DATABASE (and password/port).',
|
||||
);
|
||||
}
|
||||
|
||||
const options: PoolOptions = {
|
||||
host: this.config.getOrThrow<string>('OLD_MYSQL_HOST'),
|
||||
port: Number(this.config.get<string>('OLD_MYSQL_PORT', '3307')),
|
||||
user: this.config.getOrThrow<string>('OLD_MYSQL_USER'),
|
||||
password: this.config.get<string>('OLD_MYSQL_PASSWORD', ''),
|
||||
database: this.config.getOrThrow<string>('OLD_MYSQL_DATABASE'),
|
||||
waitForConnections: true,
|
||||
connectionLimit: 4,
|
||||
namedPlaceholders: false,
|
||||
};
|
||||
|
||||
this.pool = mysql.createPool(options);
|
||||
return this.pool;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
Injectable,
|
||||
OnModuleDestroy,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class LegacySourceS3Service implements OnModuleDestroy {
|
||||
private client: S3Client | null = null;
|
||||
private bucket = '';
|
||||
private publicUrlBase = '';
|
||||
private readonly prefixCache = new Map<number, string>();
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.client?.destroy();
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return Boolean(
|
||||
this.config.get<string>('OLD_S3_ENDPOINT') &&
|
||||
this.config.get<string>('OLD_S3_BUCKET') &&
|
||||
this.config.get<string>('OLD_S3_ACCESS_KEY_ID') &&
|
||||
this.config.get<string>('OLD_S3_SECRET_ACCESS_KEY'),
|
||||
);
|
||||
}
|
||||
|
||||
spatieObjectKey(prefix: string, mediaId: number, fileName: string): string {
|
||||
const hash = createHash('md5').update(String(mediaId)).digest('hex');
|
||||
return `${prefix}/${hash}/${fileName}`;
|
||||
}
|
||||
|
||||
async resolveBusinessPrefix(
|
||||
oldBusinessId: number,
|
||||
slugHint: string | null,
|
||||
probes: Array<{ id: number; fileName: string }>,
|
||||
): Promise<string> {
|
||||
const cached = this.prefixCache.get(oldBusinessId);
|
||||
if (cached) return cached;
|
||||
|
||||
const candidates: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (prefix: string) => {
|
||||
if (!prefix || seen.has(prefix)) return;
|
||||
seen.add(prefix);
|
||||
candidates.push(prefix);
|
||||
};
|
||||
|
||||
if (slugHint?.trim()) {
|
||||
const slug = slugHint.trim();
|
||||
add(`${slug}_${oldBusinessId}`);
|
||||
add(`${slug.replace(/-/g, '')}_${oldBusinessId}`);
|
||||
}
|
||||
|
||||
const listed = await this.listTopLevelPrefixes();
|
||||
for (const prefix of listed) {
|
||||
if (prefix.endsWith(`_${oldBusinessId}`)) {
|
||||
add(prefix);
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidates.length) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Could not resolve old S3 path prefix for business ${oldBusinessId}. Check OLD_S3_* and that files exist.`,
|
||||
);
|
||||
}
|
||||
|
||||
const sample = probes.slice(0, 12);
|
||||
let bestPrefix = candidates[0];
|
||||
let bestHits = -1;
|
||||
|
||||
for (const prefix of candidates) {
|
||||
let hits = 0;
|
||||
for (const probe of sample) {
|
||||
const key = this.spatieObjectKey(prefix, probe.id, probe.fileName);
|
||||
if (await this.objectExists(key)) {
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
if (hits > bestHits) {
|
||||
bestHits = hits;
|
||||
bestPrefix = prefix;
|
||||
}
|
||||
if (bestHits === sample.length && sample.length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestHits <= 0 && sample.length > 0) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Old S3 files not found for business ${oldBusinessId} (tried: ${candidates.join(', ')}).`,
|
||||
);
|
||||
}
|
||||
|
||||
this.prefixCache.set(oldBusinessId, bestPrefix);
|
||||
return bestPrefix;
|
||||
}
|
||||
|
||||
async getObjectBuffer(key: string): Promise<Buffer> {
|
||||
const { client, bucket } = this.getClient();
|
||||
try {
|
||||
const result = await client.send(
|
||||
new GetObjectCommand({ Bucket: bucket, Key: key }),
|
||||
);
|
||||
if (!result.Body) {
|
||||
throw new Error(`Empty body for ${key}`);
|
||||
}
|
||||
return Buffer.from(await result.Body.transformToByteArray());
|
||||
} catch (err) {
|
||||
// Public URL fallback (path-style bucket public reads)
|
||||
if (this.publicUrlBase) {
|
||||
const res = await fetch(`${this.publicUrlBase}/${key}`);
|
||||
if (res.ok) {
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown S3 error';
|
||||
throw new ServiceUnavailableException(
|
||||
`Old CMS S3 get failed for ${key}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async objectExists(key: string): Promise<boolean> {
|
||||
const { client, bucket } = this.getClient();
|
||||
try {
|
||||
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
||||
return true;
|
||||
} catch {
|
||||
if (this.publicUrlBase) {
|
||||
try {
|
||||
const res = await fetch(`${this.publicUrlBase}/${key}`, {
|
||||
method: 'HEAD',
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async listTopLevelPrefixes(): Promise<string[]> {
|
||||
const { client, bucket } = this.getClient();
|
||||
const prefixes: string[] = [];
|
||||
let token: string | undefined;
|
||||
do {
|
||||
const res = await client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Delimiter: '/',
|
||||
ContinuationToken: token,
|
||||
MaxKeys: 1000,
|
||||
}),
|
||||
);
|
||||
for (const p of res.CommonPrefixes ?? []) {
|
||||
const raw = p.Prefix?.replace(/\/$/, '');
|
||||
if (raw) prefixes.push(raw);
|
||||
}
|
||||
token = res.IsTruncated ? res.NextContinuationToken : undefined;
|
||||
} while (token);
|
||||
return prefixes;
|
||||
}
|
||||
|
||||
private getClient(): { client: S3Client; bucket: string } {
|
||||
if (this.client) {
|
||||
return { client: this.client, bucket: this.bucket };
|
||||
}
|
||||
|
||||
if (!this.isConfigured()) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Old CMS S3 is not configured. Set OLD_S3_ENDPOINT, OLD_S3_BUCKET, OLD_S3_ACCESS_KEY_ID, OLD_S3_SECRET_ACCESS_KEY.',
|
||||
);
|
||||
}
|
||||
|
||||
this.bucket = this.config.getOrThrow<string>('OLD_S3_BUCKET');
|
||||
this.publicUrlBase = (
|
||||
this.config.get<string>('OLD_S3_PUBLIC_URL') ?? ''
|
||||
).replace(/\/$/, '');
|
||||
|
||||
this.client = new S3Client({
|
||||
endpoint: this.config.getOrThrow<string>('OLD_S3_ENDPOINT'),
|
||||
region: this.config.get<string>('OLD_S3_REGION', 'us-east-1'),
|
||||
forcePathStyle:
|
||||
this.config.get<string>('OLD_S3_FORCE_PATH_STYLE', 'true') === 'true',
|
||||
credentials: {
|
||||
accessKeyId: this.config.getOrThrow<string>('OLD_S3_ACCESS_KEY_ID'),
|
||||
secretAccessKey: this.config.getOrThrow<string>(
|
||||
'OLD_S3_SECRET_ACCESS_KEY',
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return { client: this.client, bucket: this.bucket };
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import sharp from 'sharp';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { businessMediaKey } from '../storage/storage-keys';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { ListMediaDto } from './dto/list-media.dto';
|
||||
import { UpdateMediaDto } from './dto/update-media.dto';
|
||||
@@ -92,7 +93,7 @@ export class MediaService {
|
||||
|
||||
const items = [];
|
||||
for (const file of files) {
|
||||
items.push(await this.uploadOne(businessId, business.slug, file, actor.id));
|
||||
items.push(await this.uploadOne(businessId, file, actor.id));
|
||||
}
|
||||
|
||||
return { items };
|
||||
@@ -153,7 +154,6 @@ export class MediaService {
|
||||
|
||||
private async uploadOne(
|
||||
businessId: bigint,
|
||||
businessSlug: string,
|
||||
file: Express.Multer.File,
|
||||
uploadedBy: bigint,
|
||||
) {
|
||||
@@ -201,7 +201,7 @@ export class MediaService {
|
||||
|
||||
const ext = this.extensionFromContentType(contentType);
|
||||
const fileName = `${randomUUID()}${ext}`;
|
||||
const storageKey = `businesses/${businessSlug}/${businessId}/media/${fileName}`;
|
||||
const storageKey = businessMediaKey(businessId, fileName);
|
||||
|
||||
const stored = await this.storage.upload({
|
||||
key: storageKey,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ?? '',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Parspack S3 object key layout (path-style public URLs):
|
||||
*
|
||||
* meshkee/
|
||||
* businesses/
|
||||
* {businessId}/
|
||||
* media/{uuid}{ext} — media library uploads
|
||||
* brand/favicon-{uuid}.png — derived favicons
|
||||
*/
|
||||
const ROOT = 'meshkee';
|
||||
|
||||
export function businessMediaKey(
|
||||
businessId: bigint | string | number,
|
||||
fileName: string,
|
||||
): string {
|
||||
return `${ROOT}/businesses/${businessId}/media/${fileName}`;
|
||||
}
|
||||
|
||||
export function businessBrandFaviconKey(
|
||||
businessId: bigint | string | number,
|
||||
fileName: string,
|
||||
): string {
|
||||
return `${ROOT}/businesses/${businessId}/brand/${fileName}`;
|
||||
}
|
||||
Reference in New Issue
Block a user