From c5a841c35fd4eec7f3310feddd6c756de5c7311a Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Fri, 2 Oct 2026 16:29:23 +0330 Subject: [PATCH] feat(rbac): implement full admin management, dynamic roles, permissions matrix, and workspace access control - Add Workspace, Role, Permission, RolePermission, AdminRoleAssignment, and AdminWorkspaceAssignment models to Prisma schema\n- Create database migration and RBAC seed script\n- Implement PermissionsGuard, @RequirePermission decorator, and real-time permission resolution in JwtAdminStrategy\n- Implement AdminManagementModule with endpoints for managers CRUD, roles matrix, permissions catalog, and workspaces\n- Secure all sensitive admin controllers with granular permission requirements\n- Add useAuthStore helpers (can, canAny, fetchMe) and v-permission directive\n- Implement Desktop views: AdminListView, AdminFormDrawer (RTL right-side), RoleMatrixView, WorkspaceListView, and ForbiddenView\n- Implement Mobile views: MAdminListView, MAdminFormView (stepped mobile form), MRoleMatrixView, MWorkspaceListView, and MForbiddenView\n- Fix sticky header behavior across desktop and mobile shells\n- Update route guards and device detection logic for mobile prefixing --- .../src/components/mobile/MobileBottomNav.vue | 33 +- .../src/components/mobile/MobileTopBar.vue | 4 +- apps/admin/src/composables/useDevice.ts | 6 +- apps/admin/src/composables/usePermission.ts | 30 + .../src/directives/permission.directive.ts | 33 + apps/admin/src/layouts/AdminLayout.vue | 92 ++- apps/admin/src/layouts/MobileLayout.vue | 21 +- apps/admin/src/main.ts | 2 + apps/admin/src/router/index.ts | 79 ++- apps/admin/src/stores/auth.store.ts | 52 +- apps/admin/src/views/errors/ForbiddenView.vue | 43 ++ .../src/views/managers/AdminFormDrawer.vue | 389 +++++++++++ .../src/views/managers/AdminListView.vue | 518 +++++++++++++++ .../admin/src/views/mobile/MAdminFormView.vue | 495 ++++++++++++++ .../admin/src/views/mobile/MAdminListView.vue | 387 +++++++++++ .../admin/src/views/mobile/MForbiddenView.vue | 36 ++ .../src/views/mobile/MRoleMatrixView.vue | 304 +++++++++ .../src/views/mobile/MWorkspaceListView.vue | 205 ++++++ apps/admin/src/views/roles/RoleMatrixView.vue | 558 ++++++++++++++++ .../views/workspaces/WorkspaceListView.vue | 260 ++++++++ .../migration.sql | 118 ++++ apps/api/prisma/schema.prisma | 97 ++- apps/api/prisma/seed-rbac.ts | 272 ++++++++ apps/api/src/app.module.ts | 2 + .../decorators/current-admin.decorator.ts | 7 + .../require-permission.decorator.ts | 6 + .../src/common/guards/permissions.guard.ts | 43 ++ .../admin-audit/admin-audit.controller.ts | 5 +- .../admin-auth/admin-auth.controller.ts | 10 +- .../modules/admin-auth/admin-auth.service.ts | 105 +++ .../modules/admin-auth/jwt-admin.strategy.ts | 47 +- .../admin-management.controller.ts | 103 +++ .../admin-management.module.ts | 18 + .../admin-management.service.ts | 611 ++++++++++++++++++ .../admin-management/dto/create-admin.dto.ts | 79 +++ .../admin-management/dto/create-role.dto.ts | 26 + .../dto/create-workspace.dto.ts | 20 + .../admin-management/dto/update-admin.dto.ts | 61 ++ .../admin-management/dto/update-role.dto.ts | 20 + .../dto/update-workspace.dto.ts | 14 + .../role-management.controller.ts | 74 +++ .../admin-management/workspace.controller.ts | 49 ++ .../campaign/admin-campaign.controller.ts | 10 +- .../modules/chance/admin-chance.controller.ts | 6 +- .../modules/prize/admin-prize.controller.ts | 10 +- .../modules/report/admin-report.controller.ts | 7 +- .../src/modules/sms/admin-sms.controller.ts | 6 +- .../src/modules/user/admin-user.controller.ts | 7 +- .../modules/winner/admin-spin.controller.ts | 6 +- .../modules/winner/admin-winner.controller.ts | 7 +- packages/shared-types/src/index.ts | 57 ++ 51 files changed, 5402 insertions(+), 48 deletions(-) create mode 100644 apps/admin/src/composables/usePermission.ts create mode 100644 apps/admin/src/directives/permission.directive.ts create mode 100644 apps/admin/src/views/errors/ForbiddenView.vue create mode 100644 apps/admin/src/views/managers/AdminFormDrawer.vue create mode 100644 apps/admin/src/views/managers/AdminListView.vue create mode 100644 apps/admin/src/views/mobile/MAdminFormView.vue create mode 100644 apps/admin/src/views/mobile/MAdminListView.vue create mode 100644 apps/admin/src/views/mobile/MForbiddenView.vue create mode 100644 apps/admin/src/views/mobile/MRoleMatrixView.vue create mode 100644 apps/admin/src/views/mobile/MWorkspaceListView.vue create mode 100644 apps/admin/src/views/roles/RoleMatrixView.vue create mode 100644 apps/admin/src/views/workspaces/WorkspaceListView.vue create mode 100644 apps/api/prisma/migrations/20261001005633_add_rbac_and_workspace/migration.sql create mode 100644 apps/api/prisma/seed-rbac.ts create mode 100644 apps/api/src/common/decorators/require-permission.decorator.ts create mode 100644 apps/api/src/common/guards/permissions.guard.ts create mode 100644 apps/api/src/modules/admin-management/admin-management.controller.ts create mode 100644 apps/api/src/modules/admin-management/admin-management.module.ts create mode 100644 apps/api/src/modules/admin-management/admin-management.service.ts create mode 100644 apps/api/src/modules/admin-management/dto/create-admin.dto.ts create mode 100644 apps/api/src/modules/admin-management/dto/create-role.dto.ts create mode 100644 apps/api/src/modules/admin-management/dto/create-workspace.dto.ts create mode 100644 apps/api/src/modules/admin-management/dto/update-admin.dto.ts create mode 100644 apps/api/src/modules/admin-management/dto/update-role.dto.ts create mode 100644 apps/api/src/modules/admin-management/dto/update-workspace.dto.ts create mode 100644 apps/api/src/modules/admin-management/role-management.controller.ts create mode 100644 apps/api/src/modules/admin-management/workspace.controller.ts diff --git a/apps/admin/src/components/mobile/MobileBottomNav.vue b/apps/admin/src/components/mobile/MobileBottomNav.vue index 1d8301d..cb040c8 100644 --- a/apps/admin/src/components/mobile/MobileBottomNav.vue +++ b/apps/admin/src/components/mobile/MobileBottomNav.vue @@ -1,5 +1,6 @@ + + + + diff --git a/apps/admin/src/views/managers/AdminFormDrawer.vue b/apps/admin/src/views/managers/AdminFormDrawer.vue new file mode 100644 index 0000000..47fd56c --- /dev/null +++ b/apps/admin/src/views/managers/AdminFormDrawer.vue @@ -0,0 +1,389 @@ + + + + + diff --git a/apps/admin/src/views/managers/AdminListView.vue b/apps/admin/src/views/managers/AdminListView.vue new file mode 100644 index 0000000..c9dda7f --- /dev/null +++ b/apps/admin/src/views/managers/AdminListView.vue @@ -0,0 +1,518 @@ + + + + + diff --git a/apps/admin/src/views/mobile/MAdminFormView.vue b/apps/admin/src/views/mobile/MAdminFormView.vue new file mode 100644 index 0000000..a6d6227 --- /dev/null +++ b/apps/admin/src/views/mobile/MAdminFormView.vue @@ -0,0 +1,495 @@ + + + + + diff --git a/apps/admin/src/views/mobile/MAdminListView.vue b/apps/admin/src/views/mobile/MAdminListView.vue new file mode 100644 index 0000000..f393db5 --- /dev/null +++ b/apps/admin/src/views/mobile/MAdminListView.vue @@ -0,0 +1,387 @@ + + + + + diff --git a/apps/admin/src/views/mobile/MForbiddenView.vue b/apps/admin/src/views/mobile/MForbiddenView.vue new file mode 100644 index 0000000..8ff3d56 --- /dev/null +++ b/apps/admin/src/views/mobile/MForbiddenView.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/apps/admin/src/views/mobile/MRoleMatrixView.vue b/apps/admin/src/views/mobile/MRoleMatrixView.vue new file mode 100644 index 0000000..5d102d9 --- /dev/null +++ b/apps/admin/src/views/mobile/MRoleMatrixView.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/apps/admin/src/views/mobile/MWorkspaceListView.vue b/apps/admin/src/views/mobile/MWorkspaceListView.vue new file mode 100644 index 0000000..9745941 --- /dev/null +++ b/apps/admin/src/views/mobile/MWorkspaceListView.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/apps/admin/src/views/roles/RoleMatrixView.vue b/apps/admin/src/views/roles/RoleMatrixView.vue new file mode 100644 index 0000000..1a4431e --- /dev/null +++ b/apps/admin/src/views/roles/RoleMatrixView.vue @@ -0,0 +1,558 @@ + + + + + diff --git a/apps/admin/src/views/workspaces/WorkspaceListView.vue b/apps/admin/src/views/workspaces/WorkspaceListView.vue new file mode 100644 index 0000000..5a4dedf --- /dev/null +++ b/apps/admin/src/views/workspaces/WorkspaceListView.vue @@ -0,0 +1,260 @@ + + + + + diff --git a/apps/api/prisma/migrations/20261001005633_add_rbac_and_workspace/migration.sql b/apps/api/prisma/migrations/20261001005633_add_rbac_and_workspace/migration.sql new file mode 100644 index 0000000..2d63a4a --- /dev/null +++ b/apps/api/prisma/migrations/20261001005633_add_rbac_and_workspace/migration.sql @@ -0,0 +1,118 @@ +-- CreateEnum +CREATE TYPE "AccessScopeType" AS ENUM ('ALL', 'CUSTOM_CAMPAIGNS'); + +-- AlterTable +ALTER TABLE "admins" ADD COLUMN "is_super_admin" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "last_login_at" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "campaigns" ADD COLUMN "workspace_id" TEXT; + +-- CreateTable +CREATE TABLE "workspaces" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "roles" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "is_system" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "roles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "permissions" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "module" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + + CONSTRAINT "permissions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "role_permissions" ( + "role_id" TEXT NOT NULL, + "permission_id" TEXT NOT NULL, + + CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id","permission_id") +); + +-- CreateTable +CREATE TABLE "admin_role_assignments" ( + "id" TEXT NOT NULL, + "admin_id" TEXT NOT NULL, + "role_id" TEXT NOT NULL, + "assigned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "admin_role_assignments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "admin_workspace_assignments" ( + "id" TEXT NOT NULL, + "admin_id" TEXT NOT NULL, + "workspace_id" TEXT NOT NULL, + "scope_type" "AccessScopeType" NOT NULL DEFAULT 'ALL', + "specific_campaign_ids" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "admin_workspace_assignments_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "workspaces_slug_key" ON "workspaces"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "roles_code_key" ON "roles"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "permissions_code_key" ON "permissions"("code"); + +-- CreateIndex +CREATE INDEX "permissions_module_idx" ON "permissions"("module"); + +-- CreateIndex +CREATE UNIQUE INDEX "admin_role_assignments_admin_id_role_id_key" ON "admin_role_assignments"("admin_id", "role_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "admin_workspace_assignments_admin_id_workspace_id_key" ON "admin_workspace_assignments"("admin_id", "workspace_id"); + +-- CreateIndex +CREATE INDEX "campaigns_workspace_id_idx" ON "campaigns"("workspace_id"); + +-- AddForeignKey +ALTER TABLE "campaigns" ADD CONSTRAINT "campaigns_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "admin_role_assignments" ADD CONSTRAINT "admin_role_assignments_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "admin_role_assignments" ADD CONSTRAINT "admin_role_assignments_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "admin_workspace_assignments" ADD CONSTRAINT "admin_workspace_assignments_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "admin_workspace_assignments" ADD CONSTRAINT "admin_workspace_assignments_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a23a92e..9901071 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -21,6 +21,11 @@ enum AdminRole { SUPPORT } +enum AccessScopeType { + ALL + CUSTOM_CAMPAIGNS +} + enum UserStatus { ACTIVE BLOCKED @@ -111,11 +116,15 @@ model Admin { passwordHash String @map("password_hash") role AdminRole @default(OPERATOR) status AdminStatus @default(ACTIVE) + isSuperAdmin Boolean @default(false) @map("is_super_admin") + lastLoginAt DateTime? @map("last_login_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - auditLogs AdminLog[] - chanceTxns ChanceTransaction[] @relation("AdminCreatedTxn") + auditLogs AdminLog[] + chanceTxns ChanceTransaction[] @relation("AdminCreatedTxn") + roles AdminRoleAssignment[] + workspaceAssignments AdminWorkspaceAssignment[] @@map("admins") } @@ -171,6 +180,8 @@ model Campaign { ctaText String? @map("cta_text") wheelDesign WheelDesign @default(CLASSIC_CASINO) @map("wheel_design") settings Json? // primaryColor, secondaryColor, headerText, buttonText, headerImage, celebrationEffect... + workspaceId String? @map("workspace_id") + workspace Workspace? @relation(fields: [workspaceId], references: [id]) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -181,6 +192,7 @@ model Campaign { userPrizes UserPrize[] @@index([status, startAt, endAt]) + @@index([workspaceId]) @@map("campaigns") } @@ -317,3 +329,84 @@ model AdminLog { @@index([entityType, entityId]) @@map("admin_logs") } + +// ==================== WORKSPACE & RBAC MODELS ==================== + +model Workspace { + id String @id @default(uuid()) + name String + slug String @unique + description String? + status String @default("ACTIVE") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + campaigns Campaign[] + adminAssignments AdminWorkspaceAssignment[] + + @@map("workspaces") +} + +model Role { + id String @id @default(uuid()) + code String @unique // e.g. "SUPER_ADMIN", "OPERATOR", "SUPPORT" + name String // نام فارسی + description String? + isSystem Boolean @default(false) @map("is_system") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + permissions RolePermission[] + admins AdminRoleAssignment[] + + @@map("roles") +} + +model Permission { + id String @id @default(uuid()) + code String @unique // e.g. "campaigns:read", "campaigns:create" + module String // e.g. "campaigns", "users", "prizes", "reports", "admins" + name String // نام فارسی + description String? + + roles RolePermission[] + + @@index([module]) + @@map("permissions") +} + +model RolePermission { + roleId String @map("role_id") + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + permissionId String @map("permission_id") + permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) + + @@id([roleId, permissionId]) + @@map("role_permissions") +} + +model AdminRoleAssignment { + id String @id @default(uuid()) + adminId String @map("admin_id") + admin Admin @relation(fields: [adminId], references: [id], onDelete: Cascade) + roleId String @map("role_id") + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + assignedAt DateTime @default(now()) @map("assigned_at") + + @@unique([adminId, roleId]) + @@map("admin_role_assignments") +} + +model AdminWorkspaceAssignment { + id String @id @default(uuid()) + adminId String @map("admin_id") + admin Admin @relation(fields: [adminId], references: [id], onDelete: Cascade) + workspaceId String @map("workspace_id") + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + scopeType AccessScopeType @default(ALL) @map("scope_type") + specificCampaignIds Json? @map("specific_campaign_ids") + createdAt DateTime @default(now()) @map("created_at") + + @@unique([adminId, workspaceId]) + @@map("admin_workspace_assignments") +} diff --git a/apps/api/prisma/seed-rbac.ts b/apps/api/prisma/seed-rbac.ts new file mode 100644 index 0000000..bc9e572 --- /dev/null +++ b/apps/api/prisma/seed-rbac.ts @@ -0,0 +1,272 @@ +import { AccessScopeType, PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +const PERMISSIONS = [ + // Campaigns + { code: 'campaigns:read', module: 'campaigns', name: 'مشاهده کمپین‌ها', description: 'مشاهده لیست و جزئیات کمپین‌ها' }, + { code: 'campaigns:create', module: 'campaigns', name: 'ایجاد کمپین', description: 'ثبت کمپین جدید' }, + { code: 'campaigns:update', module: 'campaigns', name: 'ویرایش کمپین', description: 'تغییر تنظیمات و وضعیت کمپین' }, + { code: 'campaigns:delete', module: 'campaigns', name: 'حذف کمپین', description: 'حذف کمپین از سامانه' }, + + // Prizes + { code: 'prizes:read', module: 'prizes', name: 'مشاهده جوایز', description: 'مشاهده لیست جوایز کمپین‌ها' }, + { code: 'prizes:manage', module: 'prizes', name: 'مدیریت جوایز', description: 'افزودن، ویرایش و تنظیم ظرفیت جوایز' }, + + // Users & Chances + { code: 'users:read', module: 'users', name: 'مشاهده کاربران', description: 'مشاهده لیست کاربران شرکت‌کننده و پروفایل' }, + { code: 'users:block', module: 'users', name: 'مسدودسازی کاربران', description: 'بلاک یا آن‌بلاک کردن کاربران' }, + { code: 'chances:adjust', module: 'chances', name: 'تنظیم دستی شانس', description: 'افزایش یا کاهش شانس با ثبت لاگ' }, + + // Winners & Spins + { code: 'spins:read', module: 'spins', name: 'مشاهده لاگ چرخش‌ها', description: 'بررسی تاریخچه اسپین‌های کاربران' }, + { code: 'winners:read', module: 'winners', name: 'مشاهده برندگان', description: 'مشاهده لیست برندگان و کدهای هدیه' }, + { code: 'winners:manage', module: 'winners', name: 'مدیریت وضعیت جوایز', description: 'تغییر وضعیت کدهای جایزه به تحویل‌شده و...' }, + + // SMS & Logs + { code: 'sms:read', module: 'sms', name: 'مشاهده لاگ پیامک', description: 'مشاهده پیامک‌های ارسالی OTP و برندگان' }, + { code: 'sms:resend', module: 'sms', name: 'ارسال مجدد پیامک', description: 'ارسال دوباره پیامک‌های ناموفق' }, + { code: 'audit:read', module: 'audit', name: 'مشاهده لاگ بازرسی', description: 'مشاهده ردپای عملیات و تغییرات مدیران' }, + + // Reports + { code: 'reports:read', module: 'reports', name: 'مشاهده گزارش‌ها و آمار', description: 'مشاهده نمودارها، آمار زنده و داشبورد' }, + { code: 'reports:export', module: 'reports', name: 'خروجی داده‌ها', description: 'دریافت خروجی اکسل و CSV گزارش‌ها' }, + + // Access Control & Admins + { code: 'admins:read', module: 'admins', name: 'مشاهده مدیران', description: 'مشاهده لیست مدیران و همکاران' }, + { code: 'admins:manage', module: 'admins', name: 'مدیریت مدیران', description: 'ایجاد، ویرایش، تغییر رمز و غیرفعال‌سازی مدیران' }, + { code: 'roles:manage', module: 'roles', name: 'مدیریت نقش‌ها و مجوزها', description: 'تعریف نقش جدید و تخصیص مجوزها' }, + { code: 'workspaces:manage', module: 'workspaces', name: 'مدیریت فضاهای کاری', description: 'تعریف و مدیریت فضاهای کاری سازمانی' }, +]; + +async function main() { + console.log('Seeding RBAC and Workspaces...'); + + // 1. Ensure Default Workspace exists + let defaultWorkspace = await prisma.workspace.findUnique({ + where: { slug: 'default-workspace' }, + }); + + if (!defaultWorkspace) { + defaultWorkspace = await prisma.workspace.create({ + data: { + name: 'فضای کاری اصلی', + slug: 'default-workspace', + description: 'فضای کاری پیش‌فرض و سراسری سیستم گردونه شانس', + status: 'ACTIVE', + }, + }); + console.log('Created default workspace:', defaultWorkspace.id); + } + + // Link any unassigned campaigns to default workspace + await prisma.campaign.updateMany({ + where: { workspaceId: null }, + data: { workspaceId: defaultWorkspace.id }, + }); + + // 2. Seed Permissions + const permissionMap = new Map(); + for (const perm of PERMISSIONS) { + const p = await prisma.permission.upsert({ + where: { code: perm.code }, + update: { + module: perm.module, + name: perm.name, + description: perm.description, + }, + create: { + code: perm.code, + module: perm.module, + name: perm.name, + description: perm.description, + }, + }); + permissionMap.set(p.code, p.id); + } + console.log(`Seeded ${permissionMap.size} permissions.`); + + // 3. Seed Roles + // SUPER_ADMIN (All permissions) + const superAdminRole = await prisma.role.upsert({ + where: { code: 'SUPER_ADMIN' }, + update: { + name: 'مدیر ارشد سامانه', + description: 'دسترسی کامل و بدون محدودیت به تمامی بخش‌ها و تنظیمات سامانه', + isSystem: true, + }, + create: { + code: 'SUPER_ADMIN', + name: 'مدیر ارشد سامانه', + description: 'دسترسی کامل و بدون محدودیت به تمامی بخش‌ها و تنظیمات سامانه', + isSystem: true, + }, + }); + + // OPERATOR (Operational permissions) + const operatorRole = await prisma.role.upsert({ + where: { code: 'OPERATOR' }, + update: { + name: 'اپراتور سامانه', + description: 'مدیریت کمپین‌ها، جوایز، شانس‌ها، برندگان و کاربران بدون دسترسی به مدیریت ادمین‌ها', + isSystem: true, + }, + create: { + code: 'OPERATOR', + name: 'اپراتور سامانه', + description: 'مدیریت کمپین‌ها، جوایز، شانس‌ها، برندگان و کاربران بدون دسترسی به مدیریت ادمین‌ها', + isSystem: true, + }, + }); + + // SUPPORT (Read-only + winner management) + const supportRole = await prisma.role.upsert({ + where: { code: 'SUPPORT' }, + update: { + name: 'کارشناس پشتیبانی', + description: 'مشاهده کاربران، برندگان، پیامک‌ها و تغییر وضعیت تحویل جوایز', + isSystem: true, + }, + create: { + code: 'SUPPORT', + name: 'کارشناس پشتیبانی', + description: 'مشاهده کاربران، برندگان، پیامک‌ها و تغییر وضعیت تحویل جوایز', + isSystem: true, + }, + }); + + // 4. Assign permissions to roles + // SUPER_ADMIN gets all permissions + for (const permId of permissionMap.values()) { + await prisma.rolePermission.upsert({ + where: { + roleId_permissionId: { + roleId: superAdminRole.id, + permissionId: permId, + }, + }, + update: {}, + create: { + roleId: superAdminRole.id, + permissionId: permId, + }, + }); + } + + // OPERATOR permissions + const operatorPermCodes = [ + 'campaigns:read', 'campaigns:create', 'campaigns:update', 'campaigns:delete', + 'prizes:read', 'prizes:manage', + 'users:read', 'users:block', 'chances:adjust', + 'spins:read', 'winners:read', 'winners:manage', + 'sms:read', 'sms:resend', + 'reports:read', 'reports:export', + 'audit:read', + ]; + for (const code of operatorPermCodes) { + const permId = permissionMap.get(code); + if (permId) { + await prisma.rolePermission.upsert({ + where: { + roleId_permissionId: { + roleId: operatorRole.id, + permissionId: permId, + }, + }, + update: {}, + create: { + roleId: operatorRole.id, + permissionId: permId, + }, + }); + } + } + + // SUPPORT permissions + const supportPermCodes = [ + 'campaigns:read', + 'prizes:read', + 'users:read', + 'spins:read', + 'winners:read', 'winners:manage', + 'sms:read', + ]; + for (const code of supportPermCodes) { + const permId = permissionMap.get(code); + if (permId) { + await prisma.rolePermission.upsert({ + where: { + roleId_permissionId: { + roleId: supportRole.id, + permissionId: permId, + }, + }, + update: {}, + create: { + roleId: supportRole.id, + permissionId: permId, + }, + }); + } + } + + // 5. Backfill existing admins + const admins = await prisma.admin.findMany(); + for (const admin of admins) { + const isSuper = admin.role === 'SUPER_ADMIN'; + + await prisma.admin.update({ + where: { id: admin.id }, + data: { + isSuperAdmin: isSuper, + }, + }); + + const targetRoleId = isSuper + ? superAdminRole.id + : admin.role === 'OPERATOR' + ? operatorRole.id + : supportRole.id; + + await prisma.adminRoleAssignment.upsert({ + where: { + adminId_roleId: { + adminId: admin.id, + roleId: targetRoleId, + }, + }, + update: {}, + create: { + adminId: admin.id, + roleId: targetRoleId, + }, + }); + + await prisma.adminWorkspaceAssignment.upsert({ + where: { + adminId_workspaceId: { + adminId: admin.id, + workspaceId: defaultWorkspace.id, + }, + }, + update: {}, + create: { + adminId: admin.id, + workspaceId: defaultWorkspace.id, + scopeType: AccessScopeType.ALL, + }, + }); + } + + console.log(`Backfilled ${admins.length} existing admins with roles and workspace assignments.`); + console.log('RBAC seed completed successfully.'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 01b49db..96c6a62 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -6,6 +6,7 @@ import { ThrottlerModule } from '@nestjs/throttler'; import { validateEnv } from './config/env.validation'; import { AdminAuditModule } from './modules/admin-audit/admin-audit.module'; import { AdminAuthModule } from './modules/admin-auth/admin-auth.module'; +import { AdminManagementModule } from './modules/admin-management/admin-management.module'; import { AuthModule } from './modules/auth/auth.module'; import { CampaignModule } from './modules/campaign/campaign.module'; import { ChanceModule } from './modules/chance/chance.module'; @@ -57,6 +58,7 @@ import { PrismaModule } from './prisma/prisma.module'; WinnerModule, ReportModule, AdminAuditModule, + AdminManagementModule, ], }) export class AppModule {} diff --git a/apps/api/src/common/decorators/current-admin.decorator.ts b/apps/api/src/common/decorators/current-admin.decorator.ts index fbdb221..dac4dfa 100644 --- a/apps/api/src/common/decorators/current-admin.decorator.ts +++ b/apps/api/src/common/decorators/current-admin.decorator.ts @@ -7,6 +7,13 @@ export interface AuthenticatedAdmin { mobile: string; name: string; role: AdminRole; + isSuperAdmin?: boolean; + permissions?: string[]; + workspaceAssignments?: Array<{ + workspaceId: string; + scopeType: string; + specificCampaignIds?: string[] | null; + }>; } export const CurrentAdmin = createParamDecorator( diff --git a/apps/api/src/common/decorators/require-permission.decorator.ts b/apps/api/src/common/decorators/require-permission.decorator.ts new file mode 100644 index 0000000..aeff53d --- /dev/null +++ b/apps/api/src/common/decorators/require-permission.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export const PERMISSIONS_KEY = 'permissions'; + +export const RequirePermission = (...permissions: string[]) => + SetMetadata(PERMISSIONS_KEY, permissions); diff --git a/apps/api/src/common/guards/permissions.guard.ts b/apps/api/src/common/guards/permissions.guard.ts new file mode 100644 index 0000000..b5d1f60 --- /dev/null +++ b/apps/api/src/common/guards/permissions.guard.ts @@ -0,0 +1,43 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PERMISSIONS_KEY } from '../decorators/require-permission.decorator'; + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredPermissions = this.reflector.getAllAndOverride(PERMISSIONS_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + // If no permission is required on the route, allow access + if (!requiredPermissions || requiredPermissions.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const admin = request.admin || request.user; + + if (!admin) { + throw new ForbiddenException('ACCESS_DENIED'); + } + + // Super Admin has full unrestricted bypass access + if (admin.isSuperAdmin === true || admin.role === 'SUPER_ADMIN') { + return true; + } + + const adminPermissions: string[] = admin.permissions || []; + + // Check if admin has all required permissions (or at least one if multiple are given) + const hasRequired = requiredPermissions.every((perm) => adminPermissions.includes(perm)); + + if (!hasRequired) { + throw new ForbiddenException('INSUFFICIENT_PERMISSIONS'); + } + + return true; + } +} diff --git a/apps/api/src/modules/admin-audit/admin-audit.controller.ts b/apps/api/src/modules/admin-audit/admin-audit.controller.ts index d3516b5..86a4b04 100644 --- a/apps/api/src/modules/admin-audit/admin-audit.controller.ts +++ b/apps/api/src/modules/admin-audit/admin-audit.controller.ts @@ -1,16 +1,19 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { AdminAuditService } from './admin-audit.service'; @ApiTags('Admin Audit Logs (لاگ‌های بازرسی مدیران)') @Controller('admin/audit-logs') -@UseGuards(JwtAdminGuard) +@UseGuards(JwtAdminGuard, PermissionsGuard) @ApiBearerAuth('AdminAuth') export class AdminAuditController { constructor(private readonly auditService: AdminAuditService) {} @Get() + @RequirePermission('audit:read') @ApiOperation({ summary: 'مشاهده تمام لاگ‌های بازرسی اقدامات ادمین با فیلتر' }) @ApiQuery({ name: 'page', required: false }) @ApiQuery({ name: 'pageSize', required: false }) diff --git a/apps/api/src/modules/admin-auth/admin-auth.controller.ts b/apps/api/src/modules/admin-auth/admin-auth.controller.ts index b45d248..dac2c8f 100644 --- a/apps/api/src/modules/admin-auth/admin-auth.controller.ts +++ b/apps/api/src/modules/admin-auth/admin-auth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, HttpCode, HttpStatus, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; @@ -31,6 +31,14 @@ export class AdminAuthController { return this.adminAuthService.refreshToken(dto); } + @Get('me') + @UseGuards(JwtAdminGuard) + @ApiBearerAuth('AdminAuth') + @ApiOperation({ summary: 'دریافت مشخصات کامل و مجوزهای مدیر جاری' }) + async getMe(@Req() req: any) { + return this.adminAuthService.getProfile(req.user.id); + } + @Patch('profile') @UseGuards(JwtAdminGuard) @HttpCode(HttpStatus.OK) diff --git a/apps/api/src/modules/admin-auth/admin-auth.service.ts b/apps/api/src/modules/admin-auth/admin-auth.service.ts index fcb06f2..f7f212d 100644 --- a/apps/api/src/modules/admin-auth/admin-auth.service.ts +++ b/apps/api/src/modules/admin-auth/admin-auth.service.ts @@ -56,6 +56,26 @@ export class AdminAuthService implements OnModuleInit { const { email, password } = dto; const admin = await this.prisma.admin.findUnique({ where: { email: email.toLowerCase() }, + include: { + roles: { + include: { + role: { + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }, + }, + }, + workspaceAssignments: { + include: { + workspace: true, + }, + }, + }, }); if (!admin) { @@ -71,6 +91,19 @@ export class AdminAuthService implements OnModuleInit { throw new UnauthorizedException('ایمیل یا کلمه عبور اشتباه است.'); } + // Update lastLoginAt + await this.prisma.admin.update({ + where: { id: admin.id }, + data: { lastLoginAt: new Date() }, + }); + + const permissions = new Set(); + admin.roles.forEach((ra) => { + ra.role.permissions.forEach((rp) => { + permissions.add(rp.permission.code); + }); + }); + const tokens = this.generateAdminTokens(admin); return { @@ -81,6 +114,19 @@ export class AdminAuthService implements OnModuleInit { email: admin.email, mobile: admin.mobile, role: admin.role, + isSuperAdmin: admin.isSuperAdmin, + permissions: Array.from(permissions), + roles: admin.roles.map((ra) => ({ + id: ra.role.id, + code: ra.role.code, + name: ra.role.name, + })), + workspaces: admin.workspaceAssignments.map((wa) => ({ + workspaceId: wa.workspaceId, + workspaceName: wa.workspace.name, + scopeType: wa.scopeType, + specificCampaignIds: wa.specificCampaignIds, + })), }, }; } @@ -183,9 +229,68 @@ export class AdminAuthService implements OnModuleInit { mobile: true, role: true, status: true, + isSuperAdmin: true, }, }); return updated; } + + async getProfile(adminId: string) { + const admin = await this.prisma.admin.findUnique({ + where: { id: adminId }, + include: { + roles: { + include: { + role: { + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }, + }, + }, + workspaceAssignments: { + include: { + workspace: true, + }, + }, + }, + }); + + if (!admin || admin.status !== AdminStatus.ACTIVE) { + throw new UnauthorizedException('حساب کاربری یافت نشد یا غیرفعال است.'); + } + + const permissions = new Set(); + admin.roles.forEach((ra) => { + ra.role.permissions.forEach((rp) => { + permissions.add(rp.permission.code); + }); + }); + + return { + id: admin.id, + name: admin.name, + email: admin.email, + mobile: admin.mobile, + role: admin.role, + isSuperAdmin: admin.isSuperAdmin, + permissions: Array.from(permissions), + roles: admin.roles.map((ra) => ({ + id: ra.role.id, + code: ra.role.code, + name: ra.role.name, + })), + workspaces: admin.workspaceAssignments.map((wa) => ({ + workspaceId: wa.workspaceId, + workspaceName: wa.workspace.name, + scopeType: wa.scopeType, + specificCampaignIds: wa.specificCampaignIds, + })), + }; + } } diff --git a/apps/api/src/modules/admin-auth/jwt-admin.strategy.ts b/apps/api/src/modules/admin-auth/jwt-admin.strategy.ts index cc3c692..331ae85 100644 --- a/apps/api/src/modules/admin-auth/jwt-admin.strategy.ts +++ b/apps/api/src/modules/admin-auth/jwt-admin.strategy.ts @@ -25,15 +25,58 @@ export class JwtAdminStrategy extends PassportStrategy(Strategy, 'jwt-admin') { const admin = await this.prisma.admin.findUnique({ where: { id: payload.sub }, - select: { id: true, email: true, name: true, mobile: true, role: true, status: true }, + select: { + id: true, + email: true, + name: true, + mobile: true, + role: true, + status: true, + isSuperAdmin: true, + roles: { + include: { + role: { + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }, + }, + }, + workspaceAssignments: { + select: { + workspaceId: true, + scopeType: true, + specificCampaignIds: true, + }, + }, + }, }); if (!admin || admin.status !== AdminStatus.ACTIVE) { throw new UnauthorizedException('ADMIN_INACTIVE_OR_NOT_FOUND'); } + const permissions = new Set(); + admin.roles.forEach((ra) => { + ra.role.permissions.forEach((rp) => { + permissions.add(rp.permission.code); + }); + }); + return { - ...admin, + id: admin.id, + email: admin.email, + name: admin.name, + mobile: admin.mobile, + role: admin.role, + status: admin.status, + isSuperAdmin: admin.isSuperAdmin, + permissions: Array.from(permissions), + workspaceAssignments: admin.workspaceAssignments, type: 'admin', }; } diff --git a/apps/api/src/modules/admin-management/admin-management.controller.ts b/apps/api/src/modules/admin-management/admin-management.controller.ts new file mode 100644 index 0000000..b787422 --- /dev/null +++ b/apps/api/src/modules/admin-management/admin-management.controller.ts @@ -0,0 +1,103 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { AdminStatus } from '@prisma/client'; +import { CurrentAdmin } from '../../common/decorators/current-admin.decorator'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; +import { AdminManagementService } from './admin-management.service'; +import { CreateAdminDto } from './dto/create-admin.dto'; +import { UpdateAdminDto } from './dto/update-admin.dto'; + +@ApiTags('Admin Management (مدیریت مدیران و دسترسی‌ها)') +@Controller('admin/managers') +@UseGuards(JwtAdminGuard, PermissionsGuard) +@UseInterceptors(AuditInterceptor) +@ApiBearerAuth('AdminAuth') +export class AdminManagementController { + constructor(private readonly service: AdminManagementService) {} + + @Get() + @RequirePermission('admins:read') + @ApiOperation({ summary: 'لیست مدیران سامانه با فیلتر جستجو، نقش و وضعیت' }) + @ApiQuery({ name: 'search', required: false }) + @ApiQuery({ name: 'roleCode', required: false }) + @ApiQuery({ name: 'status', enum: AdminStatus, required: false }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) + async findAll(@Query() query: any) { + return this.service.findAllAdmins(query); + } + + @Get(':id') + @RequirePermission('admins:read') + @ApiOperation({ summary: 'مشاهده مشخصات کامل مدیر، نقش‌ها و فضاهای کاری' }) + async findOne(@Param('id') id: string) { + return this.service.findAdminById(id); + } + + @Post() + @RequirePermission('admins:manage') + @ApiOperation({ summary: 'افزودن مدیر جدید به همراه نقش‌ها و اسکوپ فضاهای کاری' }) + @ApiResponse({ status: 201, description: 'مدیر با موفقیت ایجاد شد' }) + async create(@Body() dto: CreateAdminDto) { + return this.service.createAdmin(dto); + } + + @Patch(':id') + @RequirePermission('admins:manage') + @ApiOperation({ summary: 'ویرایش مشخصات مدیر و نقش‌ها/فضاهای کاری' }) + async update( + @Param('id') id: string, + @Body() dto: UpdateAdminDto, + @CurrentAdmin('id') currentAdminId: string, + ) { + return this.service.updateAdmin(id, dto, currentAdminId); + } + + @Patch(':id/status') + @RequirePermission('admins:manage') + @ApiOperation({ summary: 'تغییر وضعیت فعال یا غیرفعال مدیر' }) + async toggleStatus( + @Param('id') id: string, + @Body('status') status: AdminStatus, + @CurrentAdmin('id') currentAdminId: string, + ) { + return this.service.toggleAdminStatus(id, status, currentAdminId); + } + + @Patch(':id/password') + @RequirePermission('admins:manage') + @ApiOperation({ summary: 'بازنشانی کلمه عبور مدیر توسط سوپرادمین' }) + async resetPassword( + @Param('id') id: string, + @Body('password') password: string, + ) { + return this.service.updateAdmin(id, { password }, ''); + } + + @Delete(':id') + @RequirePermission('admins:manage') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'حذف مدیر (در صورت عدم وجود سوابق تراکنش)' }) + async delete( + @Param('id') id: string, + @CurrentAdmin('id') currentAdminId: string, + ) { + return this.service.deleteAdmin(id, currentAdminId); + } +} diff --git a/apps/api/src/modules/admin-management/admin-management.module.ts b/apps/api/src/modules/admin-management/admin-management.module.ts new file mode 100644 index 0000000..fe09427 --- /dev/null +++ b/apps/api/src/modules/admin-management/admin-management.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminManagementController } from './admin-management.controller'; +import { AdminManagementService } from './admin-management.service'; +import { RoleManagementController } from './role-management.controller'; +import { WorkspaceController } from './workspace.controller'; + +@Module({ + imports: [PrismaModule], + controllers: [ + AdminManagementController, + RoleManagementController, + WorkspaceController, + ], + providers: [AdminManagementService], + exports: [AdminManagementService], +}) +export class AdminManagementModule {} diff --git a/apps/api/src/modules/admin-management/admin-management.service.ts b/apps/api/src/modules/admin-management/admin-management.service.ts new file mode 100644 index 0000000..4560368 --- /dev/null +++ b/apps/api/src/modules/admin-management/admin-management.service.ts @@ -0,0 +1,611 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { AccessScopeType, AdminRole, AdminStatus } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CreateAdminDto } from './dto/create-admin.dto'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { CreateWorkspaceDto } from './dto/create-workspace.dto'; +import { UpdateAdminDto } from './dto/update-admin.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; +import { UpdateWorkspaceDto } from './dto/update-workspace.dto'; + +@Injectable() +export class AdminManagementService { + constructor(private readonly prisma: PrismaService) {} + + // ========================================================================= + // ADMINS CRUD + // ========================================================================= + + async findAllAdmins(params: { + search?: string; + roleCode?: string; + status?: AdminStatus; + page?: number; + pageSize?: number; + }) { + const page = Math.max(1, Number(params.page) || 1); + const pageSize = Math.min(100, Math.max(1, Number(params.pageSize) || 10)); + const skip = (page - 1) * pageSize; + + const where: any = {}; + + if (params.status) { + where.status = params.status; + } + + if (params.search && params.search.trim()) { + const q = params.search.trim(); + where.OR = [ + { name: { contains: q, mode: 'insensitive' } }, + { email: { contains: q, mode: 'insensitive' } }, + { mobile: { contains: q } }, + ]; + } + + if (params.roleCode) { + where.roles = { + some: { + role: { + code: params.roleCode, + }, + }, + }; + } + + const [total, admins] = await Promise.all([ + this.prisma.admin.count({ where }), + this.prisma.admin.findMany({ + where, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + email: true, + mobile: true, + role: true, + status: true, + isSuperAdmin: true, + lastLoginAt: true, + createdAt: true, + updatedAt: true, + roles: { + select: { + role: { + select: { + id: true, + code: true, + name: true, + isSystem: true, + }, + }, + }, + }, + workspaceAssignments: { + select: { + workspace: { + select: { + id: true, + name: true, + slug: true, + }, + }, + scopeType: true, + specificCampaignIds: true, + }, + }, + }, + }), + ]); + + return { + data: admins, + meta: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }; + } + + async findAdminById(id: string) { + const admin = await this.prisma.admin.findUnique({ + where: { id }, + include: { + roles: { + include: { + role: { + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }, + }, + }, + workspaceAssignments: { + include: { + workspace: true, + }, + }, + auditLogs: { + take: 10, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!admin) { + throw new NotFoundException('مدیر مورد نظر یافت نشد.'); + } + + const { passwordHash, ...safeAdmin } = admin; + return safeAdmin; + } + + async createAdmin(dto: CreateAdminDto) { + const email = dto.email.toLowerCase().trim(); + const mobile = dto.mobile.trim(); + + const [existingEmail, existingMobile] = await Promise.all([ + this.prisma.admin.findUnique({ where: { email } }), + this.prisma.admin.findUnique({ where: { mobile } }), + ]); + + if (existingEmail) { + throw new ConflictException('این آدرس ایمیل قبلاً در سامانه ثبت شده است.'); + } + if (existingMobile) { + throw new ConflictException('این شماره موبایل قبلاً برای مدیر دیگری ثبت شده است.'); + } + + const passwordHash = await bcrypt.hash(dto.password, 10); + + return this.prisma.$transaction(async (tx) => { + const admin = await tx.admin.create({ + data: { + name: dto.name.trim(), + email, + mobile, + passwordHash, + status: dto.status || AdminStatus.ACTIVE, + isSuperAdmin: dto.isSuperAdmin || false, + role: dto.isSuperAdmin ? AdminRole.SUPER_ADMIN : AdminRole.OPERATOR, + }, + }); + + // Role assignments + if (dto.roleIds && dto.roleIds.length > 0) { + await tx.adminRoleAssignment.createMany({ + data: dto.roleIds.map((roleId) => ({ + adminId: admin.id, + roleId, + })), + }); + } + + // Workspace assignments + if (dto.workspaceAssignments && dto.workspaceAssignments.length > 0) { + await tx.adminWorkspaceAssignment.createMany({ + data: dto.workspaceAssignments.map((wa) => ({ + adminId: admin.id, + workspaceId: wa.workspaceId, + scopeType: wa.scopeType || AccessScopeType.ALL, + specificCampaignIds: wa.specificCampaignIds ? wa.specificCampaignIds : undefined, + })), + }); + } else { + // Assign to default workspace if none specified + const defaultWs = await tx.workspace.findUnique({ where: { slug: 'default-workspace' } }); + if (defaultWs) { + await tx.adminWorkspaceAssignment.create({ + data: { + adminId: admin.id, + workspaceId: defaultWs.id, + scopeType: AccessScopeType.ALL, + }, + }); + } + } + + return tx.admin.findUnique({ + where: { id: admin.id }, + select: { + id: true, + name: true, + email: true, + mobile: true, + status: true, + isSuperAdmin: true, + createdAt: true, + }, + }); + }); + } + + async updateAdmin(id: string, dto: UpdateAdminDto, currentAdminId: string) { + const admin = await this.prisma.admin.findUnique({ where: { id } }); + if (!admin) { + throw new NotFoundException('مدیر مورد نظر یافت نشد.'); + } + + // Protection rule: Self modification of status / isSuperAdmin + if (id === currentAdminId) { + if (dto.status !== undefined && dto.status !== admin.status) { + throw new BadRequestException('امکان تغییر وضعیت حساب کاربری جاری توسط خودتان وجود ندارد.'); + } + if (dto.isSuperAdmin !== undefined && dto.isSuperAdmin !== admin.isSuperAdmin) { + throw new BadRequestException('امکان تغییر اختیارات ارشد حساب کاربری خودتان وجود ندارد.'); + } + } + + // Protection rule: Last Super Admin demotion/deactivation + if (admin.isSuperAdmin) { + const willDemoteOrDeactivate = + (dto.isSuperAdmin === false) || + (dto.status === AdminStatus.DISABLED); + + if (willDemoteOrDeactivate) { + const activeSuperAdminCount = await this.prisma.admin.count({ + where: { isSuperAdmin: true, status: AdminStatus.ACTIVE }, + }); + if (activeSuperAdminCount <= 1) { + throw new BadRequestException('امکان غیرفعال‌سازی یا خلع اختیارات آخرین مدیر ارشد سامانه وجود ندارد.'); + } + } + } + + const dataToUpdate: any = {}; + if (dto.name) dataToUpdate.name = dto.name.trim(); + + if (dto.email) { + const emailLower = dto.email.toLowerCase().trim(); + const existing = await this.prisma.admin.findFirst({ + where: { email: emailLower, id: { not: id } }, + }); + if (existing) { + throw new ConflictException('این ایمیل قبلاً برای مدیر دیگری ثبت شده است.'); + } + dataToUpdate.email = emailLower; + } + + if (dto.mobile) { + const mobileClean = dto.mobile.trim(); + const existing = await this.prisma.admin.findFirst({ + where: { mobile: mobileClean, id: { not: id } }, + }); + if (existing) { + throw new ConflictException('این شماره موبایل قبلاً برای مدیر دیگری ثبت شده است.'); + } + dataToUpdate.mobile = mobileClean; + } + + if (dto.password) { + dataToUpdate.passwordHash = await bcrypt.hash(dto.password, 10); + } + + if (dto.status !== undefined) { + dataToUpdate.status = dto.status; + } + + if (dto.isSuperAdmin !== undefined) { + dataToUpdate.isSuperAdmin = dto.isSuperAdmin; + if (dto.isSuperAdmin) { + dataToUpdate.role = AdminRole.SUPER_ADMIN; + } + } + + return this.prisma.$transaction(async (tx) => { + await tx.admin.update({ + where: { id }, + data: dataToUpdate, + }); + + // Update role assignments if explicitly provided + if (dto.roleIds !== undefined) { + await tx.adminRoleAssignment.deleteMany({ where: { adminId: id } }); + if (dto.roleIds.length > 0) { + await tx.adminRoleAssignment.createMany({ + data: dto.roleIds.map((roleId) => ({ + adminId: id, + roleId, + })), + }); + } + } + + // Update workspace assignments if explicitly provided + if (dto.workspaceAssignments !== undefined) { + await tx.adminWorkspaceAssignment.deleteMany({ where: { adminId: id } }); + if (dto.workspaceAssignments.length > 0) { + await tx.adminWorkspaceAssignment.createMany({ + data: dto.workspaceAssignments.map((wa) => ({ + adminId: id, + workspaceId: wa.workspaceId, + scopeType: wa.scopeType || AccessScopeType.ALL, + specificCampaignIds: wa.specificCampaignIds ? wa.specificCampaignIds : undefined, + })), + }); + } + } + + return tx.admin.findUnique({ + where: { id }, + select: { + id: true, + name: true, + email: true, + mobile: true, + status: true, + isSuperAdmin: true, + updatedAt: true, + }, + }); + }); + } + + async toggleAdminStatus(id: string, status: AdminStatus, currentAdminId: string) { + if (id === currentAdminId) { + throw new BadRequestException('امکان غیرفعال‌سازی حساب کاربری جاری توسط خودتان وجود ندارد.'); + } + + const admin = await this.prisma.admin.findUnique({ where: { id } }); + if (!admin) { + throw new NotFoundException('مدیر مورد نظر یافت نشد.'); + } + + if (admin.isSuperAdmin && status === AdminStatus.DISABLED) { + const activeSuperAdminCount = await this.prisma.admin.count({ + where: { isSuperAdmin: true, status: AdminStatus.ACTIVE }, + }); + if (activeSuperAdminCount <= 1) { + throw new BadRequestException('امکان غیرفعال‌سازی آخرین مدیر ارشد سامانه وجود ندارد.'); + } + } + + return this.prisma.admin.update({ + where: { id }, + data: { status }, + select: { id: true, name: true, status: true }, + }); + } + + async deleteAdmin(id: string, currentAdminId: string) { + if (id === currentAdminId) { + throw new BadRequestException('امکان حذف حساب کاربری خودتان وجود ندارد.'); + } + + const admin = await this.prisma.admin.findUnique({ + where: { id }, + include: { + chanceTxns: { take: 1 }, + }, + }); + + if (!admin) { + throw new NotFoundException('مدیر مورد نظر یافت نشد.'); + } + + if (admin.isSuperAdmin) { + const activeSuperAdminCount = await this.prisma.admin.count({ + where: { isSuperAdmin: true, status: AdminStatus.ACTIVE }, + }); + if (activeSuperAdminCount <= 1) { + throw new BadRequestException('امکان حذف آخرین مدیر ارشد سامانه وجود ندارد.'); + } + } + + if (admin.chanceTxns && admin.chanceTxns.length > 0) { + throw new BadRequestException( + 'این مدیر دارای سوابق ثبت یا تغییر شانس است و حذف فیزیکی آن یکپارچگی داده‌ها را مخدوش می‌کند. لطفاً وضعیت حساب آن را به «غیرفعال» تغییر دهید.', + ); + } + + await this.prisma.admin.delete({ where: { id } }); + return { success: true, message: 'مدیر با موفقیت حذف شد.' }; + } + + // ========================================================================= + // ROLES & PERMISSIONS + // ========================================================================= + + async findAllRoles() { + return this.prisma.role.findMany({ + orderBy: { createdAt: 'asc' }, + include: { + permissions: { + include: { + permission: true, + }, + }, + _count: { + select: { admins: true }, + }, + }, + }); + } + + async findRoleById(id: string) { + const role = await this.prisma.role.findUnique({ + where: { id }, + include: { + permissions: { + include: { + permission: true, + }, + }, + }, + }); + + if (!role) { + throw new NotFoundException('نقش مورد نظر یافت نشد.'); + } + + return role; + } + + async createRole(dto: CreateRoleDto) { + const existing = await this.prisma.role.findUnique({ + where: { code: dto.code.toUpperCase().trim() }, + }); + if (existing) { + throw new ConflictException('نقشی با این کد انگلیسی قبلاً ثبت شده است.'); + } + + return this.prisma.$transaction(async (tx) => { + const role = await tx.role.create({ + data: { + code: dto.code.toUpperCase().trim(), + name: dto.name.trim(), + description: dto.description?.trim(), + isSystem: false, + }, + }); + + if (dto.permissionIds && dto.permissionIds.length > 0) { + await tx.rolePermission.createMany({ + data: dto.permissionIds.map((permId) => ({ + roleId: role.id, + permissionId: permId, + })), + }); + } + + return tx.role.findUnique({ + where: { id: role.id }, + include: { + permissions: { + include: { permission: true }, + }, + }, + }); + }); + } + + async updateRole(id: string, dto: UpdateRoleDto) { + const role = await this.prisma.role.findUnique({ where: { id } }); + if (!role) { + throw new NotFoundException('نقش مورد نظر یافت نشد.'); + } + + return this.prisma.$transaction(async (tx) => { + await tx.role.update({ + where: { id }, + data: { + name: dto.name?.trim(), + description: dto.description?.trim(), + }, + }); + + if (dto.permissionIds !== undefined) { + await tx.rolePermission.deleteMany({ where: { roleId: id } }); + if (dto.permissionIds.length > 0) { + await tx.rolePermission.createMany({ + data: dto.permissionIds.map((permId) => ({ + roleId: id, + permissionId: permId, + })), + }); + } + } + + return tx.role.findUnique({ + where: { id }, + include: { + permissions: { + include: { permission: true }, + }, + }, + }); + }); + } + + async deleteRole(id: string) { + const role = await this.prisma.role.findUnique({ + where: { id }, + include: { + _count: { + select: { admins: true }, + }, + }, + }); + + if (!role) { + throw new NotFoundException('نقش مورد نظر یافت نشد.'); + } + + if (role.isSystem) { + throw new BadRequestException('امکان حذف نقش‌های پیش‌فرض و سیستمی وجود ندارد.'); + } + + if (role._count.admins > 0) { + throw new BadRequestException('به دلیل وجود مدیران منتسب به این نقش، ابتدا نقش آن‌ها را تغییر داده و سپس اقدام به حذف نمایید.'); + } + + await this.prisma.role.delete({ where: { id } }); + return { success: true, message: 'نقش با موفقیت حذف شد.' }; + } + + async findAllPermissions() { + return this.prisma.permission.findMany({ + orderBy: [{ module: 'asc' }, { code: 'asc' }], + }); + } + + // ========================================================================= + // WORKSPACES + // ========================================================================= + + async findAllWorkspaces() { + return this.prisma.workspace.findMany({ + orderBy: { createdAt: 'asc' }, + include: { + _count: { + select: { campaigns: true, adminAssignments: true }, + }, + }, + }); + } + + async createWorkspace(dto: CreateWorkspaceDto) { + const existing = await this.prisma.workspace.findUnique({ + where: { slug: dto.slug.toLowerCase().trim() }, + }); + if (existing) { + throw new ConflictException('فضای کاری با این اسلاگ انگلیسی قبلاً ثبت شده است.'); + } + + return this.prisma.workspace.create({ + data: { + name: dto.name.trim(), + slug: dto.slug.toLowerCase().trim(), + description: dto.description?.trim(), + }, + }); + } + + async updateWorkspace(id: string, dto: UpdateWorkspaceDto) { + const workspace = await this.prisma.workspace.findUnique({ where: { id } }); + if (!workspace) { + throw new NotFoundException('فضای کاری یافت نشد.'); + } + + return this.prisma.workspace.update({ + where: { id }, + data: { + name: dto.name?.trim(), + description: dto.description?.trim(), + }, + }); + } +} diff --git a/apps/api/src/modules/admin-management/dto/create-admin.dto.ts b/apps/api/src/modules/admin-management/dto/create-admin.dto.ts new file mode 100644 index 0000000..17402f7 --- /dev/null +++ b/apps/api/src/modules/admin-management/dto/create-admin.dto.ts @@ -0,0 +1,79 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AccessScopeType, AdminStatus } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsEmail, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MinLength, + ValidateNested, +} from 'class-validator'; + +export class AdminWorkspaceAssignmentDto { + @ApiProperty({ description: 'شناسه فضای کاری' }) + @IsString() + @IsNotEmpty() + workspaceId: string; + + @ApiPropertyOptional({ enum: AccessScopeType, default: AccessScopeType.ALL }) + @IsEnum(AccessScopeType) + @IsOptional() + scopeType?: AccessScopeType = AccessScopeType.ALL; + + @ApiPropertyOptional({ description: 'لیست شناسه کمپین‌های مجاز در صورت اسکوپ سفارشی' }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + specificCampaignIds?: string[]; +} + +export class CreateAdminDto { + @ApiProperty({ description: 'نام و نام خانوادگی مدیر', example: 'علی رضایی' }) + @IsString() + @IsNotEmpty({ message: 'نام مدیر الزامی است.' }) + name: string; + + @ApiProperty({ description: 'ایمیل سازمانی مدیر', example: 'ali@example.com' }) + @IsEmail({}, { message: 'فرمت ایمیل نامعتبر است.' }) + @IsNotEmpty({ message: 'ایمیل الزامی است.' }) + email: string; + + @ApiProperty({ description: 'شماره موبایل مدیر (۱۱ رقم با ۰۹)', example: '09123456789' }) + @Matches(/^09\d{9}$/, { message: 'شماره موبایل باید ۱۱ رقمی بوده و با ۰۹ شروع شود.' }) + @IsNotEmpty({ message: 'شماره موبایل الزامی است.' }) + mobile: string; + + @ApiProperty({ description: 'کلمه عبور ورود به سامانه (حداقل ۶ کاراکتر)', example: 'SecurePass123' }) + @IsString() + @MinLength(6, { message: 'کلمه عبور باید حداقل ۶ کاراکتر باشد.' }) + @IsNotEmpty({ message: 'کلمه عبور الزامی است.' }) + password: string; + + @ApiPropertyOptional({ enum: AdminStatus, default: AdminStatus.ACTIVE }) + @IsEnum(AdminStatus) + @IsOptional() + status?: AdminStatus = AdminStatus.ACTIVE; + + @ApiPropertyOptional({ description: 'آیا این کاربر مدیر ارشد تام‌الاختیار است؟', default: false }) + @IsBoolean() + @IsOptional() + isSuperAdmin?: boolean = false; + + @ApiPropertyOptional({ description: 'شناسه نقش‌های تخصیص‌یافته به مدیر', type: [String] }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + roleIds?: string[]; + + @ApiPropertyOptional({ description: 'تخصیص‌های فضاهای کاری و اسکوپ دسترسی', type: [AdminWorkspaceAssignmentDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AdminWorkspaceAssignmentDto) + @IsOptional() + workspaceAssignments?: AdminWorkspaceAssignmentDto[]; +} diff --git a/apps/api/src/modules/admin-management/dto/create-role.dto.ts b/apps/api/src/modules/admin-management/dto/create-role.dto.ts new file mode 100644 index 0000000..ed7330f --- /dev/null +++ b/apps/api/src/modules/admin-management/dto/create-role.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator'; + +export class CreateRoleDto { + @ApiProperty({ description: 'کد یکتا و انگلیسی نقش', example: 'MARKETING_LEAD' }) + @IsString() + @Matches(/^[A-Z0-9_]+$/, { message: 'کد نقش باید فقط شامل حروف بزرگ انگلیسی، اعداد و خط زیر (_) باشد.' }) + @IsNotEmpty({ message: 'کد نقش الزامی است.' }) + code: string; + + @ApiProperty({ description: 'نام نمایشی نقش به فارسی', example: 'سرپرست بازاریابی' }) + @IsString() + @IsNotEmpty({ message: 'نام نقش الزامی است.' }) + name: string; + + @ApiPropertyOptional({ description: 'توضیحات نقش' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ description: 'لیست شناسه‌های پرمیشن‌های منتسب به این نقش', type: [String] }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + permissionIds?: string[]; +} diff --git a/apps/api/src/modules/admin-management/dto/create-workspace.dto.ts b/apps/api/src/modules/admin-management/dto/create-workspace.dto.ts new file mode 100644 index 0000000..a892c58 --- /dev/null +++ b/apps/api/src/modules/admin-management/dto/create-workspace.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator'; + +export class CreateWorkspaceDto { + @ApiProperty({ description: 'نام فضای کاری', example: 'برند دیجی‌کالا' }) + @IsString() + @IsNotEmpty({ message: 'نام فضای کاری الزامی است.' }) + name: string; + + @ApiProperty({ description: 'شناسه انگلیسی یکتا (اسلاگ)', example: 'digikala-brand' }) + @IsString() + @Matches(/^[a-z0-9-]+$/, { message: 'اسلاگ باید فقط شامل حروف کوچک انگلیسی، اعداد و خط تیره (-) باشد.' }) + @IsNotEmpty({ message: 'اسلاگ الزامی است.' }) + slug: string; + + @ApiPropertyOptional({ description: 'توضیحات فضای کاری' }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/apps/api/src/modules/admin-management/dto/update-admin.dto.ts b/apps/api/src/modules/admin-management/dto/update-admin.dto.ts new file mode 100644 index 0000000..6744a3e --- /dev/null +++ b/apps/api/src/modules/admin-management/dto/update-admin.dto.ts @@ -0,0 +1,61 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { AdminStatus } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsEmail, + IsEnum, + IsOptional, + IsString, + Matches, + MinLength, + ValidateNested, +} from 'class-validator'; +import { AdminWorkspaceAssignmentDto } from './create-admin.dto'; + +export class UpdateAdminDto { + @ApiPropertyOptional({ description: 'نام و نام خانوادگی مدیر' }) + @IsString() + @IsOptional() + name?: string; + + @ApiPropertyOptional({ description: 'ایمیل سازمانی مدیر' }) + @IsEmail({}, { message: 'فرمت ایمیل نامعتبر است.' }) + @IsOptional() + email?: string; + + @ApiPropertyOptional({ description: 'شماره موبایل مدیر (۱۱ رقم با ۰۹)' }) + @Matches(/^09\d{9}$/, { message: 'شماره موبایل باید ۱۱ رقمی بوده و با ۰۹ شروع شود.' }) + @IsOptional() + mobile?: string; + + @ApiPropertyOptional({ description: 'کلمه عبور جدید (اختیاری)' }) + @IsString() + @MinLength(6, { message: 'کلمه عبور باید حداقل ۶ کاراکتر باشد.' }) + @IsOptional() + password?: string; + + @ApiPropertyOptional({ enum: AdminStatus }) + @IsEnum(AdminStatus) + @IsOptional() + status?: AdminStatus; + + @ApiPropertyOptional({ description: 'آیا این کاربر مدیر ارشد تام‌الاختیار است؟' }) + @IsBoolean() + @IsOptional() + isSuperAdmin?: boolean; + + @ApiPropertyOptional({ description: 'شناسه نقش‌های تخصیص‌یافته به مدیر', type: [String] }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + roleIds?: string[]; + + @ApiPropertyOptional({ description: 'تخصیص‌های فضاهای کاری و اسکوپ دسترسی', type: [AdminWorkspaceAssignmentDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AdminWorkspaceAssignmentDto) + @IsOptional() + workspaceAssignments?: AdminWorkspaceAssignmentDto[]; +} diff --git a/apps/api/src/modules/admin-management/dto/update-role.dto.ts b/apps/api/src/modules/admin-management/dto/update-role.dto.ts new file mode 100644 index 0000000..64711c6 --- /dev/null +++ b/apps/api/src/modules/admin-management/dto/update-role.dto.ts @@ -0,0 +1,20 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsString } from 'class-validator'; + +export class UpdateRoleDto { + @ApiPropertyOptional({ description: 'نام نمایشی نقش به فارسی' }) + @IsString() + @IsOptional() + name?: string; + + @ApiPropertyOptional({ description: 'توضیحات نقش' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ description: 'لیست شناسه‌های پرمیشن‌های منتسب به این نقش', type: [String] }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + permissionIds?: string[]; +} diff --git a/apps/api/src/modules/admin-management/dto/update-workspace.dto.ts b/apps/api/src/modules/admin-management/dto/update-workspace.dto.ts new file mode 100644 index 0000000..3ee5c02 --- /dev/null +++ b/apps/api/src/modules/admin-management/dto/update-workspace.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateWorkspaceDto { + @ApiPropertyOptional({ description: 'نام فضای کاری' }) + @IsString() + @IsOptional() + name?: string; + + @ApiPropertyOptional({ description: 'توضیحات فضای کاری' }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/apps/api/src/modules/admin-management/role-management.controller.ts b/apps/api/src/modules/admin-management/role-management.controller.ts new file mode 100644 index 0000000..efe51c9 --- /dev/null +++ b/apps/api/src/modules/admin-management/role-management.controller.ts @@ -0,0 +1,74 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; +import { AdminManagementService } from './admin-management.service'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; + +@ApiTags('Role & Permission Management (مدیریت نقش‌ها و مجوزها)') +@Controller('admin') +@UseGuards(JwtAdminGuard, PermissionsGuard) +@UseInterceptors(AuditInterceptor) +@ApiBearerAuth('AdminAuth') +export class RoleManagementController { + constructor(private readonly service: AdminManagementService) {} + + @Get('roles') + @RequirePermission('admins:read') + @ApiOperation({ summary: 'لیست تمام نقش‌های تعریف‌شده به همراه تعداد مدیران و مجوزها' }) + async findAllRoles() { + return this.service.findAllRoles(); + } + + @Get('roles/:id') + @RequirePermission('roles:manage') + @ApiOperation({ summary: 'مشاهده جزئیات نقش و شناسه‌های مجوزهای آن' }) + async findRoleById(@Param('id') id: string) { + return this.service.findRoleById(id); + } + + @Post('roles') + @RequirePermission('roles:manage') + @ApiOperation({ summary: 'ایجاد نقش جدید و تخصیص مجوزها' }) + @ApiResponse({ status: 201, description: 'نقش با موفقیت ایجاد شد' }) + async createRole(@Body() dto: CreateRoleDto) { + return this.service.createRole(dto); + } + + @Patch('roles/:id') + @RequirePermission('roles:manage') + @ApiOperation({ summary: 'ویرایش مشخصات یا مجوزهای نقش' }) + async updateRole(@Param('id') id: string, @Body() dto: UpdateRoleDto) { + return this.service.updateRole(id, dto); + } + + @Delete('roles/:id') + @RequirePermission('roles:manage') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'حذف نقش سفارشی' }) + async deleteRole(@Param('id') id: string) { + return this.service.deleteRole(id); + } + + @Get('permissions') + @RequirePermission('admins:read') + @ApiOperation({ summary: 'کاتالوگ کامل مجوزهای سیستم دسته‌بندی‌شده بر اساس ماژول' }) + async findAllPermissions() { + return this.service.findAllPermissions(); + } +} diff --git a/apps/api/src/modules/admin-management/workspace.controller.ts b/apps/api/src/modules/admin-management/workspace.controller.ts new file mode 100644 index 0000000..16df8f5 --- /dev/null +++ b/apps/api/src/modules/admin-management/workspace.controller.ts @@ -0,0 +1,49 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; +import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; +import { AdminManagementService } from './admin-management.service'; +import { CreateWorkspaceDto } from './dto/create-workspace.dto'; +import { UpdateWorkspaceDto } from './dto/update-workspace.dto'; + +@ApiTags('Workspace Management (مدیریت فضاهای کاری)') +@Controller('admin/workspaces') +@UseGuards(JwtAdminGuard, PermissionsGuard) +@UseInterceptors(AuditInterceptor) +@ApiBearerAuth('AdminAuth') +export class WorkspaceController { + constructor(private readonly service: AdminManagementService) {} + + @Get() + @RequirePermission('admins:read') + @ApiOperation({ summary: 'لیست فضاهای کاری به همراه آمار کمپین‌ها و مدیران' }) + async findAll() { + return this.service.findAllWorkspaces(); + } + + @Post() + @RequirePermission('workspaces:manage') + @ApiOperation({ summary: 'تعریف فضای کاری جدید' }) + @ApiResponse({ status: 201, description: 'فضای کاری با موفقیت ایجاد شد' }) + async create(@Body() dto: CreateWorkspaceDto) { + return this.service.createWorkspace(dto); + } + + @Patch(':id') + @RequirePermission('workspaces:manage') + @ApiOperation({ summary: 'ویرایش مشخصات فضای کاری' }) + async update(@Param('id') id: string, @Body() dto: UpdateWorkspaceDto) { + return this.service.updateWorkspace(id, dto); + } +} diff --git a/apps/api/src/modules/campaign/admin-campaign.controller.ts b/apps/api/src/modules/campaign/admin-campaign.controller.ts index b154884..e0d793a 100644 --- a/apps/api/src/modules/campaign/admin-campaign.controller.ts +++ b/apps/api/src/modules/campaign/admin-campaign.controller.ts @@ -12,6 +12,8 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; import { CampaignService } from './campaign.service'; import { CampaignFilterDto } from './dto/campaign-filter.dto'; @@ -20,13 +22,14 @@ import { UpdateCampaignDto } from './dto/update-campaign.dto'; @ApiTags('Admin Campaigns (مدیریت کمپین‌ها)') @Controller('admin/campaigns') -@UseGuards(JwtAdminGuard) +@UseGuards(JwtAdminGuard, PermissionsGuard) @UseInterceptors(AuditInterceptor) @ApiBearerAuth('AdminAuth') export class AdminCampaignController { constructor(private readonly campaignService: CampaignService) {} @Post() + @RequirePermission('campaigns:create') @ApiOperation({ summary: 'ایجاد کمپین جدید' }) @ApiResponse({ status: 201, description: 'کمپین با موفقیت ایجاد شد' }) async create(@Body() dto: CreateCampaignDto) { @@ -34,30 +37,35 @@ export class AdminCampaignController { } @Get() + @RequirePermission('campaigns:read') @ApiOperation({ summary: 'لیست کمپین‌ها با فیلتر وضعیت، جستجو و صفحه‌بندی' }) async findAll(@Query() filter: CampaignFilterDto) { return this.campaignService.findAll(filter); } @Get(':id') + @RequirePermission('campaigns:read') @ApiOperation({ summary: 'دریافت جزئیات کامل کمپین به همراه جوایز و آمار' }) async findOne(@Param('id') id: string) { return this.campaignService.findOne(id); } @Patch(':id') + @RequirePermission('campaigns:update') @ApiOperation({ summary: 'ویرایش اطلاعات و وضعیت کمپین' }) async update(@Param('id') id: string, @Body() dto: UpdateCampaignDto) { return this.campaignService.update(id, dto); } @Delete(':id') + @RequirePermission('campaigns:delete') @ApiOperation({ summary: 'حذف کمپین (در صورت عدم وجود اسپین ثبت‌شده)' }) async remove(@Param('id') id: string) { return this.campaignService.remove(id); } @Post(':id/duplicate') + @RequirePermission('campaigns:create') @ApiOperation({ summary: 'کپی‌برداری سریع از یک کمپین به همراه جوایز آن' }) async duplicate(@Param('id') id: string) { return this.campaignService.duplicate(id); diff --git a/apps/api/src/modules/chance/admin-chance.controller.ts b/apps/api/src/modules/chance/admin-chance.controller.ts index ef00236..dd4b6cc 100644 --- a/apps/api/src/modules/chance/admin-chance.controller.ts +++ b/apps/api/src/modules/chance/admin-chance.controller.ts @@ -10,6 +10,8 @@ import { import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { CurrentAdmin } from '../../common/decorators/current-admin.decorator'; import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; import { ChanceService } from './chance.service'; import { AdjustChanceDto, AdminAdjustChancePayloadDto } from './dto/adjust-chance.dto'; @@ -17,13 +19,14 @@ import { AdjustChanceDto, AdminAdjustChancePayloadDto } from './dto/adjust-chanc @ApiTags('Admin Chance Management (مدیریت شانس)') @Controller('admin') -@UseGuards(JwtAdminGuard) +@UseGuards(JwtAdminGuard, PermissionsGuard) @UseInterceptors(AuditInterceptor) @ApiBearerAuth('AdminAuth') export class AdminChanceController { constructor(private readonly chanceService: ChanceService) {} @Post('chances/adjust') + @RequirePermission('chances:adjust') @ApiOperation({ summary: 'تغییر شانس کاربر در کمپین (اندپوئینت مستقیم پنل مدیریت)' }) @ApiResponse({ status: 200, @@ -46,6 +49,7 @@ export class AdminChanceController { } @Post('users/:userId/campaigns/:campaignId/chances') + @RequirePermission('chances:adjust') @ApiOperation({ summary: 'اضافه یا کم کردن دستی شانس کاربر در یک کمپین خاص' }) @ApiResponse({ status: 200, diff --git a/apps/api/src/modules/prize/admin-prize.controller.ts b/apps/api/src/modules/prize/admin-prize.controller.ts index 2d93e1c..537897f 100644 --- a/apps/api/src/modules/prize/admin-prize.controller.ts +++ b/apps/api/src/modules/prize/admin-prize.controller.ts @@ -12,6 +12,8 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; import { CreatePrizeDto } from './dto/create-prize.dto'; import { UpdatePrizeDto } from './dto/update-prize.dto'; @@ -20,13 +22,14 @@ import { PrizeService } from './prize.service'; @ApiTags('Admin Prizes (مدیریت جوایز)') @Controller('admin') -@UseGuards(JwtAdminGuard) +@UseGuards(JwtAdminGuard, PermissionsGuard) @UseInterceptors(AuditInterceptor) @ApiBearerAuth('AdminAuth') export class AdminPrizeController { constructor(private readonly prizeService: PrizeService) {} @Post('campaigns/:campaignId/prizes') + @RequirePermission('prizes:manage') @ApiOperation({ summary: 'ایجاد جایزه جدید برای یک کمپین' }) @ApiResponse({ status: 201, description: 'جایزه با موفقیت به کمپین اضافه شد' }) async create( @@ -37,12 +40,14 @@ export class AdminPrizeController { } @Get('campaigns/:campaignId/prizes') + @RequirePermission('prizes:read') @ApiOperation({ summary: 'لیست تمام جوایز تعریف‌شده برای یک کمپین' }) async findByCampaign(@Param('campaignId') campaignId: string) { return this.prizeService.findByCampaign(campaignId); } @Get('prizes/:id') + @RequirePermission('prizes:read') @ApiOperation({ summary: 'دریافت مشخصات کامل یک جایزه' }) async findOne(@Param('id') id: string) { return this.prizeService.findOne(id); @@ -50,18 +55,21 @@ export class AdminPrizeController { @Patch('prizes/:id') @Put('prizes/:id') + @RequirePermission('prizes:manage') @ApiOperation({ summary: 'ویرایش اطلاعات و وزن جایزه' }) async update(@Param('id') id: string, @Body() dto: UpdatePrizeDto) { return this.prizeService.update(id, dto); } @Patch('prizes/:id/stock') + @RequirePermission('prizes:manage') @ApiOperation({ summary: 'شارژ یا اصلاح دستی موجودی جایزه' }) async updateStock(@Param('id') id: string, @Body() dto: UpdateStockDto) { return this.prizeService.updateStock(id, dto); } @Delete('prizes/:id') + @RequirePermission('prizes:manage') @ApiOperation({ summary: 'حذف جایزه (در صورت عدم ثبت در نتایج قرعه‌کشی)' }) async remove(@Param('id') id: string) { return this.prizeService.remove(id); diff --git a/apps/api/src/modules/report/admin-report.controller.ts b/apps/api/src/modules/report/admin-report.controller.ts index 584d0f8..4547569 100644 --- a/apps/api/src/modules/report/admin-report.controller.ts +++ b/apps/api/src/modules/report/admin-report.controller.ts @@ -2,16 +2,19 @@ import { Controller, Get, Header, Query, Res, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { ReportService } from './report.service'; @ApiTags('Admin Reports (گزارش‌گیری و داشبورد)') @Controller('admin/reports') -@UseGuards(JwtAdminGuard) +@UseGuards(JwtAdminGuard, PermissionsGuard) @ApiBearerAuth('AdminAuth') export class AdminReportController { constructor(private readonly reportService: ReportService) {} @Get('dashboard') + @RequirePermission('reports:read') @ApiOperation({ summary: 'دریافت تمام آمارهای تجمیعی داشبورد اصلی، چارت‌های روزانه و آخرین برندگان' }) @ApiResponse({ status: 200, description: 'شاخص‌های KPI داشبورد' }) async getDashboard() { @@ -19,6 +22,7 @@ export class AdminReportController { } @Get('participation') + @RequirePermission('reports:read') @ApiOperation({ summary: 'گزارش مشارکت کاربران و تحلیل شانس‌ها با فیلتر بازه زمانی' }) @ApiQuery({ name: 'from', required: false }) @ApiQuery({ name: 'to', required: false }) @@ -28,6 +32,7 @@ export class AdminReportController { } @Get('export') + @RequirePermission('reports:export') @ApiOperation({ summary: 'خروجی اکسل گزارش مشارکت' }) @Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') @Header('Content-Disposition', 'attachment; filename=participation-report.xlsx') diff --git a/apps/api/src/modules/sms/admin-sms.controller.ts b/apps/api/src/modules/sms/admin-sms.controller.ts index f3de7b2..c539b5f 100644 --- a/apps/api/src/modules/sms/admin-sms.controller.ts +++ b/apps/api/src/modules/sms/admin-sms.controller.ts @@ -9,18 +9,21 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; import { SmsService } from './sms.service'; @ApiTags('Admin SMS Logs (مدیریت پیامک‌ها)') @Controller('admin/sms-logs') -@UseGuards(JwtAdminGuard) +@UseGuards(JwtAdminGuard, PermissionsGuard) @UseInterceptors(AuditInterceptor) @ApiBearerAuth('AdminAuth') export class AdminSmsController { constructor(private readonly smsService: SmsService) {} @Get() + @RequirePermission('sms:read') @ApiOperation({ summary: 'لیست تمام پیامک‌های ارسالی با فیلتر وضعیت و موبایل' }) @ApiQuery({ name: 'page', required: false }) @ApiQuery({ name: 'pageSize', required: false }) @@ -31,6 +34,7 @@ export class AdminSmsController { } @Post(':id/resend') + @RequirePermission('sms:resend') @ApiOperation({ summary: 'ارسال مجدد پیامک ناموفق' }) @ApiResponse({ status: 200, description: 'پیامک در صف ارسال مجدد قرار گرفت' }) async resend(@Param('id') id: string) { diff --git a/apps/api/src/modules/user/admin-user.controller.ts b/apps/api/src/modules/user/admin-user.controller.ts index 36a21ee..e8f8ca4 100644 --- a/apps/api/src/modules/user/admin-user.controller.ts +++ b/apps/api/src/modules/user/admin-user.controller.ts @@ -11,18 +11,21 @@ import { import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { UserStatus } from '@prisma/client'; import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; import { UserService } from './user.service'; @ApiTags('Admin Users (مدیریت کاربران)') @Controller('admin/users') -@UseGuards(JwtAdminGuard) +@UseGuards(JwtAdminGuard, PermissionsGuard) @UseInterceptors(AuditInterceptor) @ApiBearerAuth('AdminAuth') export class AdminUserController { constructor(private readonly userService: UserService) {} @Get() + @RequirePermission('users:read') @ApiOperation({ summary: 'لیست کاربران با جستجوی شماره موبایل/نام و فیلتر وضعیت' }) @ApiQuery({ name: 'page', required: false }) @ApiQuery({ name: 'pageSize', required: false }) @@ -33,12 +36,14 @@ export class AdminUserController { } @Get(':id') + @RequirePermission('users:read') @ApiOperation({ summary: 'مشاهده پروفایل کامل، کمپین‌ها، شانس‌ها، اسپین‌ها و جوایز کاربر' }) async findOne(@Param('id') id: string) { return this.userService.findOneForAdmin(id); } @Patch(':id/block') + @RequirePermission('users:block') @ApiOperation({ summary: 'مسدودسازی یا رفع مسدودیت کاربر' }) async toggleBlock( @Param('id') id: string, diff --git a/apps/api/src/modules/winner/admin-spin.controller.ts b/apps/api/src/modules/winner/admin-spin.controller.ts index 16f72f5..fe9f8a4 100644 --- a/apps/api/src/modules/winner/admin-spin.controller.ts +++ b/apps/api/src/modules/winner/admin-spin.controller.ts @@ -10,18 +10,21 @@ import { import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; import { WinnerService } from './winner.service'; @ApiTags('Admin Spins (تاریخچه اسپین‌ها)') @Controller('admin/spins') -@UseGuards(JwtAdminGuard) +@UseGuards(JwtAdminGuard, PermissionsGuard) @UseInterceptors(AuditInterceptor) @ApiBearerAuth('AdminAuth') export class AdminSpinController { constructor(private readonly winnerService: WinnerService) {} @Get() + @RequirePermission('spins:read') @ApiOperation({ summary: 'لیست تاریخچه تمام چرخاندن‌های گردونه با فیلتر و جستجو' }) @ApiQuery({ name: 'page', required: false }) @ApiQuery({ name: 'pageSize', required: false }) @@ -33,6 +36,7 @@ export class AdminSpinController { } @Get('export') + @RequirePermission('reports:export') @ApiOperation({ summary: 'خروجی فایل اکسل از تاریخچه تمام اسپین‌ها' }) @Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') @Header('Content-Disposition', 'attachment; filename=spins.xlsx') diff --git a/apps/api/src/modules/winner/admin-winner.controller.ts b/apps/api/src/modules/winner/admin-winner.controller.ts index a0da948..a8240bc 100644 --- a/apps/api/src/modules/winner/admin-winner.controller.ts +++ b/apps/api/src/modules/winner/admin-winner.controller.ts @@ -15,19 +15,22 @@ import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@ne import { Response } from 'express'; import { CurrentAdmin } from '../../common/decorators/current-admin.decorator'; import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { PermissionsGuard } from '../../common/guards/permissions.guard'; +import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; import { UpdatePrizeStatusDto } from './dto/update-prize-status.dto'; import { WinnerService } from './winner.service'; @ApiTags('Admin Winners (مدیریت برندگان)') @Controller('admin/user-prizes') -@UseGuards(JwtAdminGuard) +@UseGuards(JwtAdminGuard, PermissionsGuard) @UseInterceptors(AuditInterceptor) @ApiBearerAuth('AdminAuth') export class AdminWinnerController { constructor(private readonly winnerService: WinnerService) {} @Get() + @RequirePermission('winners:read') @ApiOperation({ summary: 'لیست برندگان با فیلتر کمپین، وضعیت جایزه و جستجو' }) @ApiQuery({ name: 'page', required: false }) @ApiQuery({ name: 'pageSize', required: false }) @@ -39,6 +42,7 @@ export class AdminWinnerController { } @Patch(':id/status') + @RequirePermission('winners:manage') @ApiOperation({ summary: 'تغییر وضعیت جایزه برنده (تحویل شد / تماس گرفته شد / منقضی شد)' }) async updateStatus( @Param('id') id: string, @@ -51,6 +55,7 @@ export class AdminWinnerController { } @Get('export') + @RequirePermission('reports:export') @ApiOperation({ summary: 'خروجی فایل اکسل از لیست برندگان' }) @Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') @Header('Content-Disposition', 'attachment; filename=winners.xlsx') diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index d3ce5c3..4b38b35 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -13,6 +13,11 @@ export enum AdminRole { SUPPORT = 'SUPPORT', } +export enum AccessScopeType { + ALL = 'ALL', + CUSTOM_CAMPAIGNS = 'CUSTOM_CAMPAIGNS', +} + export enum UserStatus { ACTIVE = 'ACTIVE', BLOCKED = 'BLOCKED', @@ -206,4 +211,56 @@ export interface PrizeDistributionDataPoint { percentage: number; } +export interface PermissionDto { + id: string; + code: string; + module: string; + name: string; + description?: string | null; +} + +export interface RoleDto { + id: string; + code: string; + name: string; + description?: string | null; + isSystem: boolean; + permissions?: PermissionDto[]; + _count?: { + admins?: number; + }; +} + +export interface WorkspaceDto { + id: string; + name: string; + slug: string; + description?: string | null; + status: string; + createdAt: string; + _count?: { + campaigns?: number; + }; +} + +export interface AdminManagerDto { + id: string; + name: string; + mobile: string; + email: string; + status: AdminStatus; + isSuperAdmin: boolean; + lastLoginAt?: string | null; + createdAt: string; + updatedAt: string; + roles: Array<{ + role: RoleDto; + }>; + workspaceAssignments: Array<{ + workspace: WorkspaceDto; + scopeType: AccessScopeType; + specificCampaignIds?: string[] | null; + }>; +} + export * from './farsi-number';