diff --git a/app/Http/Controllers/Api/Admin/GlobalMenuController.php b/app/Http/Controllers/Api/Admin/GlobalMenuController.php index f23da385..101b0495 100644 --- a/app/Http/Controllers/Api/Admin/GlobalMenuController.php +++ b/app/Http/Controllers/Api/Admin/GlobalMenuController.php @@ -165,30 +165,39 @@ public function restore(Request $request, int $id): JsonResponse /** * 글로벌 메뉴 영구 삭제 + * - 연관 권한도 함께 삭제 + * - 참조하는 테넌트 메뉴의 global_menu_id 해제 + * - 삭제 정보는 archived_records에 저장 */ public function forceDestroy(Request $request, int $id): JsonResponse { try { $result = $this->menuService->forceDeleteGlobalMenu($id); - if (! $result) { + if (! $result['success']) { return response()->json([ 'success' => false, - 'message' => '글로벌 메뉴를 찾을 수 없거나 자식 메뉴가 있어 영구 삭제할 수 없습니다.', - ], 404); + 'message' => $result['message'], + ], 400); } if ($request->header('HX-Request')) { return response()->json([ 'success' => true, - 'message' => '글로벌 메뉴가 영구 삭제되었습니다.', + 'message' => $result['message'], 'action' => 'refresh', + 'deleted_permissions' => $result['deleted_permissions'], + 'referencing_menus_unlinked' => $result['referencing_menus_unlinked'] ?? 0, + 'batch_id' => $result['batch_id'] ?? null, ]); } return response()->json([ 'success' => true, - 'message' => '글로벌 메뉴가 영구 삭제되었습니다.', + 'message' => $result['message'], + 'deleted_permissions' => $result['deleted_permissions'], + 'referencing_menus_unlinked' => $result['referencing_menus_unlinked'] ?? 0, + 'batch_id' => $result['batch_id'] ?? null, ]); } catch (\Exception $e) { return response()->json([ diff --git a/app/Http/Controllers/Api/Admin/MenuController.php b/app/Http/Controllers/Api/Admin/MenuController.php index 38aa2f46..94038e2f 100644 --- a/app/Http/Controllers/Api/Admin/MenuController.php +++ b/app/Http/Controllers/Api/Admin/MenuController.php @@ -197,6 +197,8 @@ public function restore(Request $request, int $id): JsonResponse /** * 메뉴 영구 삭제 (슈퍼관리자 전용) + * - 연관 권한도 함께 삭제 + * - 삭제 정보는 archived_records에 저장 */ public function forceDestroy(Request $request, int $id): JsonResponse { @@ -211,25 +213,29 @@ public function forceDestroy(Request $request, int $id): JsonResponse try { $result = $this->menuService->forceDeleteMenu($id); - if (! $result) { + if (! $result['success']) { return response()->json([ 'success' => false, - 'message' => '메뉴를 찾을 수 없거나 자식 메뉴가 있어 영구 삭제할 수 없습니다.', - ], 404); + 'message' => $result['message'], + ], 400); } // HTMX 요청 시 테이블 새로고침 트리거 if ($request->header('HX-Request')) { return response()->json([ 'success' => true, - 'message' => '메뉴가 영구 삭제되었습니다.', + 'message' => $result['message'], 'action' => 'refresh', + 'deleted_permissions' => $result['deleted_permissions'], + 'batch_id' => $result['batch_id'] ?? null, ]); } return response()->json([ 'success' => true, - 'message' => '메뉴가 영구 삭제되었습니다.', + 'message' => $result['message'], + 'deleted_permissions' => $result['deleted_permissions'], + 'batch_id' => $result['batch_id'] ?? null, ]); } catch (\Exception $e) { return response()->json([ diff --git a/app/Services/MenuService.php b/app/Services/MenuService.php index 7f8d2aa3..3868399c 100644 --- a/app/Services/MenuService.php +++ b/app/Services/MenuService.php @@ -2,12 +2,16 @@ namespace App\Services; +use App\Models\Archives\ArchivedRecord; +use App\Models\Archives\ArchivedRecordRelation; use App\Models\Commons\GlobalMenu; use App\Models\Commons\Menu; +use App\Models\Permission; use App\Models\Tenants\Tenant; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; class MenuService { @@ -280,17 +284,91 @@ public function restoreMenu(int $id): bool /** * 메뉴 영구 삭제 (슈퍼관리자 전용) + * - 연관 권한(permissions)도 함께 삭제 + * - role_has_permissions, model_has_permissions는 FK CASCADE로 자동 삭제 + * - 삭제 정보를 archived_records에 저장 + * + * @return array{success: bool, message: string, deleted_permissions: array} */ - public function forceDeleteMenu(int $id): bool + public function forceDeleteMenu(int $id): array { $menu = Menu::withTrashed()->findOrFail($id); // 자식 메뉴가 있는 경우 영구 삭제 불가 if ($menu->children()->withTrashed()->count() > 0) { - return false; + return [ + 'success' => false, + 'message' => '자식 메뉴가 있어 삭제할 수 없습니다.', + 'deleted_permissions' => [], + ]; } - return $menu->forceDelete(); + return DB::transaction(function () use ($menu) { + // 연관 권한 조회 (삭제 전 기록용) + $permissions = Permission::where('name', 'like', "menu:{$menu->id}.%")->get(); + $permissionData = $permissions->map(fn ($p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'guard_name' => $p->guard_name, + 'tenant_id' => $p->tenant_id, + ])->toArray(); + + // 역할-권한 연결 정보 조회 + $rolePermissions = DB::table('role_has_permissions') + ->join('roles', 'role_has_permissions.role_id', '=', 'roles.id') + ->whereIn('role_has_permissions.permission_id', $permissions->pluck('id')) + ->select('roles.id as role_id', 'roles.name as role_name', 'role_has_permissions.permission_id') + ->get() + ->toArray(); + + // 아카이브 레코드 생성 + $batchId = 'menu_delete_'.Str::uuid(); + $archivedRecord = ArchivedRecord::create([ + 'batch_id' => $batchId, + 'batch_description' => "메뉴 삭제: {$menu->name} (ID: {$menu->id})", + 'record_type' => 'menu', + 'tenant_id' => $menu->tenant_id, + 'original_id' => $menu->id, + 'main_data' => $menu->toArray(), + 'schema_version' => '1.0', + 'deleted_by' => auth()->id(), + 'deleted_at' => now(), + 'notes' => "메뉴 영구 삭제 - 연관 권한 {$permissions->count()}개 함께 삭제", + ]); + + // 연관 권한 정보 저장 + if ($permissions->isNotEmpty()) { + ArchivedRecordRelation::create([ + 'archived_record_id' => $archivedRecord->id, + 'table_name' => 'permissions', + 'data' => $permissionData, + 'record_count' => $permissions->count(), + ]); + } + + // 역할-권한 연결 정보 저장 + if (! empty($rolePermissions)) { + ArchivedRecordRelation::create([ + 'archived_record_id' => $archivedRecord->id, + 'table_name' => 'role_has_permissions', + 'data' => $rolePermissions, + 'record_count' => count($rolePermissions), + ]); + } + + // 연관 권한 삭제 (FK CASCADE로 role_has_permissions, model_has_permissions 자동 삭제) + Permission::where('name', 'like', "menu:{$menu->id}.%")->delete(); + + // 메뉴 영구 삭제 + $menu->forceDelete(); + + return [ + 'success' => true, + 'message' => "메뉴 '{$menu->name}'와 연관 권한 {$permissions->count()}개가 삭제되었습니다.", + 'deleted_permissions' => $permissionData, + 'batch_id' => $batchId, + ]; + }); } /** @@ -598,17 +676,113 @@ public function restoreGlobalMenu(int $id): bool /** * 글로벌 메뉴 영구 삭제 + * - 연관 권한(permissions)도 함께 삭제 + * - 이 글로벌 메뉴를 참조하는 테넌트 메뉴들의 global_menu_id도 null 처리 + * - 삭제 정보를 archived_records에 저장 + * + * @return array{success: bool, message: string, deleted_permissions: array} */ - public function forceDeleteGlobalMenu(int $id): bool + public function forceDeleteGlobalMenu(int $id): array { $menu = GlobalMenu::withTrashed()->findOrFail($id); // 자식 메뉴가 있는 경우 영구 삭제 불가 if ($menu->children()->withTrashed()->count() > 0) { - return false; + return [ + 'success' => false, + 'message' => '자식 메뉴가 있어 삭제할 수 없습니다.', + 'deleted_permissions' => [], + ]; } - return $menu->forceDelete(); + return DB::transaction(function () use ($menu) { + // 연관 권한 조회 (삭제 전 기록용) + $permissions = Permission::where('name', 'like', "global_menu:{$menu->id}.%")->get(); + $permissionData = $permissions->map(fn ($p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'guard_name' => $p->guard_name, + 'tenant_id' => $p->tenant_id, + ])->toArray(); + + // 역할-권한 연결 정보 조회 + $rolePermissions = DB::table('role_has_permissions') + ->join('roles', 'role_has_permissions.role_id', '=', 'roles.id') + ->whereIn('role_has_permissions.permission_id', $permissions->pluck('id')) + ->select('roles.id as role_id', 'roles.name as role_name', 'role_has_permissions.permission_id') + ->get() + ->toArray(); + + // 참조하는 테넌트 메뉴 조회 + $referencingMenus = Menu::withTrashed() + ->where('global_menu_id', $menu->id) + ->get(['id', 'tenant_id', 'name']) + ->toArray(); + + // 아카이브 레코드 생성 + $batchId = 'global_menu_delete_'.Str::uuid(); + $archivedRecord = ArchivedRecord::create([ + 'batch_id' => $batchId, + 'batch_description' => "글로벌 메뉴 삭제: {$menu->name} (ID: {$menu->id})", + 'record_type' => 'global_menu', + 'tenant_id' => null, + 'original_id' => $menu->id, + 'main_data' => $menu->toArray(), + 'schema_version' => '1.0', + 'deleted_by' => auth()->id(), + 'deleted_at' => now(), + 'notes' => "글로벌 메뉴 영구 삭제 - 연관 권한 {$permissions->count()}개, 참조 테넌트 메뉴 ".count($referencingMenus).'개 해제', + ]); + + // 연관 권한 정보 저장 + if ($permissions->isNotEmpty()) { + ArchivedRecordRelation::create([ + 'archived_record_id' => $archivedRecord->id, + 'table_name' => 'permissions', + 'data' => $permissionData, + 'record_count' => $permissions->count(), + ]); + } + + // 역할-권한 연결 정보 저장 + if (! empty($rolePermissions)) { + ArchivedRecordRelation::create([ + 'archived_record_id' => $archivedRecord->id, + 'table_name' => 'role_has_permissions', + 'data' => $rolePermissions, + 'record_count' => count($rolePermissions), + ]); + } + + // 참조 테넌트 메뉴 정보 저장 + if (! empty($referencingMenus)) { + ArchivedRecordRelation::create([ + 'archived_record_id' => $archivedRecord->id, + 'table_name' => 'menus (referencing)', + 'data' => $referencingMenus, + 'record_count' => count($referencingMenus), + ]); + } + + // 연관 권한 삭제 + Permission::where('name', 'like', "global_menu:{$menu->id}.%")->delete(); + + // 이 글로벌 메뉴를 참조하는 테넌트 메뉴들의 참조 해제 + Menu::withTrashed() + ->where('global_menu_id', $menu->id) + ->update(['global_menu_id' => null, 'is_customized' => true]); + + // 글로벌 메뉴 영구 삭제 + $menu->forceDelete(); + + return [ + 'success' => true, + 'message' => "글로벌 메뉴 '{$menu->name}'와 연관 권한 {$permissions->count()}개가 삭제되었습니다.", + 'deleted_permissions' => $permissionData, + 'referencing_menus_unlinked' => count($referencingMenus), + 'batch_id' => $batchId, + ]; + }); } /**