Creating import&export excel

This commit is contained in:
2026-09-15 17:33:05 +03:30
parent 9ae176bec0
commit 44f9849d0e
26 changed files with 3682 additions and 474 deletions
+20 -2
View File
@@ -1,7 +1,9 @@
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsNumber,
IsOptional,
IsString,
Matches,
Max,
@@ -30,13 +32,29 @@ export class InviteUserDto {
@MaxLength(72)
password!: string;
@IsOptional()
@IsIn(INVITE_ROLES)
role!: (typeof INVITE_ROLES)[number];
role?: (typeof INVITE_ROLES)[number];
@IsOptional()
@IsString()
orgRoleId?: string;
}
export class UpdateUserRoleDto {
@IsOptional()
@IsIn(ALL_ROLES)
role!: (typeof ALL_ROLES)[number];
role?: (typeof ALL_ROLES)[number];
@IsOptional()
@IsString()
orgRoleId?: string;
}
export class UpdateUserStatusDto {
@Type(() => Boolean)
@IsBoolean()
isActive!: boolean;
}
export class UpdateUserCommissionDto {
+32 -2
View File
@@ -5,6 +5,7 @@ import {
Param,
Patch,
Post,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import type { UserRole } from '@mento/shared';
@@ -12,7 +13,12 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { InviteUserDto, UpdateUserCommissionDto, UpdateUserRoleDto } from './dto';
import {
InviteUserDto,
UpdateUserCommissionDto,
UpdateUserRoleDto,
UpdateUserStatusDto,
} from './dto';
import { UsersService } from './users.service';
@Controller('users')
@@ -25,6 +31,15 @@ export class UsersController {
return this.usersService.list(user.organizationId);
}
@Get(':id/avatar')
async avatar(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
const file = await this.usersService.openAvatar(user.organizationId, id);
return new StreamableFile(file.stream, { type: file.mimeType });
}
@Post()
@Roles('owner', 'admin')
invite(
@@ -49,6 +64,21 @@ export class UsersController {
);
}
@Patch(':id/status')
@Roles('owner', 'admin')
updateStatus(
@CurrentUser() user: { organizationId: string; userId: string; role: UserRole },
@Param('id') id: string,
@Body() dto: UpdateUserStatusDto,
) {
return this.usersService.updateStatus(
user.organizationId,
{ userId: user.userId, role: user.role },
id,
dto.isActive,
);
}
@Patch(':id/role')
@Roles('owner', 'admin')
updateRole(
@@ -60,7 +90,7 @@ export class UsersController {
user.organizationId,
{ userId: user.userId, role: user.role },
id,
dto.role,
dto,
);
}
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { OrgRolesModule } from '../org-roles/org-roles.module';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [AuthModule],
imports: [AuthModule, OrgRolesModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
+130 -14
View File
@@ -10,19 +10,51 @@ 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 { 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 UserRow = {
id: string;
email: string;
mobile: string | null;
fullName: string;
role: UserRole;
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;
};
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly orgRoles: OrgRolesService,
private readonly files: LocalFileStorage,
) {}
async list(organizationId: string): Promise<OrgUserDto[]> {
await this.orgRoles.ensureDefaultRoles(organizationId);
const rows = await this.prisma.user.findMany({
where: { organizationId },
orderBy: [{ role: 'asc' }, { fullName: 'asc' }],
include: { orgRole: { select: { id: true, name: true, color: true } } },
orderBy: [{ lastLoginAt: 'desc' }, { fullName: 'asc' }],
});
return rows.map((u) => this.toDto(u));
}
@@ -54,6 +86,14 @@ export class UsersService {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
const resolved = await this.orgRoles.resolveInviteRole(organizationId, {
orgRoleId: dto.orgRoleId,
role: dto.role,
});
if (resolved.role === 'owner' && actorRole !== 'owner') {
throw new ForbiddenException('فقط مالک می‌تواند نقش مدیر سیستم را واگذار کند');
}
const passwordHash = await bcrypt.hash(dto.password, 10);
try {
const row = await this.prisma.user.create({
@@ -63,8 +103,11 @@ export class UsersService {
mobile,
fullName: dto.fullName.trim(),
passwordHash,
role: dto.role,
role: resolved.role,
orgRoleId: resolved.orgRoleId,
isActive: true,
},
include: { orgRole: { select: { id: true, name: true, color: true } } },
});
return this.toDto(row);
} catch (e) {
@@ -94,6 +137,7 @@ export class UsersService {
const row = await this.prisma.user.update({
where: { id: target.id },
data: { commissionPercent: new Prisma.Decimal(percent) },
include: { orgRole: { select: { id: true, name: true, color: true } } },
});
return this.toDto(row);
}
@@ -102,7 +146,7 @@ export class UsersService {
organizationId: string,
actor: { userId: string; role: UserRole },
targetId: string,
nextRole: UserRole,
dto: { role?: UserRole; orgRoleId?: string },
): Promise<OrgUserDto> {
if (actor.role !== 'owner' && actor.role !== 'admin') {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند نقش را تغییر دهد');
@@ -113,6 +157,28 @@ export class UsersService {
});
if (!target) throw new NotFoundException('کاربر پیدا نشد');
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('مدیر نمی‌تواند نقش مالک را تغییر دهد');
@@ -132,26 +198,76 @@ export class UsersService {
const row = await this.prisma.user.update({
where: { id: target.id },
data: { role: nextRole },
data: { role: nextRole, orgRoleId: nextOrgRoleId },
include: { orgRole: { select: { id: true, name: true, color: true } } },
});
return this.toDto(row);
}
private toDto(row: {
id: string;
email: string;
mobile?: string | null;
fullName: string;
role: UserRole;
commissionPercent?: { toString(): string } | number;
createdAt: Date;
}): OrgUserDto {
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.prisma.user.findFirst({
where: { id: targetId, organizationId },
});
if (!target) throw new NotFoundException('کاربر پیدا نشد');
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 } },
});
if (activeOwners < 1) {
throw new BadRequestException('حداقل یک مالک فعال لازم است');
}
}
const row = await this.prisma.user.update({
where: { id: target.id },
data: { isActive },
include: { orgRole: { select: { id: true, name: true, color: true } } },
});
return this.toDto(row);
}
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) {
throw new NotFoundException('عکس پروفایل تنظیم نشده');
}
const ext = path.extname(user.avatarStorageKey).slice(1).toLowerCase();
const mime = AVATAR_EXT[ext] ?? 'image/jpeg';
return {
stream: this.files.openReadStream(user.avatarStorageKey),
mimeType: mime,
};
}
private toDto(row: UserRow): OrgUserDto {
return {
id: row.id,
email: row.email,
mobile: row.mobile ?? null,
fullName: row.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),
commissionPercent: Number(row.commissionPercent ?? 0),
createdAt: row.createdAt.toISOString(),
};