Creating a CRM Super Admin
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
PlatformOrganizationDto,
|
||||
PlatformStatsDto,
|
||||
PlatformUserDto,
|
||||
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 { PrismaService } from '../prisma/prisma.service';
|
||||
import type {
|
||||
CreatePlatformOrganizationDto,
|
||||
ResetPlatformUserPasswordDto,
|
||||
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,
|
||||
) {}
|
||||
|
||||
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(),
|
||||
]);
|
||||
return {
|
||||
organizationCount,
|
||||
activeOrganizationCount,
|
||||
userCount,
|
||||
activeUserCount,
|
||||
membershipCount,
|
||||
};
|
||||
}
|
||||
|
||||
async listOrganizations(query?: string): Promise<PlatformOrganizationDto[]> {
|
||||
const q = query?.trim();
|
||||
const rows = await this.prisma.organization.findMany({
|
||||
where: q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: 'insensitive' } },
|
||||
{ slug: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
businessField: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
memberships: true,
|
||||
people: true,
|
||||
deals: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return rows.map((row) => this.orgToDto(row));
|
||||
}
|
||||
|
||||
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,
|
||||
_count: {
|
||||
select: {
|
||||
memberships: true,
|
||||
people: true,
|
||||
deals: true,
|
||||
},
|
||||
},
|
||||
memberships: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
fullName: true,
|
||||
email: true,
|
||||
mobile: true,
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('محیط پیدا نشد');
|
||||
return {
|
||||
...this.orgToDto(row),
|
||||
members: row.memberships.map((m) => ({
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
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.orgRoles.ensureDefaultRoles(organization.id);
|
||||
await this.pipelines.ensureDefaultPipeline(organization.id);
|
||||
|
||||
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): Promise<PlatformUserDto[]> {
|
||||
const q = query?.trim();
|
||||
const rows = await this.prisma.user.findMany({
|
||||
where: q
|
||||
? {
|
||||
OR: [
|
||||
{ fullName: { contains: q, mode: 'insensitive' } },
|
||||
{ email: { contains: q, mode: 'insensitive' } },
|
||||
{ mobile: { contains: q } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 300,
|
||||
include: {
|
||||
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' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('کاربر پیدا نشد');
|
||||
return this.userToDto(row);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('عضویت پیدا نشد');
|
||||
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 (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);
|
||||
}
|
||||
|
||||
private orgToDto(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
businessField: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
_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,
|
||||
};
|
||||
}
|
||||
|
||||
private userToDto(row: {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile: string | null;
|
||||
fullName: string;
|
||||
isActive: boolean;
|
||||
lastLoginAt: Date | null;
|
||||
createdAt: Date;
|
||||
memberships: Array<{
|
||||
id: string;
|
||||
role: PlatformUserDto['memberships'][number]['role'];
|
||||
isActive: boolean;
|
||||
organization: { id: string; name: string; isActive: boolean };
|
||||
}>;
|
||||
}): PlatformUserDto {
|
||||
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(),
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user