Add production-ready SuperAdmin API features for tenants, SMS, servers, and plugins.
Includes mobile auth, get_user/get_version, SMS wallet parity endpoints, appearance backfill, and related Prisma migrations. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { AdminsService } from './admins.service';
|
||||
import { CreateAdminDto, TestSmsDto, UpdateAdminDto } from './dto/admin.dto';
|
||||
|
||||
@Controller('admins')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AdminsController {
|
||||
constructor(private readonly adminsService: AdminsService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.adminsService.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateAdminDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
return this.adminsService.create(dto, user.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAdminDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
return this.adminsService.update(id, dto, user.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
return this.adminsService.remove(id, user.id);
|
||||
}
|
||||
|
||||
@Post(':id/test-sms')
|
||||
testSms(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: TestSmsDto,
|
||||
) {
|
||||
return this.adminsService.sendTestSms(id, dto.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AdminsController } from './admins.controller';
|
||||
import { AdminsService } from './admins.service';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [AdminsController],
|
||||
providers: [AdminsService],
|
||||
exports: [AdminsService],
|
||||
})
|
||||
export class AdminsModule {}
|
||||
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AdminNotifyService } from '../notifications/admin-notify.service';
|
||||
import { CentralSmsService } from '../notifications/central-sms.service';
|
||||
import { displayName, normalizeMobile } from '../common/mobile.util';
|
||||
import { CreateAdminDto, UpdateAdminDto } from './dto/admin.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly notify: AdminNotifyService,
|
||||
private readonly sms: CentralSmsService,
|
||||
) {}
|
||||
|
||||
list() {
|
||||
return this.prisma.user.findMany({
|
||||
orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
mobile: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}).then((rows) =>
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
name: displayName(row.firstName, row.lastName),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async create(dto: CreateAdminDto, actorId?: string) {
|
||||
const mobile = normalizeMobile(dto.mobile);
|
||||
if (!mobile) {
|
||||
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||
}
|
||||
|
||||
try {
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
mobile,
|
||||
firstName: dto.firstName.trim(),
|
||||
lastName: dto.lastName.trim(),
|
||||
passwordHash,
|
||||
role: 'super_admin',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
action: 'admin.create',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
userId: actorId,
|
||||
after: this.publicUser(user) as unknown as Prisma.InputJsonValue,
|
||||
});
|
||||
|
||||
this.notify.notifyAdmins(
|
||||
`سوپرادمین: مدیر جدید «${displayName(user.firstName, user.lastName)}» با موبایل ${user.mobile} اضافه شد.`,
|
||||
);
|
||||
|
||||
return this.publicUser(user);
|
||||
} catch (error) {
|
||||
this.handleUnique(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateAdminDto, actorId?: string) {
|
||||
const before = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!before) throw new NotFoundException('مدیر یافت نشد');
|
||||
|
||||
const mobile =
|
||||
dto.mobile !== undefined ? normalizeMobile(dto.mobile) : undefined;
|
||||
if (dto.mobile !== undefined && !mobile) {
|
||||
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||
}
|
||||
|
||||
if (dto.isActive === false) {
|
||||
await this.ensureNotLastActive(id);
|
||||
}
|
||||
|
||||
try {
|
||||
const data: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
mobile?: string;
|
||||
isActive?: boolean;
|
||||
passwordHash?: string;
|
||||
} = {};
|
||||
|
||||
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
||||
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
|
||||
if (mobile) data.mobile = mobile;
|
||||
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
||||
if (dto.password) data.passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
action: 'admin.update',
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
userId: actorId,
|
||||
before: this.publicUser(before) as unknown as Prisma.InputJsonValue,
|
||||
after: this.publicUser(user) as unknown as Prisma.InputJsonValue,
|
||||
});
|
||||
|
||||
this.notify.notifyAdmins(
|
||||
`سوپرادمین: پروفایل مدیر «${displayName(user.firstName, user.lastName)}» بهروزرسانی شد.`,
|
||||
);
|
||||
|
||||
return this.publicUser(user);
|
||||
} catch (error) {
|
||||
this.handleUnique(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string, actorId?: string) {
|
||||
const before = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!before) throw new NotFoundException('مدیر یافت نشد');
|
||||
if (actorId && actorId === id) {
|
||||
throw new BadRequestException('نمیتوانید خودتان را حذف کنید');
|
||||
}
|
||||
await this.ensureNotLastActive(id);
|
||||
|
||||
await this.prisma.user.delete({ where: { id } });
|
||||
|
||||
await this.audit.log({
|
||||
action: 'admin.delete',
|
||||
entityType: 'user',
|
||||
entityId: id,
|
||||
userId: actorId,
|
||||
before: this.publicUser(before) as unknown as Prisma.InputJsonValue,
|
||||
});
|
||||
|
||||
this.notify.notifyAdmins(
|
||||
`سوپرادمین: مدیر «${displayName(before.firstName, before.lastName)}» حذف شد.`,
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async sendTestSms(adminId: string, message?: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: adminId } });
|
||||
if (!user) throw new NotFoundException('مدیر یافت نشد');
|
||||
|
||||
const text =
|
||||
message?.trim() ||
|
||||
`تست پیامک سوپرادمین آبشده برای ${displayName(user.firstName, user.lastName)}`;
|
||||
|
||||
const result = await this.sms.send(user.mobile, text);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException(result.error || 'ارسال پیامک ناموفق بود');
|
||||
}
|
||||
return { ok: true, mobile: user.mobile };
|
||||
}
|
||||
|
||||
private async ensureNotLastActive(id: string) {
|
||||
const activeCount = await this.prisma.user.count({
|
||||
where: { isActive: true },
|
||||
});
|
||||
const target = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (target?.isActive && activeCount <= 1) {
|
||||
throw new BadRequestException('حداقل یک مدیر فعال باید باقی بماند');
|
||||
}
|
||||
}
|
||||
|
||||
private publicUser(user: {
|
||||
id: string;
|
||||
mobile: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: user.id,
|
||||
mobile: user.mobile,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
name: displayName(user.firstName, user.lastName),
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private handleUnique(error: unknown) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateAdminDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(64)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(64)
|
||||
lastName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(15)
|
||||
mobile!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
@MaxLength(72)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class UpdateAdminDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(64)
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(64)
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(15)
|
||||
mobile?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
@MaxLength(72)
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class TestSmsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
message?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user