import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { PermissionsService } from '../auth/permissions.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuthUser } from '../auth/auth.types'; import { AddDomainDto } from './dto/add-domain.dto'; import { UpdateDomainDto } from './dto/update-domain.dto'; import { CreateBusinessDto } from './dto/create-business.dto'; 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'; type BusinessRow = { id: bigint; name: string; nameFa: string | null; about: string | null; slug: string; createdAt: Date; isActive: boolean; oldBusinessId: bigint | null; domainId: bigint | null; domain: string | null; sslEnabled: boolean | null; ownerUserId: bigint | null; ownerName: string | null; ownerCellNumber: string | null; primaryColor: string | null; }; function slugify(value: string) { return value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, ''); } @Injectable() 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) { if (!(await this.permissions.isSuperAdmin(actor.id))) { throw new ForbiddenException('Super admin access required'); } } async list(query: ListBusinessesDto, actor: AuthUser) { await this.assertSuperAdmin(actor); const page = query.page ?? 1; const pageSize = query.pageSize ?? 10; const skip = (page - 1) * pageSize; const nameLike = query.name ? `%${query.name.trim()}%` : null; const domainLike = query.domain ? `%${query.domain.trim()}%` : null; const categoryLike = query.category ? `%${query.category.trim()}%` : null; const where = Prisma.sql` WHERE 1=1 ${nameLike ? Prisma.sql`AND (b.name ILIKE ${nameLike} OR b.name_fa ILIKE ${nameLike})` : Prisma.empty} ${domainLike ? Prisma.sql` AND EXISTS ( SELECT 1 FROM domains d WHERE d.business_id = b.id AND d.host ILIKE ${domainLike} ) ` : Prisma.empty} ${categoryLike ? Prisma.sql` AND EXISTS ( SELECT 1 FROM business_category_assignments bca JOIN business_categories bc ON bc.id = bca.category_id WHERE bca.business_id = b.id AND (bc.slug ILIKE ${categoryLike} OR bc.name ILIKE ${categoryLike}) ) ` : Prisma.empty} `; const [items, totalRow] = await Promise.all([ this.prisma.$queryRaw(Prisma.sql` SELECT b.id AS "id", b.name AS "name", b.name_fa AS "nameFa", b.about AS "about", 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", own."ownerUserId" AS "ownerUserId", own."ownerName" AS "ownerName", own."ownerCellNumber" AS "ownerCellNumber", b.settings->'branding'->>'primaryColor' AS "primaryColor" FROM businesses b LEFT JOIN LATERAL ( SELECT d.id, d.host, d.ssl_enabled FROM domains d WHERE d.business_id = b.id ORDER BY d.is_primary DESC, d.created_at DESC LIMIT 1 ) dom ON TRUE LEFT JOIN LATERAL ( SELECT u.id AS "ownerUserId", (u.first_name || ' ' || u.last_name) AS "ownerName", u.cell_number AS "ownerCellNumber" FROM business_users bu JOIN users u ON u.id = bu.user_id WHERE bu.business_id = b.id AND bu.is_owner = TRUE LIMIT 1 ) own ON TRUE ${where} ORDER BY b.created_at DESC LIMIT ${pageSize} OFFSET ${skip} `), this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql` SELECT COUNT(*)::int AS "total" FROM businesses b ${where} `), ]); return { items: items.map((item) => ({ ...item, primaryColor: normalizeBusinessPrimaryColorId( item.primaryColor, ) as BusinessPrimaryColorId, })), total: totalRow[0]?.total ?? 0, page, pageSize, }; } async search(query: SearchBusinessesDto, actor: AuthUser) { await this.assertSuperAdmin(actor); const q = query.q.trim(); const limit = Math.min(Math.max(query.limit ?? 20, 1), 50); const like = `%${q}%`; const items = await this.prisma.$queryRaw< { id: bigint; name: string; nameFa: string | null; slug: string; }[] >(Prisma.sql` SELECT DISTINCT b.id AS "id", b.name AS "name", b.name_fa AS "nameFa", b.slug AS "slug" FROM businesses b LEFT JOIN domains d ON d.business_id = b.id WHERE b.is_active = TRUE AND ( b.name ILIKE ${like} OR b.name_fa ILIKE ${like} OR b.slug ILIKE ${like} OR d.host ILIKE ${like} ) ORDER BY b.name ASC LIMIT ${limit} `); return { items: items.map((business) => ({ id: business.id, name: business.name, nameFa: business.nameFa, slug: business.slug, label: this.formatBusinessLabel(business), })), }; } async listStaff(businessIdRaw: string, 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 members = await this.prisma.businessUser.findMany({ where: { businessId }, include: { user: { select: { id: true, cellNumber: true, firstName: true, lastName: true, email: true, cellVerifiedAt: true, isActive: true, }, }, role: true, inviter: { select: { id: true, firstName: true, lastName: true } }, }, orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }], }); return { items: members.map((member) => ({ id: member.id, userId: member.user.id, cellNumber: member.user.cellNumber, firstName: member.user.firstName, lastName: member.user.lastName, email: member.user.email, isActive: member.user.isActive, isVerified: member.user.cellVerifiedAt !== null, isOwner: member.isOwner, teamRole: member.isOwner ? 'business_owner' : member.role?.slug ?? null, invitedBy: member.inviter, createdAt: member.createdAt, })), }; } async getOne(businessIdRaw: string, actor: AuthUser) { await this.assertSuperAdmin(actor); const businessId = BigInt(businessIdRaw); const business = await this.prisma.business.findUnique({ where: { id: businessId }, include: { categoryAssignments: { include: { category: true }, }, businessUsers: { where: { isOwner: true }, include: { user: true }, take: 1, }, domains: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] }, }, }); if (!business) { throw new NotFoundException('Business not found'); } return this.serializeBusiness(business); } async create(dto: CreateBusinessDto, actor: AuthUser) { await this.assertSuperAdmin(actor); const slug = dto.slug?.trim() || slugify(dto.name); if (!slug) { throw new BadRequestException('Could not generate slug from name'); } await this.assertSlugAvailable(slug); await this.validateCategoryIds(dto.categoryIds); if (dto.oldBusinessId !== undefined) { await this.assertOldBusinessIdAvailable(BigInt(dto.oldBusinessId)); } 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; }); } 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); } async update(businessIdRaw: string, dto: UpdateBusinessDto, 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 nextName = dto.name?.trim() ?? business.name; const nextNameFa = dto.nameFa?.trim() ?? business.nameFa ?? business.name; const nextSlug = dto.slug?.trim() ?? (dto.name ? slugify(dto.name) : business.slug); if (nextSlug !== business.slug) { await this.assertSlugAvailable(nextSlug, businessId); } if (dto.categoryIds) { await this.validateCategoryIds(dto.categoryIds); } if (dto.ownerUserId !== undefined && dto.ownerUserId !== null) { 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 }, data: { name: nextName, nameFa: nextNameFa, about: dto.about !== undefined ? dto.about.trim() || null : undefined, slug: nextSlug, ...(dto.oldBusinessId !== undefined ? { oldBusinessId: dto.oldBusinessId === null ? null : BigInt(dto.oldBusinessId), } : {}), }, }); if (dto.categoryIds) { await tx.businessCategoryAssignment.deleteMany({ where: { businessId } }); await tx.businessCategoryAssignment.createMany({ data: dto.categoryIds.map((id) => ({ businessId, categoryId: BigInt(id), })), }); } if (dto.ownerUserId !== undefined && dto.ownerUserId !== null) { await tx.businessUser.deleteMany({ where: { businessId, isOwner: true }, }); await this.assignOwner(tx, businessId, BigInt(dto.ownerUserId), actor.id); } }); return this.getOne(businessIdRaw, actor); } async addDomain(businessIdRaw: string, dto: AddDomainDto, actor: AuthUser) { await this.assertSuperAdmin(actor); const businessId = BigInt(businessIdRaw); const host = dto.host.trim(); if (!host) { throw new BadRequestException('host is required'); } const business = await this.prisma.business.findUnique({ where: { id: businessId } }); if (!business) { throw new NotFoundException('Business not found'); } const hasPrimary = await this.prisma.domain.findFirst({ where: { businessId, isPrimary: true }, select: { id: true }, }); const isPrimary = dto.isPrimary ?? !hasPrimary; return this.prisma.domain.create({ data: { businessId, host, isPrimary, isVerified: false, sslEnabled: false, }, }); } async updateDomain( businessIdRaw: string, domainIdRaw: string, dto: UpdateDomainDto, actor: AuthUser, ) { await this.assertSuperAdmin(actor); const businessId = BigInt(businessIdRaw); const domainId = BigInt(domainIdRaw); const host = dto.host.trim(); if (!host) { throw new BadRequestException('host is required'); } const domain = await this.prisma.domain.findFirst({ where: { id: domainId, businessId }, }); if (!domain) { throw new NotFoundException('Domain not found'); } const existing = await this.prisma.domain.findUnique({ where: { host } }); if (existing && existing.id !== domainId) { throw new ConflictException('Domain host is already taken'); } return this.prisma.domain.update({ where: { id: domainId }, data: { host }, }); } async disable(businessIdRaw: string, dto: DisableBusinessDto, 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'); } return this.prisma.business.update({ where: { id: businessId }, data: { isActive: dto.isActive }, }); } async remove(businessIdRaw: string, 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'); } await this.prisma.business.delete({ where: { id: businessId } }); 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) { throw new ConflictException('Business slug is already taken'); } } 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({ where: { id: { in: ids }, isActive: true }, }); if (count !== ids.length) { throw new BadRequestException('One or more categoryIds are invalid'); } } private async createOwnerUser( tx: Prisma.TransactionClient, dto: Pick< CreateBusinessDto, 'ownerFirstName' | 'ownerLastName' | 'ownerCellNumber' | 'ownerPassword' >, ) { const existing = await tx.user.findUnique({ where: { cellNumber: dto.ownerCellNumber }, }); // Users can own multiple businesses. If the cell number already exists, // leave that user unchanged and only attach the new business as owner. if (existing) { if (!existing.isActive) { throw new BadRequestException('Owner user is inactive'); } return existing; } const passwordHash = await bcrypt.hash(dto.ownerPassword, 10); return tx.user.create({ data: { cellNumber: dto.ownerCellNumber, passwordHash, firstName: dto.ownerFirstName.trim(), lastName: dto.ownerLastName.trim(), cellVerifiedAt: new Date(), }, }); } private async findOwnerUser(ownerUserId: number) { const owner = await this.prisma.user.findUnique({ where: { id: BigInt(ownerUserId) }, }); if (!owner || !owner.isActive) { throw new NotFoundException('Owner user not found'); } return owner; } private async assignOwner( tx: Prisma.TransactionClient, businessId: bigint, ownerUserId: bigint, invitedBy: bigint, ) { const businessOwnerRole = await tx.role.findUnique({ where: { slug: 'business_owner' }, }); if (!businessOwnerRole) { throw new Error('business_owner role is missing'); } await tx.businessUser.upsert({ where: { businessId_userId: { businessId, userId: ownerUserId }, }, create: { businessId, userId: ownerUserId, isOwner: true, invitedBy, }, update: { isOwner: true, roleId: null, invitedBy, }, }); const hasRole = await tx.userRole.findUnique({ where: { userId_roleId: { userId: ownerUserId, roleId: businessOwnerRole.id, }, }, }); if (!hasRole) { await tx.userRole.create({ data: { userId: ownerUserId, roleId: businessOwnerRole.id }, }); } } private serializeBusiness( business: { id: bigint; name: string; nameFa: string | null; about: string | null; slug: string; isActive: boolean; oldBusinessId: bigint | null; createdAt: Date; updatedAt: Date; categoryAssignments: { category: { id: bigint; name: string; slug: string; parentId: bigint | null; }; }[]; businessUsers: { user: { id: bigint; cellNumber: string; firstName: string | null; lastName: string | null; email: string | null; }; }[]; domains: { id: bigint; host: string; isPrimary: boolean; isVerified: boolean; sslEnabled: boolean; }[]; }, ) { const owner = business.businessUsers[0]?.user ?? null; return { id: business.id, name: business.name, nameFa: business.nameFa, 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) => ({ id: a.category.id, name: a.category.name, slug: a.category.slug, parentId: a.category.parentId, })), categoryIds: business.categoryAssignments.map((a) => a.category.id), owner: owner ? { id: owner.id, cellNumber: owner.cellNumber, firstName: owner.firstName, lastName: owner.lastName, email: owner.email, } : null, ownerUserId: owner?.id ?? null, domains: business.domains, }; } private formatBusinessLabel(business: { name: string; nameFa: string | null; slug: string; }): string { if (business.nameFa && business.nameFa !== business.name) { return `${business.name} / ${business.nameFa}`; } return business.name; } }