mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
984 lines
30 KiB
TypeScript
984 lines
30 KiB
TypeScript
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<BusinessRow[]>(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;
|
|
}
|
|
}
|