import { BadRequestException, ConflictException, ForbiddenException, HttpException, 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 { LegacyMigrateLookupDto } from './dto/legacy-migrate-lookup.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'; import { normalizeDashboardLocale, normalizeDashboardThemeMode, normalizeEnabledBusinessModules, normalizeHomeCharts, toPrismaJson, } from '../business-settings/business-settings.util'; import type { BusinessModuleId, DashboardLocale, DashboardThemeMode, HomeChartId, } from '../business-settings/business-settings.types'; import { DEFAULT_BUSINESS_SETTINGS, DEFAULT_ENABLED_BUSINESS_MODULES, DEFAULT_HOME_CHARTS, DEFAULT_NEW_BUSINESS_MODULES, } from '../business-settings/business-settings.types'; import { ArvanDnsService } from '../arvan-dns/arvan-dns.service'; import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service'; import { deploySlugFromHost, isValidGitRepoUrl, normalizeGitRepoUrlForClone, } from '../website-deploy/website-deploy.util'; type BusinessRow = { id: bigint; name: string; nameFa: string | null; about: string | null; slug: string; createdAt: Date; annualRenewalAt: Date | null; isActive: boolean; oldBusinessId: bigint | null; domainId: bigint | null; domain: string | null; sslEnabled: boolean | null; gitRepoUrl: string | null; deploySlug: string | null; ownerUserId: bigint | null; ownerName: string | null; ownerNameEn: string | null; ownerCellNumber: string | null; primaryColor: string | null; defaultLocale: string | null; themeMode: string | null; enabledModules: unknown; homeCharts: unknown; }; 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 readonly websiteDeployAgent: WebsiteDeployAgentService, private readonly arvanDns: ArvanDnsService, ) {} 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 ?? 24; 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.annual_renewal_at AS "annualRenewalAt", b.is_active AS "isActive", b.old_business_id AS "oldBusinessId", dom.id AS "domainId", dom.host AS "domain", dom.ssl_enabled AS "sslEnabled", dom.git_repo_url AS "gitRepoUrl", dom.deploy_slug AS "deploySlug", own."ownerUserId" AS "ownerUserId", own."ownerName" AS "ownerName", own."ownerNameEn" AS "ownerNameEn", own."ownerCellNumber" AS "ownerCellNumber", b.settings->'branding'->>'primaryColor' AS "primaryColor", b.settings->'branding'->>'defaultLocale' AS "defaultLocale", b.settings->'branding'->>'themeMode' AS "themeMode", b.settings->'modules'->'enabled' AS "enabledModules", b.settings->'modules'->'charts' AS "homeCharts" FROM businesses b LEFT JOIN LATERAL ( SELECT d.id, d.host, d.ssl_enabled, d.git_repo_url, d.deploy_slug 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", NULLIF(TRIM(BOTH ' ' FROM COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')), '') AS "ownerName", NULLIF(TRIM(BOTH ' ' FROM COALESCE(u.first_name_en, '') || ' ' || COALESCE(u.last_name_en, '')), '') AS "ownerNameEn", 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) => { const { enabledModules: rawEnabledModules, homeCharts: rawHomeCharts, ...rest } = item; const enabledModules: BusinessModuleId[] = rawEnabledModules == null ? [...DEFAULT_ENABLED_BUSINESS_MODULES] : normalizeEnabledBusinessModules(rawEnabledModules); const homeCharts: [HomeChartId, HomeChartId] = rawHomeCharts == null ? [...DEFAULT_HOME_CHARTS] : normalizeHomeCharts(rawHomeCharts); return { ...rest, primaryColor: normalizeBusinessPrimaryColorId( item.primaryColor, ) as BusinessPrimaryColorId, defaultLocale: normalizeDashboardLocale( item.defaultLocale, ) as DashboardLocale, themeMode: normalizeDashboardThemeMode( item.themeMode, ) as DashboardThemeMode, enabledModules, moduleCount: enabledModules.length, homeCharts, }; }), 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, annualRenewalAt: dto.annualRenewalAt ? new Date(dto.annualRenewalAt) : new Date(), oldBusinessId: dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined, settings: toPrismaJson({ ...DEFAULT_BUSINESS_SETTINGS, modules: { enabled: [...DEFAULT_NEW_BUSINESS_MODULES], charts: [...DEFAULT_HOME_CHARTS], }, }), }, }); 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), } : {}), ...(dto.annualRenewalAt !== undefined ? { annualRenewalAt: dto.annualRenewalAt === null ? null : new Date(dto.annualRenewalAt), } : {}), }, }); 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().toLowerCase(); const gitRepoUrl = dto.gitRepoUrl?.trim() || null; if (!host) { throw new BadRequestException('host is required'); } if (gitRepoUrl && !isValidGitRepoUrl(gitRepoUrl)) { throw new BadRequestException( 'gitRepoUrl must be a git URL (e.g. https://git.meshkee.com/Meshkee-Websites/oaktasty.git)', ); } const business = await this.prisma.business.findUnique({ where: { id: businessId } }); if (!business) { throw new NotFoundException('Business not found'); } const existingHost = await this.prisma.domain.findUnique({ where: { host } }); if (existingHost) { throw new ConflictException('Domain host is already taken'); } const hasPrimary = await this.prisma.domain.findFirst({ where: { businessId, isPrimary: true }, select: { id: true }, }); const isPrimary = dto.isPrimary ?? !hasPrimary; let deploySlug: string | null = null; let provisionError: string | null = null; if (gitRepoUrl) { deploySlug = deploySlugFromHost(host); if (!deploySlug) { throw new BadRequestException('Could not derive deploy slug from host'); } const slugTaken = await this.prisma.domain.findFirst({ where: { deploySlug }, select: { id: true }, }); if (slugTaken) { throw new ConflictException(`Deploy slug "${deploySlug}" is already in use`); } try { await this.websiteDeployAgent.provision({ slug: deploySlug, host, gitRepoUrl: normalizeGitRepoUrlForClone(gitRepoUrl), }); } catch (err) { if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') { provisionError = err.message; } else { provisionError = 'Storefront provision failed on websites server'; } deploySlug = null; } } const domain = await this.prisma.domain.create({ data: { businessId, host, isPrimary, isVerified: false, sslEnabled: false, deploySlug: provisionError ? null : deploySlug, gitRepoUrl: provisionError ? null : gitRepoUrl, }, }); const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null; return { ...domain, provisionError, dnsError, }; } 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().toLowerCase(); const gitRepoUrl = dto.gitRepoUrl?.trim() || null; if (!host) { throw new BadRequestException('host is required'); } if (gitRepoUrl && !isValidGitRepoUrl(gitRepoUrl)) { throw new BadRequestException( 'gitRepoUrl must be a git URL (e.g. https://git.meshkee.com/Meshkee-Websites/oaktasty.git)', ); } 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'); } let deploySlug = domain.deploySlug; let provisionError: string | null = null; let nextGitRepoUrl = domain.gitRepoUrl; const hostChanged = domain.host !== host; const effectiveGitRepoUrl = gitRepoUrl || domain.gitRepoUrl?.trim() || null; // Only provision when wiring a new/changed repo, deploy slug is missing, or // the apex host changed (nginx vhost is keyed by host — rename must bind the new name). // Re-saving the same git URL + same host must not block the API on clone again. const alreadyWired = !!effectiveGitRepoUrl && !!domain.deploySlug?.trim() && (!gitRepoUrl || domain.gitRepoUrl?.trim() === gitRepoUrl) && !hostChanged; if (effectiveGitRepoUrl && !alreadyWired) { const slug = domain.deploySlug?.trim() || deploySlugFromHost(host); if (!slug) { throw new BadRequestException('Could not derive deploy slug from host'); } const slugTaken = await this.prisma.domain.findFirst({ where: { deploySlug: slug, NOT: { id: domainId } }, select: { id: true }, }); if (slugTaken) { throw new ConflictException(`Deploy slug "${slug}" is already in use`); } try { await this.websiteDeployAgent.provision({ slug, host, gitRepoUrl: normalizeGitRepoUrlForClone(effectiveGitRepoUrl), }); deploySlug = slug; nextGitRepoUrl = effectiveGitRepoUrl; } catch (err) { if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') { provisionError = err.message; } else { provisionError = 'Storefront provision failed on websites server'; } } } else if (alreadyWired) { deploySlug = domain.deploySlug; nextGitRepoUrl = effectiveGitRepoUrl; } const updated = await this.prisma.domain.update({ where: { id: domainId }, data: { host, ...(gitRepoUrl && !provisionError ? { deploySlug, gitRepoUrl: nextGitRepoUrl } : {}), }, }); const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null; return { ...updated, provisionError, dnsError, }; } private async applyArvanDns(host: string): Promise { try { await this.arvanDns.ensureTenantRecords(host); return null; } catch (err) { if (err instanceof HttpException) { const res = err.getResponse(); if (typeof res === 'string') return res; if (res && typeof res === 'object' && 'message' in res) { const message = (res as { message: unknown }).message; return Array.isArray(message) ? message.map(String).join(', ') : String(message); } } if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') { return err.message; } return 'Arvan DNS update failed'; } } 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 lookupLegacyMigrate( businessIdRaw: string, query: LegacyMigrateLookupDto, actor: AuthUser, ) { await this.assertSuperAdmin(actor); const businessId = BigInt(businessIdRaw); const business = await this.prisma.business.findUnique({ where: { id: businessId }, select: { id: true, name: true, domains: { select: { host: true, isPrimary: true }, orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }], }, }, }); if (!business) { throw new NotFoundException('Business not found'); } const meshkeeHosts = business.domains.map((d) => d.host); const lookup = await this.legacyMigrate.lookupLegacyBusiness({ meshkeeHosts, oldBusinessId: query.oldBusinessId, }); return { meshkeeHosts, meshkeeBusinessName: business.name, oldBusinessId: lookup.oldBusinessId != null ? String(lookup.oldBusinessId) : null, domains: lookup.domains, brandEn: lookup.brandEn, brandFa: lookup.brandFa, slug: lookup.slug, matchedHost: lookup.matchedHost, source: lookup.source, }; } 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, | 'ownerTitleId' | 'ownerFirstName' | 'ownerLastName' | 'ownerFirstNameEn' | 'ownerLastNameEn' | 'ownerCellNumber' | 'ownerPassword' >, ) { const existing = await tx.user.findUnique({ where: { cellNumber: dto.ownerCellNumber }, }); let titleId: bigint | null | undefined = undefined; if (dto.ownerTitleId !== undefined) { const title = await tx.userTitle.findFirst({ where: { id: BigInt(dto.ownerTitleId), isActive: true }, select: { id: true }, }); if (!title) { throw new BadRequestException('Owner title not found'); } titleId = title.id; } const passwordHash = await bcrypt.hash(dto.ownerPassword, 10); const nameData = { ...(titleId !== undefined ? { titleId } : {}), firstName: dto.ownerFirstName.trim(), lastName: dto.ownerLastName.trim(), firstNameEn: dto.ownerFirstNameEn.trim(), lastNameEn: dto.ownerLastNameEn.trim(), passwordHash, }; // Users can own multiple businesses. If the cell number already exists, // refresh name + password from the create form, then attach as owner. // Admin-created owners are trusted — always mark the cell verified. if (existing) { if (!existing.isActive) { throw new BadRequestException('Owner user is inactive'); } return tx.user.update({ where: { id: existing.id }, data: { ...nameData, cellVerifiedAt: existing.cellVerifiedAt ?? new Date(), }, }); } return tx.user.create({ data: { cellNumber: dto.ownerCellNumber, cellVerifiedAt: new Date(), ...nameData, }, }); } 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 }, }); } await tx.user.update({ where: { id: ownerUserId }, data: { cellVerifiedAt: new Date() }, }); } private serializeBusiness( business: { id: bigint; name: string; nameFa: string | null; about: string | null; slug: string; isActive: boolean; oldBusinessId: bigint | null; annualRenewalAt: Date | 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; firstNameEn: string | null; lastNameEn: 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, annualRenewalAt: business.annualRenewalAt, 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, firstNameEn: owner.firstNameEn, lastNameEn: owner.lastNameEn, 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; } }