Multi-environment and multi-user construction
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
const INVITE_ROLES = ['admin', 'sales', 'support'] as const;
|
||||
@@ -27,10 +28,12 @@ export class InviteUserDto {
|
||||
@MaxLength(120)
|
||||
fullName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, value) => typeof value === 'string' && value.length > 0)
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(72)
|
||||
password!: string;
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(INVITE_ROLES)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { MembershipsModule } from '../memberships/memberships.module';
|
||||
import { OrgRolesModule } from '../org-roles/org-roles.module';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, OrgRolesModule],
|
||||
imports: [AuthModule, OrgRolesModule, MembershipsModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -26,19 +27,22 @@ const AVATAR_EXT: Record<string, string> = {
|
||||
gif: 'image/gif',
|
||||
};
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile: string | null;
|
||||
fullName: string;
|
||||
type MembershipRow = {
|
||||
role: UserRole;
|
||||
orgRoleId: string | null;
|
||||
isActive: boolean;
|
||||
commissionPercent: { toString(): string } | number | null;
|
||||
createdAt: Date;
|
||||
isActive: boolean;
|
||||
lastLoginAt: Date | null;
|
||||
orgRoleId: string | null;
|
||||
avatarStorageKey: string | null;
|
||||
orgRole: { id: string; name: string; color: string } | null;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile: string | null;
|
||||
fullName: string;
|
||||
lastLoginAt: Date | null;
|
||||
avatarStorageKey: string | null;
|
||||
organizationId: string;
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -46,17 +50,14 @@ export class UsersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly orgRoles: OrgRolesService,
|
||||
private readonly memberships: MembershipsService,
|
||||
private readonly files: LocalFileStorage,
|
||||
) {}
|
||||
|
||||
async list(organizationId: string): Promise<OrgUserDto[]> {
|
||||
await this.orgRoles.ensureDefaultRoles(organizationId);
|
||||
const rows = await this.prisma.user.findMany({
|
||||
where: { organizationId },
|
||||
include: { orgRole: { select: { id: true, name: true, color: true } } },
|
||||
orderBy: [{ lastLoginAt: 'desc' }, { fullName: 'asc' }],
|
||||
});
|
||||
return rows.map((u) => this.toDto(u));
|
||||
const rows = await this.memberships.listInOrg(organizationId);
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async invite(
|
||||
@@ -73,19 +74,6 @@ export class UsersService {
|
||||
throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
}
|
||||
|
||||
const existingMobile = await this.prisma.user.findFirst({ where: { mobile } });
|
||||
if (existingMobile) {
|
||||
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
|
||||
const email = inviteEmailFromMobile(mobile);
|
||||
const existingEmail = await this.prisma.user.findFirst({
|
||||
where: { organizationId, email },
|
||||
});
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
|
||||
const resolved = await this.orgRoles.resolveInviteRole(organizationId, {
|
||||
orgRoleId: dto.orgRoleId,
|
||||
role: dto.role,
|
||||
@@ -94,9 +82,27 @@ export class UsersService {
|
||||
throw new ForbiddenException('فقط مالک میتواند نقش مدیر سیستم را واگذار کند');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const existingUser = await this.prisma.user.findFirst({ where: { mobile } });
|
||||
if (existingUser) {
|
||||
return this.addExistingUser(organizationId, existingUser.id, resolved);
|
||||
}
|
||||
|
||||
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 row = await this.prisma.user.create({
|
||||
const created = await this.prisma.user.create({
|
||||
data: {
|
||||
organizationId,
|
||||
email,
|
||||
@@ -107,9 +113,14 @@ export class UsersService {
|
||||
orgRoleId: resolved.orgRoleId,
|
||||
isActive: true,
|
||||
},
|
||||
include: { orgRole: { select: { id: true, name: true, color: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
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('این شماره موبایل قبلاً ثبت شده است');
|
||||
@@ -130,16 +141,18 @@ export class UsersService {
|
||||
if (percent < 0 || percent > 100) {
|
||||
throw new BadRequestException('درصد پورسانت باید بین ۰ تا ۱۰۰ باشد');
|
||||
}
|
||||
const target = await this.prisma.user.findFirst({
|
||||
where: { id: targetId, organizationId },
|
||||
});
|
||||
if (!target) throw new NotFoundException('کاربر پیدا نشد');
|
||||
const row = await this.prisma.user.update({
|
||||
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
|
||||
await this.prisma.membership.update({
|
||||
where: { id: target.id },
|
||||
data: { commissionPercent: new Prisma.Decimal(percent) },
|
||||
include: { orgRole: { select: { id: true, name: true, color: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
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 updateRole(
|
||||
@@ -152,10 +165,7 @@ export class UsersService {
|
||||
throw new ForbiddenException('فقط مالک یا مدیر میتواند نقش را تغییر دهد');
|
||||
}
|
||||
|
||||
const target = await this.prisma.user.findFirst({
|
||||
where: { id: targetId, organizationId },
|
||||
});
|
||||
if (!target) throw new NotFoundException('کاربر پیدا نشد');
|
||||
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
|
||||
|
||||
let nextRole = dto.role ?? target.role;
|
||||
let nextOrgRoleId = dto.orgRoleId ?? target.orgRoleId;
|
||||
@@ -186,22 +196,30 @@ export class UsersService {
|
||||
}
|
||||
|
||||
if (target.role === 'owner' && nextRole !== 'owner') {
|
||||
const owners = await this.prisma.user.count({
|
||||
where: { organizationId, role: 'owner' },
|
||||
});
|
||||
if (owners <= 1) {
|
||||
const owners = await this.memberships.countActiveOwners(
|
||||
organizationId,
|
||||
target.userId,
|
||||
);
|
||||
if (owners < 1) {
|
||||
throw new BadRequestException(
|
||||
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const row = await this.prisma.user.update({
|
||||
await this.prisma.membership.update({
|
||||
where: { id: target.id },
|
||||
data: { role: nextRole, orgRoleId: nextOrgRoleId },
|
||||
include: { orgRole: { select: { id: true, name: true, color: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
if (target.user.organizationId === organizationId) {
|
||||
await this.memberships.syncUserLastOrg(
|
||||
target.userId,
|
||||
organizationId,
|
||||
nextRole,
|
||||
nextOrgRoleId,
|
||||
);
|
||||
}
|
||||
return this.dtoOf(organizationId, targetId);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
@@ -216,58 +234,97 @@ export class UsersService {
|
||||
if (actor.userId === targetId && !isActive) {
|
||||
throw new BadRequestException('نمیتوانید حساب خودتان را غیرفعال کنید');
|
||||
}
|
||||
const target = await this.prisma.user.findFirst({
|
||||
where: { id: targetId, organizationId },
|
||||
});
|
||||
if (!target) throw new NotFoundException('کاربر پیدا نشد');
|
||||
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.prisma.user.count({
|
||||
where: { organizationId, role: 'owner', isActive: true, id: { not: targetId } },
|
||||
});
|
||||
const activeOwners = await this.memberships.countActiveOwners(
|
||||
organizationId,
|
||||
targetId,
|
||||
);
|
||||
if (activeOwners < 1) {
|
||||
throw new BadRequestException('حداقل یک مالک فعال لازم است');
|
||||
}
|
||||
}
|
||||
const row = await this.prisma.user.update({
|
||||
await this.prisma.membership.update({
|
||||
where: { id: target.id },
|
||||
data: { isActive },
|
||||
include: { orgRole: { select: { id: true, name: true, color: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
return this.dtoOf(organizationId, targetId);
|
||||
}
|
||||
|
||||
async openAvatar(organizationId: string, userId: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: userId, organizationId },
|
||||
select: { id: true, avatarStorageKey: true },
|
||||
});
|
||||
if (!user?.avatarStorageKey) {
|
||||
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(user.avatarStorageKey).slice(1).toLowerCase();
|
||||
const ext = path.extname(key).slice(1).toLowerCase();
|
||||
const mime = AVATAR_EXT[ext] ?? 'image/jpeg';
|
||||
return {
|
||||
stream: this.files.openReadStream(user.avatarStorageKey),
|
||||
stream: this.files.openReadStream(key),
|
||||
mimeType: mime,
|
||||
};
|
||||
}
|
||||
|
||||
private toDto(row: UserRow): OrgUserDto {
|
||||
private async addExistingUser(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
resolved: { role: UserRole; orgRoleId: string },
|
||||
): Promise<OrgUserDto> {
|
||||
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.id,
|
||||
email: row.email,
|
||||
mobile: row.mobile ?? null,
|
||||
fullName: row.fullName,
|
||||
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.lastLoginAt ? row.lastLoginAt.toISOString() : null,
|
||||
hasAvatar: Boolean(row.avatarStorageKey),
|
||||
lastLoginAt: row.user.lastLoginAt ? row.user.lastLoginAt.toISOString() : null,
|
||||
hasAvatar: Boolean(row.user.avatarStorageKey),
|
||||
commissionPercent: Number(row.commissionPercent ?? 0),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user