The person who registered stays the only owner. Full day-to-day access is granted through the manager role instead of a second owner. Co-authored-by: Cursor <cursoragent@cursor.com>
921 lines
28 KiB
TypeScript
921 lines
28 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import type {
|
|
PlatformAdminDto,
|
|
PlatformOrganizationDto,
|
|
PlatformStatsDto,
|
|
PlatformUserDto,
|
|
ProductPlanCode,
|
|
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';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { buildSearchWhere, ci } from '../common/search';
|
|
import type {
|
|
ApplyBillingPlanDto,
|
|
CreatePlatformAdminDto,
|
|
CreatePlatformOrganizationDto,
|
|
ResetPlatformAdminPasswordDto,
|
|
ResetPlatformUserPasswordDto,
|
|
UpdateOrgModulesDto,
|
|
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,
|
|
) {}
|
|
|
|
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(),
|
|
]);
|
|
return {
|
|
organizationCount,
|
|
activeOrganizationCount,
|
|
userCount,
|
|
activeUserCount,
|
|
membershipCount,
|
|
expiringSoonCount,
|
|
expiredBillingCount,
|
|
};
|
|
}
|
|
|
|
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) },
|
|
]),
|
|
orderBy: { createdAt: 'desc' },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
businessField: true,
|
|
isActive: true,
|
|
createdAt: true,
|
|
enabledModules: true,
|
|
_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 } },
|
|
},
|
|
});
|
|
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),
|
|
};
|
|
});
|
|
}
|
|
|
|
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,
|
|
_count: {
|
|
select: {
|
|
memberships: true,
|
|
people: true,
|
|
deals: true,
|
|
},
|
|
},
|
|
memberships: {
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
fullName: true,
|
|
email: true,
|
|
mobile: true,
|
|
isActive: true,
|
|
billingAccount: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
},
|
|
},
|
|
});
|
|
if (!row) throw new NotFoundException('محیط پیدا نشد');
|
|
const ownerAccount = row.memberships.find(
|
|
(m) => m.role === 'owner' && m.isActive,
|
|
)?.user.billingAccount;
|
|
return {
|
|
...this.orgToDto(row),
|
|
subscription: ownerAccount ? toSummary(ownerAccount) : null,
|
|
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,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
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(),
|
|
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 });
|
|
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,
|
|
});
|
|
|
|
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 });
|
|
}
|
|
|
|
async listUsers(query?: string, ownersOnly = false): Promise<PlatformUserDto[]> {
|
|
const search = buildSearchWhere<Prisma.UserWhereInput>(query, (t) => [
|
|
{ fullName: ci(t) },
|
|
{ email: ci(t) },
|
|
{ mobile: { contains: t } },
|
|
]);
|
|
const rows = await this.prisma.user.findMany({
|
|
where: {
|
|
...(search ? { AND: [search] } : {}),
|
|
...(ownersOnly
|
|
? { memberships: { some: { role: 'owner', isActive: true } } }
|
|
: {}),
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 300,
|
|
include: {
|
|
billingAccount: true,
|
|
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,
|
|
},
|
|
});
|
|
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);
|
|
}
|
|
|
|
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,
|
|
},
|
|
});
|
|
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);
|
|
}
|
|
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);
|
|
}
|
|
if (row.role === 'owner' && role !== 'owner') {
|
|
const owners = await this.memberships.countActiveOwners(
|
|
row.organizationId,
|
|
row.userId,
|
|
);
|
|
if (owners < 1) {
|
|
throw new BadRequestException(
|
|
'مالک محیط همان کسی است که ثبتنام کرده و نمیتوان نقش او را عوض کرد',
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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() };
|
|
}
|
|
|
|
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(),
|
|
};
|
|
}
|
|
|
|
private orgToDto(row: {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
businessField: string | null;
|
|
isActive: boolean;
|
|
createdAt: Date;
|
|
enabledModules?: string[];
|
|
_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),
|
|
};
|
|
}
|
|
|
|
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;
|
|
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,
|
|
);
|
|
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,
|
|
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,
|
|
})),
|
|
};
|
|
}
|
|
}
|