feat: complete admin panel and backend admin api migration

This commit is contained in:
s.esmaeili
2026-09-13 21:02:25 +03:30
parent 03119cd655
commit 267fc7b7d1
239 changed files with 25431 additions and 935 deletions
@@ -0,0 +1,100 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminUsersService } from './admin-users.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
* Legacy endpoints (UserController + RoleController):
* GET/POST/PUT/DELETE users
* GET const/__list__ut -> access-level picker
* POST const -> create an access level
* GET roles/{ut} -> rids of one level
* POST roles/create -> set rids
* DELETE roles/{ut}/{rid} -> revoke one rid
*/
@Controller('admin/users')
export class AdminUsersController {
constructor(private readonly users: AdminUsersService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('user.list')
list(@Body() query: TableQueryDto) {
return this.users.list(query);
}
@Get('role-options')
@RequirePermission('user.list')
roleOptions() {
return this.users.roleOptions();
}
/** Legacy GET const/__list__ut. */
@Get('types')
@RequirePermission('user.list')
listUserTypes() {
return this.users.listUserTypes();
}
/** Legacy POST const {key:"__list__ut", val:"<title>"}. */
@Post('types')
@HttpCode(200)
@RequirePermission('user.permission.create')
createUserType(@Body() body: { val?: string; title?: string }) {
return this.users.createUserType(body.val ?? body.title ?? '');
}
/** Legacy GET roles/{ut}. */
@Get('types/:ut/roles')
@RequirePermission('user.permission')
getRoles(@Param('ut') ut: string) {
return this.users.getRoles(Number(ut));
}
/** Legacy POST roles/create {ut, roles}. */
@Post('roles')
@HttpCode(200)
@RequirePermission('user.permission.edit')
setRoles(@Body() body: { ut: number | string; roles: string[] }) {
return this.users.setRoles(Number(body.ut), body.roles ?? []);
}
/** Legacy DELETE roles/{ut}/{rid}. */
@Delete('types/:ut/roles/:rid')
@RequirePermission('user.permission.edit')
revokeRole(@Param('ut') ut: string, @Param('rid') rid: string) {
return this.users.revokeRole(Number(ut), rid);
}
/** The "اطلاعات من" screen. */
@Get('me')
myData(@CurrentAdmin() admin: AuthenticatedAdmin) {
return this.users.myData(admin.id);
}
@Post()
@HttpCode(200)
@RequirePermission('user.create')
create(@Body() data: Record<string, unknown>) {
return this.users.create(data);
}
@Get(':id')
@RequirePermission('user.list')
findOne(@Param('id') id: string) {
return this.users.findOne(id);
}
@Put(':id')
@RequirePermission('user.list.edit')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.users.update(id, data);
}
@Delete(':id')
@RequirePermission('user.list.remove')
remove(@Param('id') id: string, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.users.remove(id, admin.id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
@Module({
imports: [PrismaModule],
controllers: [AdminUsersController],
providers: [AdminUsersService],
})
export class AdminUsersModule {}
@@ -0,0 +1,323 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { AdminRole, Prisma } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
/**
* Admin users and the permission matrix — legacy views/user/*, served by
* UserController and RoleController:
* POST/GET/PUT/DELETE users -> the admin user list and form
* GET const/__list__ut -> the user-type ("سطح دسترسی") picker
* POST const -> create a new user type
* GET roles/{ut} -> the rids granted to a user type
* POST roles/create -> grant rids
* DELETE roles/{ut}/{rid} -> revoke one rid
*
* The permission matrix writes the `permission(rid, userTypeId)` rows that
* Phase 1 modelled, so this screen is what actually administers the ACL.
*/
@Injectable()
export class AdminUsersService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'fullName',
'mobile',
'role',
'isActive',
'createdAt',
'userTypeId',
'userType.title',
'branch.name',
// legacy filter keys
'name',
'ut',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
name: 'fullName',
ut: 'userTypeId',
caj: 'createdAt',
active: 'isActive',
};
/** Legacy `GET users` — the admin user grid. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const mapField = (f?: string) => (f ? (AdminUsersService.FIELD_ALIASES[f] ?? f) : f);
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
if (mapped.field === 'isActive' && mapped.value !== undefined) {
mapped.value = String(mapped.value) === '1';
}
if (mapped.field === 'userTypeId' && mapped.value !== undefined) {
mapped.value = Number(mapped.value);
}
return mapped;
}),
order: query.order
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
: undefined,
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['fullName', 'mobile'],
allowed: AdminUsersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.adminUser.findMany({
where: where as Prisma.AdminUserWhereInput,
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
skip,
take,
include: { userType: true, branch: true },
}),
this.prisma.adminUser.count({ where: where as Prisma.AdminUserWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** Legacy field names preserved; the password hash is never exposed. */
private toRow(u: Prisma.AdminUserGetPayload<{ include: { userType: true; branch: true } }>) {
return {
id: u.id,
name: u.fullName,
fname: u.fullName,
lname: '',
mobile: u.mobile,
role: u.role,
ut: u.userTypeId,
user_type: u.userType ? { id: u.userType.id, title: u.userType.title } : null,
branch_id: u.branchId,
branch: u.branch ? { id: u.branch.id, name: u.branch.name } : null,
percent: u.wagePercent,
active: u.isActive ? 1 : -1,
is_active: u.isActive,
caj: u.createdAt,
};
}
async findOne(id: string) {
const u = await this.prisma.adminUser.findUnique({
where: { id },
include: { userType: true, branch: true },
});
if (!u) throw new NotFoundException('کاربر یافت نشد');
return this.toRow(u);
}
/** Legacy `POST users`. */
async create(data: Record<string, unknown>) {
const fullName = String(data.name ?? data.fname ?? '').trim();
const mobile = String(data.mobile ?? '').trim();
const password = String(data.password ?? '');
if (!fullName) throw new BadRequestException('نام الزامی است');
if (!mobile) throw new BadRequestException('موبایل الزامی است');
if (!password) throw new BadRequestException('رمز عبور الزامی است');
const existing = await this.prisma.adminUser.findUnique({ where: { mobile } });
if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
return this.prisma.adminUser.create({
data: {
fullName,
mobile,
passwordHash: await bcrypt.hash(password, 10),
role: (data.role as AdminRole) ?? AdminRole.SALES_EXPERT,
userTypeId: data.ut !== undefined && data.ut !== null ? Number(data.ut) : null,
branchId: (data.branch_id as string) || null,
wagePercent:
data.percent !== undefined && data.percent !== null
? new Prisma.Decimal(String(data.percent))
: null,
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
},
include: { userType: true, branch: true },
});
}
/** Legacy `PUT users/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.adminUser.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('کاربر یافت نشد');
const patch: Prisma.AdminUserUpdateInput = {};
if (data.name !== undefined) patch.fullName = String(data.name);
if (data.mobile !== undefined) {
const mobile = String(data.mobile);
if (mobile !== existing.mobile) {
const clash = await this.prisma.adminUser.findUnique({ where: { mobile } });
if (clash) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
}
patch.mobile = mobile;
}
// A blank password field means "leave it unchanged" — legacy did the same.
if (data.password) patch.passwordHash = await bcrypt.hash(String(data.password), 10);
if (data.role !== undefined) patch.role = data.role as AdminRole;
if (data.ut !== undefined) {
patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true };
}
if (data.branch_id !== undefined) {
patch.branch = data.branch_id
? { connect: { id: String(data.branch_id) } }
: { disconnect: true };
}
if (data.percent !== undefined) {
patch.wagePercent =
data.percent === null ? null : new Prisma.Decimal(String(data.percent));
}
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
return this.prisma.adminUser.update({
where: { id },
data: patch,
include: { userType: true, branch: true },
});
}
/**
* Legacy `DELETE users/{id}`.
*
* Refuses to delete an admin who has posted ledger entries or changed order
* statuses — those rows reference them, and losing the actor would erase the
* audit trail. Deactivation is offered instead. Legacy deleted regardless.
*/
async remove(id: string, actorId: string) {
const u = await this.prisma.adminUser.findUnique({
where: { id },
include: {
_count: { select: { postedLedgerEntries: true, orderStatusEvents: true } },
},
});
if (!u) throw new NotFoundException('کاربر یافت نشد');
if (id === actorId) {
throw new BadRequestException('نمی‌توانید حساب کاربری خودتان را حذف کنید');
}
const refs = u._count.postedLedgerEntries + u._count.orderStatusEvents;
if (refs > 0) {
throw new BadRequestException(
`حذف این کاربر ممکن نیست، چون ${refs} سند یا تغییر وضعیت به نام او ثبت شده است. می‌توانید غیرفعالش کنید.`,
);
}
await this.prisma.adminUser.delete({ where: { id } });
return { success: true };
}
// ── User types + the permission matrix ─────────────────────────────
/** Legacy `GET const/__list__ut` — the access-level picker. */
async listUserTypes() {
const rows = await this.prisma.userType.findMany({
orderBy: { id: 'asc' },
include: { _count: { select: { adminUsers: true, permissions: true } } },
});
// legacy returned {id, title} pairs; the counts are additive, not breaking.
return rows.map((t) => ({
id: t.id,
title: t.title,
users_count: t._count.adminUsers,
permissions_count: t._count.permissions,
}));
}
/**
* Legacy `POST const` with `{key: "__list__ut", val: "<title>"}` — creates a
* new access level. Legacy's `config_static` used an auto-increment id; here
* the next free integer is taken explicitly since UserType.id is not generated.
*/
async createUserType(title: string) {
if (!title?.trim()) throw new BadRequestException('نام سطح دسترسی الزامی است');
const last = await this.prisma.userType.findFirst({ orderBy: { id: 'desc' } });
const nextId = (last?.id ?? 84) + 1; // 85 is legacy's first, per Permission.vue
return this.prisma.userType.create({
data: { id: nextId, title: title.trim() },
});
}
/** Legacy `GET roles/{ut}` — the rids granted to one access level. */
async getRoles(userTypeId: number) {
const type = await this.prisma.userType.findUnique({
where: { id: userTypeId },
include: { permissions: true },
});
if (!type) throw new NotFoundException('سطح دسترسی یافت نشد');
// legacy's tree binds to a flat array of rid strings
return type.permissions.map((p) => p.rid);
}
/**
* Legacy `POST roles/create` with `{ut, roles: [...]}`.
*
* The posted array is the complete tick-state of the permission tree, so it
* REPLACES that type's rows — which is what legacy's live code does too: it
* posts the whole `this.rid` array and nothing else.
*
* (Legacy also carries a COMMENTED-OUT block that diffed the old and new sets
* and fired one DELETE per removed rid on a 500ms setTimeout. That path is
* dead in legacy and is not revived here; the transaction below achieves the
* same end atomically.)
*/
async setRoles(userTypeId: number, rids: string[]) {
const type = await this.prisma.userType.findUnique({ where: { id: userTypeId } });
if (!type) throw new NotFoundException('سطح دسترسی یافت نشد');
const clean = [...new Set((rids ?? []).map((r) => String(r)).filter(Boolean))];
await this.prisma.$transaction([
this.prisma.permission.deleteMany({ where: { userTypeId } }),
this.prisma.permission.createMany({
data: clean.map((rid) => ({ rid, userTypeId })),
skipDuplicates: true,
}),
]);
return this.getRoles(userTypeId);
}
/** Legacy `DELETE roles/{ut}/{rid}` — revoke a single rid. */
async revokeRole(userTypeId: number, rid: string) {
const existing = await this.prisma.permission.findUnique({
where: { rid_userTypeId: { rid, userTypeId } },
});
if (!existing) throw new NotFoundException('این دسترسی وجود ندارد');
await this.prisma.permission.delete({
where: { rid_userTypeId: { rid, userTypeId } },
});
return { success: true };
}
/** Legacy `GET users/show` equivalent for the "اطلاعات من" screen. */
async myData(adminId: string) {
return this.findOne(adminId);
}
/** The AdminRole picker for the user form. */
roleOptions() {
return [
{ value: AdminRole.SUPER_ADMIN, title: 'مدیر ارشد' },
{ value: AdminRole.SYSTEM_APPROVER, title: 'تایید مدیر سیستم' },
{ value: AdminRole.SALES_EXPERT, title: 'کارشناس فروش' },
{ value: AdminRole.WORKSHOP_SUPERVISOR, title: 'سرپرست کارگاه' },
{ value: AdminRole.ACCOUNTANT, title: 'حسابدار' },
];
}
}