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
+2
View File
@@ -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,
+7 -7
View File
@@ -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(
+8 -2
View File
@@ -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 {}
+353 -21
View File
@@ -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
+480
View File
@@ -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,
+10
View File
@@ -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 {}
+71
View File
@@ -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 };
}
}
+3 -3
View File
@@ -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,
+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) ?? '',
+24
View File
@@ -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}`;
}