Files
CRM_Montzar/apps/api/src/platform/platform.service.ts
T

921 lines
28 KiB
TypeScript
Raw Normal View History

2026-09-19 21:54:21 +03:30
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
2026-09-19 22:42:51 +03:30
PlatformAdminDto,
2026-09-19 21:54:21 +03:30
PlatformOrganizationDto,
PlatformStatsDto,
PlatformUserDto,
ProductPlanCode,
2026-09-19 21:54:21 +03:30
UserRole,
} from '@mento/shared';
import { isBusinessField } from '@mento/shared';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
import { normalizeMobile } from '../auth/auth.service';
import { MembershipsService } from '../memberships/memberships.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service';
import { BillingService, toSummary } from '../billing/billing.service';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
2026-09-19 21:54:21 +03:30
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
2026-09-19 21:54:21 +03:30
import type {
ApplyBillingPlanDto,
2026-09-19 22:42:51 +03:30
CreatePlatformAdminDto,
2026-09-19 21:54:21 +03:30
CreatePlatformOrganizationDto,
2026-09-19 22:42:51 +03:30
ResetPlatformAdminPasswordDto,
2026-09-19 21:54:21 +03:30
ResetPlatformUserPasswordDto,
UpdateOrgModulesDto,
2026-09-19 21:54:21 +03:30
UpdatePlatformOrganizationDto,
} from './dto';
function slugify(input: string): string {
return (
input
.trim()
.toLowerCase()
.replace(/[^\w\u0600-\u06FF]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || `org-${Date.now()}`
);
}
@Injectable()
export class PlatformService {
constructor(
private readonly prisma: PrismaService,
private readonly memberships: MembershipsService,
private readonly orgRoles: OrgRolesService,
private readonly pipelines: PipelinesService,
private readonly billing: BillingService,
private readonly storage: StorageQuotaService,
private readonly adminAlerts: PlatformAdminAlertService,
2026-09-19 21:54:21 +03:30
) {}
async stats(): Promise<PlatformStatsDto> {
const [
organizationCount,
activeOrganizationCount,
userCount,
activeUserCount,
membershipCount,
] = await this.prisma.$transaction([
this.prisma.organization.count(),
this.prisma.organization.count({ where: { isActive: true } }),
this.prisma.user.count(),
this.prisma.user.count({ where: { isActive: true } }),
this.prisma.membership.count(),
]);
const [expiringSoonCount, expiredBillingCount] = await Promise.all([
this.billing.countExpiringSoon(),
this.billing.countExpired(),
]);
2026-09-19 21:54:21 +03:30
return {
organizationCount,
activeOrganizationCount,
userCount,
activeUserCount,
membershipCount,
expiringSoonCount,
expiredBillingCount,
2026-09-19 21:54:21 +03:30
};
}
async listOrganizations(query?: string): Promise<PlatformOrganizationDto[]> {
const rows = await this.prisma.organization.findMany({
where: buildSearchWhere<Prisma.OrganizationWhereInput>(query, (t) => [
{ name: ci(t) },
{ slug: ci(t) },
]),
2026-09-19 21:54:21 +03:30
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
slug: true,
businessField: true,
isActive: true,
createdAt: true,
enabledModules: true,
2026-09-19 21:54:21 +03:30
_count: {
select: {
memberships: true,
people: true,
deals: true,
},
},
memberships: {
where: { role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' },
take: 1,
select: {
userId: true,
user: { select: { fullName: true, billingAccount: true } },
},
},
conversationSettings: { select: { organizationId: true } },
2026-09-19 21:54:21 +03:30
},
});
return rows.map((row) => {
const owner = row.memberships[0];
const account = owner?.user.billingAccount;
return {
...this.orgToDto(row),
subscription: account ? toSummary(account) : null,
ownerUserId: owner?.userId ?? null,
ownerName: owner?.user.fullName ?? null,
chatConfigured: Boolean(row.conversationSettings),
};
});
2026-09-19 21:54:21 +03:30
}
async getOrganization(id: string): Promise<PlatformOrganizationDto> {
const row = await this.prisma.organization.findUnique({
where: { id },
select: {
id: true,
name: true,
slug: true,
businessField: true,
isActive: true,
createdAt: true,
enabledModules: true,
2026-09-19 21:54:21 +03:30
_count: {
select: {
memberships: true,
people: true,
deals: true,
},
},
memberships: {
include: {
user: {
select: {
id: true,
fullName: true,
email: true,
mobile: true,
isActive: true,
2026-09-21 16:38:24 +03:30
billingAccount: true,
2026-09-19 21:54:21 +03:30
},
},
},
orderBy: { createdAt: 'asc' },
},
},
});
if (!row) throw new NotFoundException('محیط پیدا نشد');
const ownerAccount = row.memberships.find(
(m) => m.role === 'owner' && m.isActive,
)?.user.billingAccount;
2026-09-19 21:54:21 +03:30
return {
...this.orgToDto(row),
subscription: ownerAccount ? toSummary(ownerAccount) : null,
2026-09-21 16:38:24 +03:30
members: row.memberships.map((m) => {
const isBillingOwner = m.role === 'owner';
return {
membershipId: m.id,
userId: m.user.id,
fullName: m.user.fullName,
email: m.user.email,
mobile: m.user.mobile,
role: m.role,
isActive: m.isActive,
userIsActive: m.user.isActive,
isBillingOwner,
billing:
isBillingOwner && m.user.billingAccount
? this.billing.toDto(m.user.billingAccount)
: null,
};
}),
2026-09-19 21:54:21 +03:30
};
}
async createOrganization(
dto: CreatePlatformOrganizationDto,
): Promise<PlatformOrganizationDto> {
const name = dto.name.trim();
if (name.length < 2) {
throw new BadRequestException('نام محیط معتبر نیست');
}
const ownerFullName = dto.ownerFullName.trim();
if (ownerFullName.length < 2) {
throw new BadRequestException('نام مالک معتبر نیست');
}
const ownerEmail = dto.ownerEmail.trim().toLowerCase();
const ownerPassword = dto.ownerPassword.trim();
if (ownerPassword.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const businessField =
dto.businessField && isBusinessField(dto.businessField)
? dto.businessField
: 'personal';
let ownerMobile: string | null = null;
if (dto.ownerMobile?.trim()) {
ownerMobile = normalizeMobile(dto.ownerMobile);
if (!ownerMobile) {
throw new BadRequestException('شماره موبایل مالک معتبر نیست');
}
}
const existingEmail = await this.prisma.user.findFirst({
where: { email: ownerEmail },
select: { id: true },
});
if (existingEmail) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
if (ownerMobile) {
const existingMobile = await this.prisma.user.findFirst({
where: { mobile: ownerMobile },
select: { id: true },
});
if (existingMobile) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
}
const baseSlug = slugify(name);
let slug = baseSlug;
let i = 1;
while (await this.prisma.organization.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${i++}`;
}
const passwordHash = await bcrypt.hash(ownerPassword, 10);
const now = new Date();
try {
const organization = await this.prisma.organization.create({
data: {
name,
slug,
businessField,
enabledModules: this.billing.defaultModulesForNewOrg(),
2026-09-19 21:54:21 +03:30
users: {
create: {
email: ownerEmail,
mobile: ownerMobile,
fullName: ownerFullName,
passwordHash,
role: 'owner',
emailVerifiedAt: now,
mobileVerifiedAt: ownerMobile ? now : null,
},
},
},
include: { users: true },
});
const owner = organization.users[0];
if (!owner) {
throw new BadRequestException('ساخت حساب مالک ناموفق بود');
}
await this.memberships.createMembership({
userId: owner.id,
organizationId: organization.id,
role: 'owner',
});
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
2026-09-19 21:54:21 +03:30
await this.orgRoles.ensureDefaultRoles(organization.id);
await this.pipelines.ensureDefaultPipeline(organization.id);
await this.adminAlerts.notifyNewAccount({
source: 'admin',
organizationName: organization.name,
ownerName: owner.fullName,
mobile: owner.mobile,
});
2026-09-19 21:54:21 +03:30
return this.getOrganization(organization.id);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('ایمیل یا موبایل تکراری است');
}
throw e;
}
}
async updateOrganization(
id: string,
dto: UpdatePlatformOrganizationDto,
): Promise<PlatformOrganizationDto> {
const existing = await this.prisma.organization.findUnique({
where: { id },
select: { id: true, slug: true },
});
if (!existing) throw new NotFoundException('محیط پیدا نشد');
const data: Prisma.OrganizationUpdateInput = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (name.length < 2) {
throw new BadRequestException('نام محیط معتبر نیست');
}
data.name = name;
}
if (dto.slug !== undefined) {
const slug = slugify(dto.slug);
if (slug.length < 2) {
throw new BadRequestException('اسلاگ معتبر نیست');
}
if (slug !== existing.slug) {
const clash = await this.prisma.organization.findUnique({
where: { slug },
select: { id: true },
});
if (clash) {
throw new ConflictException('این اسلاگ قبلاً استفاده شده است');
}
data.slug = slug;
}
}
if (dto.businessField !== undefined) {
if (dto.businessField === null || dto.businessField === '') {
data.businessField = null;
} else if (!isBusinessField(dto.businessField)) {
throw new BadRequestException('زمینه فعالیت معتبر نیست');
} else {
data.businessField = dto.businessField;
}
}
if (dto.isActive !== undefined) {
data.isActive = dto.isActive;
}
if (Object.keys(data).length === 0) {
throw new BadRequestException('هیچ فیلدی برای به‌روزرسانی ارسال نشده');
}
await this.prisma.organization.update({ where: { id }, data });
return this.getOrganization(id);
}
async setOrganizationActive(
id: string,
isActive: boolean,
): Promise<PlatformOrganizationDto> {
return this.updateOrganization(id, { isActive });
}
2026-09-21 16:38:24 +03:30
async listUsers(query?: string, ownersOnly = false): Promise<PlatformUserDto[]> {
const search = buildSearchWhere<Prisma.UserWhereInput>(query, (t) => [
{ fullName: ci(t) },
{ email: ci(t) },
{ mobile: { contains: t } },
]);
2026-09-19 21:54:21 +03:30
const rows = await this.prisma.user.findMany({
2026-09-21 16:38:24 +03:30
where: {
...(search ? { AND: [search] } : {}),
2026-09-21 16:38:24 +03:30
...(ownersOnly
? { memberships: { some: { role: 'owner', isActive: true } } }
: {}),
},
2026-09-19 21:54:21 +03:30
orderBy: { createdAt: 'desc' },
take: 300,
include: {
billingAccount: true,
2026-09-19 21:54:21 +03:30
memberships: {
include: {
organization: {
select: { id: true, name: true, isActive: true },
},
},
orderBy: { createdAt: 'asc' },
},
},
});
return rows.map((row) => this.userToDto(row));
}
async getUser(id: string): Promise<PlatformUserDto> {
const row = await this.prisma.user.findUnique({
where: { id },
include: {
memberships: {
include: {
organization: {
select: { id: true, name: true, isActive: true },
},
},
orderBy: { createdAt: 'asc' },
},
billingAccount: true,
2026-09-19 21:54:21 +03:30
},
});
if (!row) throw new NotFoundException('کاربر پیدا نشد');
return this.userToDto(row);
}
async storageUsage(userId: string, refresh = false) {
const owner = await this.prisma.membership.findFirst({
where: { userId, role: 'owner', isActive: true },
select: { id: true },
});
if (!owner) {
throw new BadRequestException('حجم فایل فقط برای مالک محیط معنی دارد');
}
const account = await this.billing.getByOwnerUserId(userId);
if (!account) {
throw new BadRequestException('اول برای این مالک یک پلن اعمال کنید');
}
return this.storage.usageForOwner(userId, refresh);
}
async seatUsage(userId: string) {
await this.requireBillingOwner(userId);
return this.billing.seatUsage(userId);
}
async setUserCap(userId: string, maxUsers: number) {
await this.requireBillingOwner(userId);
return this.billing.setMaxUsers(userId, maxUsers);
}
private async requireBillingOwner(userId: string) {
const owner = await this.prisma.membership.findFirst({
where: { userId, role: 'owner', isActive: true },
select: { id: true },
});
if (!owner) {
throw new BadRequestException('سقف کاربر فقط برای مالک محیط معنی دارد');
}
const account = await this.billing.getByOwnerUserId(userId);
if (!account) {
throw new BadRequestException('اول برای این مالک یک پلن اعمال کنید');
}
}
async setStorageQuota(userId: string, quotaGb: number | null) {
await this.storageUsage(userId, false);
return this.storage.setQuotaGb(userId, quotaGb);
}
async applyBillingPlan(userId: string, dto: ApplyBillingPlanDto) {
const existing = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!existing) throw new NotFoundException('کاربر پیدا نشد');
await this.billing.applyPlan(userId, dto);
return this.getUser(userId);
}
async applyProductPlan(id: string, planCode: ProductPlanCode) {
const existing = await this.prisma.organization.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('محیط پیدا نشد');
await this.billing.applyProductPlan(id, planCode);
return this.getOrganization(id);
}
async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
const existing = await this.prisma.organization.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('محیط پیدا نشد');
await this.billing.setOrgModules(id, dto.enabledModules ?? []);
return this.getOrganization(id);
}
2026-09-19 21:54:21 +03:30
async setUserActive(id: string, isActive: boolean): Promise<PlatformUserDto> {
const existing = await this.prisma.user.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('کاربر پیدا نشد');
await this.prisma.user.update({
where: { id },
data: { isActive },
});
return this.getUser(id);
}
async resetUserPassword(
id: string,
dto: ResetPlatformUserPasswordDto,
): Promise<{ ok: true }> {
const existing = await this.prisma.user.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('کاربر پیدا نشد');
const password = dto.password.trim();
if (password.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const passwordHash = await bcrypt.hash(password, 10);
await this.prisma.user.update({
where: { id },
data: { passwordHash },
});
return { ok: true };
}
async setMembershipActive(
id: string,
isActive: boolean,
): Promise<PlatformUserDto> {
const row = await this.prisma.membership.findUnique({
where: { id },
select: {
id: true,
userId: true,
organizationId: true,
role: true,
isActive: true,
2026-09-19 21:54:21 +03:30
},
});
if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (isActive && !row.isActive) {
if (row.role === 'owner') {
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
}
await this.billing.assertSeatAvailable(row.organizationId, row.userId);
}
2026-09-19 21:54:21 +03:30
if (!isActive && row.role === 'owner') {
const activeOwners = await this.memberships.countActiveOwners(
row.organizationId,
row.userId,
);
if (activeOwners < 1) {
throw new BadRequestException('حداقل یک مالک فعال برای این محیط لازم است');
}
}
await this.prisma.membership.update({
where: { id },
data: { isActive },
});
return this.getUser(row.userId);
}
async updateMembershipRole(
id: string,
role: UserRole,
): Promise<PlatformUserDto> {
const row = await this.prisma.membership.findUnique({
where: { id },
include: {
user: { select: { id: true, organizationId: true } },
},
});
if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (role === 'owner' && row.role !== 'owner') {
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
}
2026-09-19 21:54:21 +03:30
if (row.role === 'owner' && role !== 'owner') {
const owners = await this.memberships.countActiveOwners(
row.organizationId,
row.userId,
);
if (owners < 1) {
throw new BadRequestException(
'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
2026-09-19 21:54:21 +03:30
);
}
}
await this.orgRoles.ensureDefaultRoles(row.organizationId);
const orgRole = await this.prisma.orgRole.findFirst({
where: { organizationId: row.organizationId, systemKey: role },
});
await this.prisma.membership.update({
where: { id },
data: {
role,
orgRoleId: orgRole?.id ?? row.orgRoleId,
},
});
if (row.user.organizationId === row.organizationId) {
await this.memberships.syncUserLastOrg(
row.userId,
row.organizationId,
role,
orgRole?.id ?? row.orgRoleId,
);
}
return this.getUser(row.userId);
}
2026-09-19 22:42:51 +03:30
async listAdmins(): Promise<PlatformAdminDto[]> {
const rows = await this.prisma.platformAdmin.findMany({
orderBy: { createdAt: 'asc' },
});
return rows.map((row) => this.adminToDto(row));
}
async createAdmin(dto: CreatePlatformAdminDto): Promise<PlatformAdminDto> {
const fullName = dto.fullName.trim();
if (fullName.length < 2) {
throw new BadRequestException('نام معتبر نیست');
}
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const password = dto.password.trim();
if (password.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const email = dto.email?.trim().toLowerCase() || null;
const existingMobile = await this.prisma.platformAdmin.findUnique({
where: { mobile },
select: { id: true },
});
if (existingMobile) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
if (email) {
const existingEmail = await this.prisma.platformAdmin.findUnique({
where: { email },
select: { id: true },
});
if (existingEmail) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
}
const passwordHash = await bcrypt.hash(password, 10);
try {
const created = await this.prisma.platformAdmin.create({
data: {
fullName,
mobile,
email,
passwordHash,
},
});
return this.adminToDto(created);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('موبایل یا ایمیل تکراری است');
}
throw e;
}
}
async setAdminActive(
id: string,
isActive: boolean,
actorId: string,
): Promise<PlatformAdminDto> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
if (!isActive && id === actorId) {
throw new BadRequestException('نمی‌توانید حساب خودتان را غیرفعال کنید');
}
if (!isActive) {
const activeCount = await this.prisma.platformAdmin.count({
where: { isActive: true, id: { not: id } },
});
if (activeCount < 1) {
throw new BadRequestException('حداقل یک سوپرادمین فعال لازم است');
}
}
const updated = await this.prisma.platformAdmin.update({
where: { id },
data: { isActive },
});
return this.adminToDto(updated);
}
async resetAdminPassword(
id: string,
dto: ResetPlatformAdminPasswordDto,
): Promise<{ ok: true }> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
const password = dto.password.trim();
if (password.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const passwordHash = await bcrypt.hash(password, 10);
await this.prisma.platformAdmin.update({
where: { id },
data: { passwordHash },
});
return { ok: true };
}
async updateAdmin(
id: string,
dto: { fullName: string; mobile: string; email?: string | null },
): Promise<PlatformAdminDto> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
const fullName = dto.fullName.trim();
if (fullName.length < 2) {
throw new BadRequestException('نام معتبر نیست');
}
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const email = dto.email?.trim().toLowerCase() || null;
const mobileTaken = await this.prisma.platformAdmin.findFirst({
where: { mobile, id: { not: id } },
select: { id: true },
});
if (mobileTaken) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
if (email) {
const emailTaken = await this.prisma.platformAdmin.findFirst({
where: { email, id: { not: id } },
select: { id: true },
});
if (emailTaken) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
}
try {
const updated = await this.prisma.platformAdmin.update({
where: { id },
data: { fullName, mobile, email },
});
return this.adminToDto(updated);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('موبایل یا ایمیل تکراری است');
}
throw e;
}
}
async deleteAdmin(id: string, actorId: string): Promise<{ ok: true }> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
if (id === actorId) {
throw new BadRequestException('نمی‌توانید حساب خودتان را حذف کنید');
}
const others = await this.prisma.platformAdmin.count({
where: { id: { not: id } },
});
if (others < 1) {
throw new BadRequestException('حداقل یک سوپرادمین باید بماند');
}
await this.prisma.platformAdmin.delete({ where: { id } });
return { ok: true };
}
async masterPasswordStatus(): Promise<{ enabled: boolean; updatedAt: string | null }> {
const row = await this.prisma.platformMasterPassword.findUnique({
where: { id: 'default' },
select: { updatedAt: true },
});
return {
enabled: Boolean(row),
updatedAt: row?.updatedAt.toISOString() ?? null,
};
}
async setMasterPassword(
password: string | null,
): Promise<{ enabled: boolean; updatedAt: string | null }> {
const trimmed = password?.trim() ?? '';
if (!trimmed) {
await this.prisma.platformMasterPassword.deleteMany({ where: { id: 'default' } });
return { enabled: false, updatedAt: null };
}
if (trimmed.length < 8) {
throw new BadRequestException('رمز کلی حداقل ۸ کاراکتر لازم است');
}
const passwordHash = await bcrypt.hash(trimmed, 10);
const row = await this.prisma.platformMasterPassword.upsert({
where: { id: 'default' },
create: { id: 'default', passwordHash },
update: { passwordHash },
});
return { enabled: true, updatedAt: row.updatedAt.toISOString() };
}
2026-09-19 22:42:51 +03:30
private adminToDto(row: {
id: string;
mobile: string | null;
email: string | null;
fullName: string;
isActive: boolean;
lastLoginAt: Date | null;
createdAt: Date;
}): PlatformAdminDto {
return {
id: row.id,
mobile: row.mobile,
email: row.email,
fullName: row.fullName,
isActive: row.isActive,
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
2026-09-19 21:54:21 +03:30
private orgToDto(row: {
id: string;
name: string;
slug: string;
businessField: string | null;
isActive: boolean;
createdAt: Date;
enabledModules?: string[];
2026-09-19 21:54:21 +03:30
_count: { memberships: number; people: number; deals: number };
}): PlatformOrganizationDto {
return {
id: row.id,
name: row.name,
slug: row.slug,
businessField: row.businessField,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
memberCount: row._count.memberships,
personCount: row._count.people,
dealCount: row._count.deals,
enabledModules: this.billing.normalizeModules(row.enabledModules),
2026-09-19 21:54:21 +03:30
};
}
private userToDto(row: {
id: string;
email: string;
mobile: string | null;
fullName: string;
isActive: boolean;
lastLoginAt: Date | null;
createdAt: Date;
billingAccount?: {
id: string;
ownerUserId: string;
planCode: string;
status: string;
startsAt: Date;
expiresAt: Date;
maxOrganizations: number;
externalPaymentRef: string | null;
notes: string | null;
} | null;
2026-09-19 21:54:21 +03:30
memberships: Array<{
id: string;
role: PlatformUserDto['memberships'][number]['role'];
isActive: boolean;
organization: { id: string; name: string; isActive: boolean };
}>;
}): PlatformUserDto {
const isBillingOwner = row.memberships.some(
(m) => m.role === 'owner' && m.isActive,
);
2026-09-19 21:54:21 +03:30
return {
id: row.id,
email: row.email,
mobile: row.mobile,
fullName: row.fullName,
isActive: row.isActive,
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
isBillingOwner,
billing:
isBillingOwner && row.billingAccount
? this.billing.toDto(row.billingAccount)
: null,
2026-09-19 21:54:21 +03:30
memberships: row.memberships.map((m) => ({
membershipId: m.id,
organizationId: m.organization.id,
organizationName: m.organization.name,
organizationIsActive: m.organization.isActive,
role: m.role,
isActive: m.isActive,
})),
};
}
}