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:
2026-07-30 18:19:53 +03:30
co-authored by Cursor
parent ddf36f8470
commit a6b3457779
54 changed files with 3770 additions and 156 deletions
+55
View File
@@ -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);
}
}
+12
View File
@@ -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 {}
+222
View File
@@ -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('این شماره موبایل قبلاً ثبت شده است');
}
}
}
+66
View File
@@ -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;
}