531 lines
15 KiB
TypeScript
531 lines
15 KiB
TypeScript
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,
|
||
|
|
})),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|