Files
CRM_Montzar/apps/api/src/users/users.service.ts
T
MahdiandCursor acb5d51f24 feat(billing): cap active users on an owner subscription
Super admin can set a seat limit of 3, 10, 20, or a negotiated number so an owner cannot add users past that cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 18:33:51 +03:30

631 lines
22 KiB
TypeScript

import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
DataScope,
DataVisibility,
InviteUserRequest,
OrgUserDto,
UpdateOrgUserRequest,
UpdateUserVisibilityRequest,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, DATA_VISIBILITIES, parseDataAccessPolicy } from '@mento/shared';
import { resolveAllSectionAccess } from '../common/data-access';
import { parseVisibilityScopes } from '../common/record-scope';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
import { normalizeMobile } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { MembershipsService } from '../memberships/memberships.service';
import { BillingService } from '../billing/billing.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import * as path from 'path';
function inviteEmailFromMobile(mobile: string): string {
return `${mobile}@users.mento.local`;
}
const AVATAR_EXT: Record<string, string> = {
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
};
type MembershipRow = {
role: UserRole;
orgRoleId: string | null;
isActive: boolean;
commissionPercent: { toString(): string } | number | null;
dataVisibility?: string | null;
dataVisibilityScopes?: unknown;
dataAccessOverride?: unknown;
priorArchiveEnabled?: boolean;
logoutTime?: string | null;
blockLoginAfterLogout?: boolean;
loginOtpOnly?: boolean;
createdAt: Date;
orgRole: { id: string; name: string; color: string; dataAccessPolicy?: unknown } | null;
user: {
id: string;
email: string;
mobile: string | null;
fullName: string;
lastLoginAt: Date | null;
avatarStorageKey: string | null;
organizationId: string;
};
};
@Injectable()
export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly orgRoles: OrgRolesService,
private readonly memberships: MembershipsService,
private readonly files: LocalFileStorage,
private readonly billing: BillingService,
) {}
async list(organizationId: string): Promise<OrgUserDto[]> {
await this.orgRoles.ensureDefaultRoles(organizationId);
const rows = await this.memberships.listInOrg(organizationId);
return rows.map((row) => this.toDto(row));
}
async invite(
organizationId: string,
actorRole: UserRole,
dto: InviteUserRequest,
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند کاربر اضافه کند');
}
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const resolved = await this.orgRoles.resolveInviteRole(organizationId, {
orgRoleId: dto.orgRoleId,
role: dto.role,
});
if (resolved.role === 'owner' && actorRole !== 'owner') {
throw new ForbiddenException('فقط مالک می‌تواند نقش مدیر سیستم را واگذار کند');
}
const existingUser = await this.prisma.user.findFirst({ where: { mobile } });
if (existingUser) {
return this.addExistingUser(organizationId, existingUser.id, resolved);
}
await this.billing.assertSeatAvailable(organizationId, null);
const password = dto.password?.trim() ?? '';
if (password.length < 8) {
throw new BadRequestException(
'برای کاربر جدید رمز عبور حداقل ۸ کاراکتر لازم است',
);
}
const email = inviteEmailFromMobile(mobile);
const existingEmail = await this.prisma.user.findFirst({ where: { email } });
if (existingEmail) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
const passwordHash = await bcrypt.hash(password, 10);
try {
const created = await this.prisma.user.create({
data: {
organizationId,
email,
mobile,
fullName: dto.fullName.trim(),
passwordHash,
role: resolved.role,
orgRoleId: resolved.orgRoleId,
isActive: true,
},
});
await this.memberships.createMembership({
userId: created.id,
organizationId,
role: resolved.role,
orgRoleId: resolved.orgRoleId,
});
return this.dtoOf(organizationId, created.id);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
throw e;
}
}
async updateProfile(
organizationId: string,
actorRole: UserRole,
targetId: string,
dto: UpdateOrgUserRequest,
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند مشخصات کاربر را ویرایش کند');
}
const target = await this.memberships.findUserInOrgById(
organizationId,
targetId,
);
if (target.role === 'owner' && actorRole !== 'owner') {
throw new ForbiddenException('مدیر نمی‌تواند مشخصات مالک را تغییر دهد');
}
const fullName = dto.fullName.trim();
if (fullName.length < 2) {
throw new BadRequestException('نام باید حداقل دو حرف باشد');
}
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const previousMobile = target.user.mobile ?? '';
if (mobile !== previousMobile) {
const taken = await this.prisma.user.findFirst({
where: { mobile, id: { not: targetId } },
select: { id: true },
});
if (taken) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
}
const data: Prisma.UserUpdateInput = { fullName, mobile };
const prevEmail = target.user.email;
const wasSynthetic =
prevEmail.endsWith('@users.mento.local') ||
prevEmail === inviteEmailFromMobile(previousMobile);
if (wasSynthetic && mobile !== previousMobile) {
const nextEmail = inviteEmailFromMobile(mobile);
const emailTaken = await this.prisma.user.findFirst({
where: { email: nextEmail, id: { not: targetId } },
select: { id: true },
});
if (!emailTaken) data.email = nextEmail;
}
const password = dto.password?.trim() ?? '';
if (password) {
if (password.length < 8) {
throw new BadRequestException('رمز عبور باید حداقل ۸ کاراکتر باشد');
}
data.passwordHash = await bcrypt.hash(password, 10);
}
try {
await this.prisma.user.update({
where: { id: targetId },
data,
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('این شماره موبایل یا ایمیل قبلاً ثبت شده است');
}
throw e;
}
return this.dtoOf(organizationId, targetId);
}
async updateCommission(
organizationId: string,
actorRole: UserRole,
targetId: string,
percent: number,
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند پورسانت را تعیین کند');
}
if (percent < 0 || percent > 100) {
throw new BadRequestException('درصد پورسانت باید بین ۰ تا ۱۰۰ باشد');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
await this.prisma.membership.update({
where: { id: target.id },
data: { commissionPercent: new Prisma.Decimal(percent) },
});
if (target.user.organizationId === organizationId) {
await this.prisma.user.update({
where: { id: target.user.id },
data: { commissionPercent: new Prisma.Decimal(percent) },
});
}
return this.dtoOf(organizationId, targetId);
}
async updateLogoutTime(
organizationId: string,
actorRole: UserRole,
targetId: string,
logoutTime: string | null,
blockLoginAfterLogout?: boolean,
): Promise<OrgUserDto> {
if (actorRole !== 'owner') {
throw new ForbiddenException('فقط مالک می‌تواند ساعت خروج را تعیین کند');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (!target) throw new NotFoundException('کاربر پیدا نشد');
if (target.role === 'owner') {
throw new BadRequestException('برای مالک ساعت خروج تعیین نمی‌شود');
}
const value = logoutTime?.trim() || null;
if (value && !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) {
throw new BadRequestException('ساعت خروج معتبر نیست');
}
await this.prisma.membership.update({
where: { id: target.id },
data: {
logoutTime: value,
// بدون ساعت خروج، مسدودسازی معنی ندارد
...(!value
? { blockLoginAfterLogout: false }
: blockLoginAfterLogout !== undefined
? { blockLoginAfterLogout }
: {}),
},
});
return this.dtoOf(organizationId, targetId);
}
async updatePriorArchive(
organizationId: string,
actorRole: UserRole,
targetId: string,
enabled: boolean,
): Promise<OrgUserDto> {
if (actorRole !== 'owner') {
throw new ForbiddenException('فقط مالک می‌تواند بایگانی اطلاعات قبل را برای کاربران روشن کند');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (enabled) {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { enabledModules: true },
});
if (!org?.enabledModules?.includes('prior_data_archive')) {
throw new ForbiddenException('بایگانی اطلاعات قبل برای این محیط فعال نیست');
}
}
await this.prisma.membership.update({
where: { id: target.id },
data: { priorArchiveEnabled: enabled },
});
return this.dtoOf(organizationId, targetId);
}
async updateVisibility(
organizationId: string,
actorRole: UserRole,
targetId: string,
dto: UpdateUserVisibilityRequest,
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('اجازه تغییر سطح دسترسی به پرونده‌ها را ندارید');
}
const { dataVisibility, scopes } = dto;
if (dataVisibility === undefined && !scopes) {
throw new BadRequestException('تغییری ارسال نشده است');
}
if (dataVisibility !== undefined && !DATA_VISIBILITIES.includes(dataVisibility)) {
throw new BadRequestException('سطح دسترسی به پرونده‌ها نامعتبر است');
}
if (scopes) {
for (const [key, value] of Object.entries(scopes)) {
if (!DATA_SCOPES.includes(key as DataScope)) {
throw new BadRequestException(`بخش نامعتبر: ${key}`);
}
if (value !== null && !DATA_VISIBILITIES.includes(value as DataVisibility)) {
throw new BadRequestException('سطح دسترسی به پرونده‌ها نامعتبر است');
}
}
}
const target = await this.memberships.findUserInOrgById(
organizationId,
targetId,
);
if (target.role === 'owner' || target.role === 'admin') {
throw new BadRequestException(
'مالک و مدیر همیشه همه پرونده‌ها را می‌بینند',
);
}
const data: Prisma.MembershipUpdateInput = {};
if (dataVisibility !== undefined) data.dataVisibility = dataVisibility;
if (scopes) {
const current = await this.prisma.membership.findUnique({
where: { id: target.id },
select: { dataVisibilityScopes: true },
});
const merged = parseVisibilityScopes(current?.dataVisibilityScopes);
for (const [key, value] of Object.entries(scopes)) {
if (value === null) delete merged[key as DataScope];
else merged[key as DataScope] = value;
}
data.dataVisibilityScopes = merged;
}
await this.prisma.membership.update({
where: { id: target.id },
data,
});
return this.dtoOf(organizationId, targetId);
}
async updateDataAccess(
organizationId: string,
actorRole: UserRole,
targetId: string,
dto: { dataAccessOverride?: Record<string, unknown> | null },
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید');
}
if (dto.dataAccessOverride === undefined) {
throw new BadRequestException('تغییری ارسال نشده است');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (target.role === 'owner' || target.role === 'admin') {
throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند');
}
const next =
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride);
await this.prisma.membership.update({
where: { id: target.id },
data: { dataAccessOverride: next },
});
return this.dtoOf(organizationId, targetId);
}
async updateRole(
organizationId: string,
actor: { userId: string; role: UserRole },
targetId: string,
dto: { role?: UserRole; orgRoleId?: string },
): Promise<OrgUserDto> {
if (actor.role !== 'owner' && actor.role !== 'admin') {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند نقش را تغییر دهد');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
let nextRole = dto.role ?? target.role;
let nextOrgRoleId = dto.orgRoleId ?? target.orgRoleId;
if (dto.orgRoleId) {
const orgRole = await this.prisma.orgRole.findFirst({
where: { id: dto.orgRoleId, organizationId },
});
if (!orgRole) throw new BadRequestException('نقش انتخاب‌شده معتبر نیست');
nextOrgRoleId = orgRole.id;
nextRole = orgRole.systemKey
? (['owner', 'admin', 'sales', 'support'].includes(orgRole.systemKey)
? (orgRole.systemKey as UserRole)
: 'sales')
: 'sales';
} else if (dto.role) {
await this.orgRoles.ensureDefaultRoles(organizationId);
const orgRole = await this.prisma.orgRole.findFirst({
where: { organizationId, systemKey: dto.role },
});
nextOrgRoleId = orgRole?.id ?? nextOrgRoleId;
}
if (actor.role === 'admin') {
if (target.role === 'owner' || nextRole === 'owner') {
throw new ForbiddenException('مدیر نمی‌تواند نقش مالک را تغییر دهد');
}
}
if (target.role === 'owner' && nextRole !== 'owner') {
const owners = await this.memberships.countActiveOwners(
organizationId,
target.userId,
);
if (owners < 1) {
throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
);
}
}
await this.prisma.membership.update({
where: { id: target.id },
data: { role: nextRole, orgRoleId: nextOrgRoleId },
});
if (target.user.organizationId === organizationId) {
await this.memberships.syncUserLastOrg(
target.userId,
organizationId,
nextRole,
nextOrgRoleId,
);
}
return this.dtoOf(organizationId, targetId);
}
async updateLoginOtpOnly(
organizationId: string,
actor: { userId: string; role: UserRole },
targetId: string,
loginOtpOnly: boolean,
): Promise<OrgUserDto> {
if (actor.role !== 'owner' && actor.role !== 'admin') {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند شیوهٔ ورود را تغییر دهد');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (target.role === 'owner') {
throw new BadRequestException('برای مالک نمی‌توان ورود فقط با کد را فعال کرد');
}
if (loginOtpOnly) {
const mobile = target.user.mobile?.trim();
if (!mobile) {
throw new BadRequestException('برای ورود با کد، کاربر باید موبایل داشته باشد');
}
}
await this.prisma.membership.update({
where: { id: target.id },
data: { loginOtpOnly },
});
return this.dtoOf(organizationId, targetId);
}
async updateStatus(
organizationId: string,
actor: { userId: string; role: UserRole },
targetId: string,
isActive: boolean,
): Promise<OrgUserDto> {
if (actor.role !== 'owner' && actor.role !== 'admin') {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند وضعیت کاربر را تغییر دهد');
}
if (actor.userId === targetId && !isActive) {
throw new BadRequestException('نمی‌توانید حساب خودتان را غیرفعال کنید');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (target.role === 'owner' && !isActive && actor.role !== 'owner') {
throw new ForbiddenException('مدیر نمی‌تواند مالک را غیرفعال کند');
}
if (target.role === 'owner' && !isActive) {
const activeOwners = await this.memberships.countActiveOwners(
organizationId,
targetId,
);
if (activeOwners < 1) {
throw new BadRequestException('حداقل یک مالک فعال لازم است');
}
}
if (isActive && !target.isActive) {
await this.billing.assertSeatAvailable(organizationId, targetId);
}
await this.prisma.membership.update({
where: { id: target.id },
data: { isActive },
});
return this.dtoOf(organizationId, targetId);
}
async openAvatar(organizationId: string, userId: string) {
const membership = await this.memberships.findInOrg(organizationId, userId);
if (!membership) throw new NotFoundException('کاربر پیدا نشد');
const key = membership.user.avatarStorageKey;
if (!key) {
throw new NotFoundException('عکس پروفایل تنظیم نشده');
}
const ext = path.extname(key).slice(1).toLowerCase();
const mime = AVATAR_EXT[ext] ?? 'image/jpeg';
return {
stream: this.files.openReadStream(key),
mimeType: mime,
};
}
private async addExistingUser(
organizationId: string,
userId: string,
resolved: { role: UserRole; orgRoleId: string },
): Promise<OrgUserDto> {
await this.billing.assertSeatAvailable(organizationId, userId);
const existing = await this.prisma.membership.findFirst({
where: { userId, organizationId },
});
if (existing?.isActive) {
throw new ConflictException('این کاربر هم‌اکنون عضو این محیط است');
}
if (existing) {
await this.prisma.membership.update({
where: { id: existing.id },
data: {
isActive: true,
role: resolved.role,
orgRoleId: resolved.orgRoleId,
},
});
return this.dtoOf(organizationId, userId);
}
try {
await this.memberships.createMembership({
userId,
organizationId,
role: resolved.role,
orgRoleId: resolved.orgRoleId,
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('این کاربر هم‌اکنون عضو این محیط است');
}
throw e;
}
return this.dtoOf(organizationId, userId);
}
private async dtoOf(organizationId: string, userId: string): Promise<OrgUserDto> {
const row = await this.memberships.findUserInOrgById(organizationId, userId);
return this.toDto(row);
}
private toDto(row: MembershipRow & { userId?: string }): OrgUserDto {
return {
id: row.user.id,
email: row.user.email,
mobile: row.user.mobile ?? null,
fullName: row.user.fullName,
role: row.role,
orgRoleId: row.orgRoleId,
orgRoleName: row.orgRole?.name,
orgRoleColor: row.orgRole?.color ?? this.orgRoles.defaultColorFor(row.role),
isActive: row.isActive,
lastLoginAt: row.user.lastLoginAt ? row.user.lastLoginAt.toISOString() : null,
hasAvatar: Boolean(row.user.avatarStorageKey),
commissionPercent: Number(row.commissionPercent ?? 0),
dataVisibility:
row.role === 'owner' || row.role === 'admin'
? 'org'
: DATA_VISIBILITIES.includes(row.dataVisibility as DataVisibility)
? (row.dataVisibility as DataVisibility)
: 'own',
dataVisibilityScopes:
row.role === 'owner' || row.role === 'admin'
? {}
: parseVisibilityScopes(row.dataVisibilityScopes),
dataAccessOverride:
row.role === 'owner' || row.role === 'admin'
? {}
: parseDataAccessPolicy(row.dataAccessOverride),
...(() => {
const effectiveDataAccess = resolveAllSectionAccess({
role: row.role,
rolePolicy: row.orgRole?.dataAccessPolicy,
override: row.dataAccessOverride,
legacyVisibility: row.dataVisibility,
legacyScopes: parseVisibilityScopes(row.dataVisibilityScopes),
});
return {
effectiveDataAccess,
effectiveCustomersAccess: effectiveDataAccess.customers,
};
})(),
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null,
blockLoginAfterLogout:
row.role !== 'owner' && Boolean(row.logoutTime) && Boolean(row.blockLoginAfterLogout),
loginOtpOnly: row.role === 'owner' ? false : Boolean(row.loginOtpOnly),
createdAt: row.createdAt.toISOString(),
};
}
}