feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -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: 'حسابدار' },
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user