From 4c284f9904485114a9a0df91561678cce85d97dc Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Sat, 6 Dec 2025 12:06:41 +0530 Subject: [PATCH] Modified side menu APIs --- .../Utility/SidebarMenuHelper.cs | 233 ++------------- Marco.Pms.Model/AppMenu/WebSideMenuItem.cs | 3 + .../ViewModels/AppMenu/WebMenuSectionVM.cs | 1 - .../Controllers/AppMenuController.cs | 270 ++++++++++-------- .../Controllers/TenantController.cs | 5 +- .../Helpers/CacheUpdateHelper.cs | 2 +- .../Service/PermissionServices.cs | 229 +++++++++++---- 7 files changed, 346 insertions(+), 397 deletions(-) diff --git a/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs b/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs index 9a17110..844c584 100644 --- a/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs +++ b/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs @@ -1,15 +1,14 @@ using Marco.Pms.Model.AppMenu; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using MongoDB.Bson; using MongoDB.Driver; namespace Marco.Pms.CacheHelper { public class SidebarMenuHelper { - private readonly IMongoCollection _collection; - private readonly IMongoCollection _webCollection; + private readonly IMongoCollection _oldCollection; + private readonly IMongoCollection _collection; private readonly ILogger _logger; public SidebarMenuHelper(IConfiguration configuration, ILogger logger) @@ -19,17 +18,17 @@ namespace Marco.Pms.CacheHelper var mongoUrl = new MongoUrl(connectionString); var client = new MongoClient(mongoUrl); var database = client.GetDatabase(mongoUrl.DatabaseName); - _collection = database.GetCollection("Menus"); - _webCollection = database.GetCollection("WebSideMenus"); + _oldCollection = database.GetCollection("Menus"); + _collection = database.GetCollection("WebSideMenus"); } - public async Task> GetAllWebMenuSectionsAsync(Guid tenantId) + public async Task> GetAllWebMenuSectionsAsync(Guid tenantId) { try { - var filter = Builders.Filter.Eq(e => e.TenantId, tenantId); + var filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - var result = await _webCollection + var result = await _collection .Find(filter) .ToListAsync(); if (result.Any()) @@ -38,9 +37,9 @@ namespace Marco.Pms.CacheHelper } tenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26"); - filter = Builders.Filter.Eq(e => e.TenantId, tenantId); + filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - result = await _webCollection + result = await _collection .Find(filter) .ToListAsync(); return result; @@ -48,229 +47,29 @@ namespace Marco.Pms.CacheHelper catch (Exception ex) { _logger.LogError(ex, "Error occurred while fetching Web Menu Sections."); - return new List(); + return new List(); } } - public async Task CreateWebMenuSectionAsync(WebMenuSection section) + public async Task> AddWebMenuItemAsync(List newItems) { try { - await _webCollection.InsertOneAsync(section); - return section; + await _collection.InsertManyAsync(newItems); + return newItems; } catch (Exception ex) { _logger.LogError(ex, "Error occurred while adding Web Menu Section."); - return null; + return new List(); } } - public async Task AddWebMenuItemAsync(Guid sectionId, List newItems) - { - try - { - - var filter = Builders.Filter.Eq(s => s.Id, sectionId); - - var update = Builders.Update.PushEach(s => s.Items, newItems); - - var result = await _webCollection.UpdateOneAsync(filter, update); - - if (result.ModifiedCount > 0) - { - return await _webCollection.Find(s => s.Id == sectionId).FirstOrDefaultAsync(); - } - - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adding menu item."); - return null; - } - } - public async Task CreateMenuSectionAsync(MenuSection section) - { - try - { - await _collection.InsertOneAsync(section); - return section; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while adding MenuSection."); - return null; - } - } - - public async Task UpdateMenuSectionAsync(Guid sectionId, MenuSection updatedSection) - { - try - { - var filter = Builders.Filter.Eq(s => s.Id, sectionId); - - var update = Builders.Update - .Set(s => s.Header, updatedSection.Header) - .Set(s => s.Title, updatedSection.Title) - .Set(s => s.Items, updatedSection.Items); - - var result = await _collection.UpdateOneAsync(filter, update); - - if (result.ModifiedCount > 0) - { - return await _collection.Find(s => s.Id == sectionId).FirstOrDefaultAsync(); - } - - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating MenuSection."); - return null; - } - } - - public async Task AddMenuItemAsync(Guid sectionId, MenuItem newItem) - { - try - { - newItem.Id = Guid.NewGuid(); - - var filter = Builders.Filter.Eq(s => s.Id, sectionId); - - var update = Builders.Update.Push(s => s.Items, newItem); - - var result = await _collection.UpdateOneAsync(filter, update); - - if (result.ModifiedCount > 0) - { - return await _collection.Find(s => s.Id == sectionId).FirstOrDefaultAsync(); - } - - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adding menu item."); - return null; - } - } - - public async Task UpdateMenuItemAsync(Guid sectionId, Guid itemId, MenuItem updatedItem) - { - try - { - var filter = Builders.Filter.And( - Builders.Filter.Eq(s => s.Id, sectionId), - Builders.Filter.ElemMatch(s => s.Items, i => i.Id == itemId) - ); - - var update = Builders.Update - .Set("Items.$.Text", updatedItem.Text) - .Set("Items.$.Icon", updatedItem.Icon) - .Set("Items.$.Available", updatedItem.Available) - .Set("Items.$.Link", updatedItem.Link) - .Set("Items.$.PermissionIds", updatedItem.PermissionIds); - - var result = await _collection.UpdateOneAsync(filter, update); - - if (result.ModifiedCount > 0) - { - // Re-fetch section and return the updated item - var section = await _collection.Find(s => s.Id == sectionId).FirstOrDefaultAsync(); - return section?.Items.FirstOrDefault(i => i.Id == itemId); - } - - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating MenuItem."); - return null; - } - } - - public async Task AddSubMenuItemAsync(Guid sectionId, Guid itemId, SubMenuItem newSubItem) - { - try - { - newSubItem.Id = Guid.NewGuid(); - - // Match the MenuSection and the specific MenuItem inside it - var filter = Builders.Filter.And( - Builders.Filter.Eq(s => s.Id, sectionId), - Builders.Filter.ElemMatch(s => s.Items, i => i.Id == itemId) - ); - - // Use positional operator `$` to target matched MenuItem and push into its Submenu - var update = Builders.Update.Push("Items.$.Submenu", newSubItem); - - var result = await _collection.UpdateOneAsync(filter, update); - - if (result.ModifiedCount > 0) - { - return await _collection.Find(s => s.Id == sectionId).FirstOrDefaultAsync(); - } - - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adding submenu item."); - return null; - } - } - - public async Task UpdateSubmenuItemAsync(Guid sectionId, Guid itemId, Guid subItemId, SubMenuItem updatedSub) - { - try - { - var filter = Builders.Filter.Eq(s => s.Id, sectionId); - - var arrayFilters = new List - { - new BsonDocumentArrayFilterDefinition( - new BsonDocument("item._id", itemId.ToString())), - new BsonDocumentArrayFilterDefinition( - new BsonDocument("sub._id", subItemId.ToString())) - }; - - var update = Builders.Update - .Set("Items.$[item].Submenu.$[sub].Text", updatedSub.Text) - .Set("Items.$[item].Submenu.$[sub].Available", updatedSub.Available) - .Set("Items.$[item].Submenu.$[sub].Link", updatedSub.Link) - .Set("Items.$[item].Submenu.$[sub].PermissionKeys", updatedSub.PermissionIds); - - var options = new UpdateOptions { ArrayFilters = arrayFilters }; - - var result = await _collection.UpdateOneAsync(filter, update, options); - - if (result.ModifiedCount == 0) - return null; - - var updatedSection = await _collection.Find(x => x.Id == sectionId).FirstOrDefaultAsync(); - - var subItem = updatedSection?.Items - .FirstOrDefault(i => i.Id == itemId)? - .Submenu - .FirstOrDefault(s => s.Id == subItemId); - - return subItem; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating SubMenuItem."); - return null; - } - } - - public async Task> GetAllMenuSectionsAsync(Guid tenantId) { var filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - var result = await _collection + var result = await _oldCollection .Find(filter) .ToListAsync(); if (result.Any()) @@ -281,7 +80,7 @@ namespace Marco.Pms.CacheHelper tenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26"); filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - result = await _collection + result = await _oldCollection .Find(filter) .ToListAsync(); return result; diff --git a/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs b/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs index f45bf6f..50483da 100644 --- a/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs +++ b/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs @@ -18,5 +18,8 @@ namespace Marco.Pms.Model.AppMenu [BsonRepresentation(BsonType.String)] public List PermissionIds { get; set; } = new List(); + + [BsonRepresentation(BsonType.String)] + public Guid TenantId { get; set; } } } diff --git a/Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs b/Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs index d233cf8..6c78ece 100644 --- a/Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs +++ b/Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs @@ -7,6 +7,5 @@ public string? Header { get; set; } public string? Name { get; set; } public List Items { get; set; } = new List(); - public Guid TenantId { get; set; } } } diff --git a/Marco.Pms.Services/Controllers/AppMenuController.cs b/Marco.Pms.Services/Controllers/AppMenuController.cs index f42d1f5..0e8a09f 100644 --- a/Marco.Pms.Services/Controllers/AppMenuController.cs +++ b/Marco.Pms.Services/Controllers/AppMenuController.cs @@ -53,218 +53,254 @@ namespace Marco.Pms.Services.Controllers tenantId = userHelper.GetTenantId(); } + /// + /// Returns the sidebar menu for the current tenant and logged-in employee, + /// filtered by permission and structured for the web application UI. + /// [HttpGet("get/menu")] public async Task GetAppSideBarMenuAsync() { - // Correlation ID for distributed tracing across services and logs. + // Correlation ID enables tracing this request across services and logs. var correlationId = HttpContext.TraceIdentifier; - _logger.LogInfo("GetAppSideBarMenuAsync started. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); + // Log the high-level intent and core context up front (no PII, no secrets). + _logger.LogInfo("GetAppSideBarMenuAsync started. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); try { - // Step 1: Validate tenant context + // 1. Validate tenant context if (tenantId == Guid.Empty) { - _logger.LogWarning("GetAppSideBarMenuAsync rejected: Invalid TenantId. CorrelationId: {CorrelationId}", correlationId); + _logger.LogWarning("GetAppSideBarMenuAsync rejected due to invalid tenant. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); - return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "The tenant identifier provided is invalid or missing.", 400)); + var error = ApiResponse.ErrorResponse("Invalid Tenant Identifier", "The tenant identifier is missing or invalid. Please verify the tenant context and try again.", 400); + + return BadRequest(error); } - // Step 2: Resolve current employee context for permission checks + // 2. Resolve current employee context + // - This call should throw or return a known result if the user is not authenticated. var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var employeeId = loggedInEmployee.Id; + if (loggedInEmployee is null) + { + _logger.LogWarning("GetAppSideBarMenuAsync failed: current employee not resolved. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); - // Step 3: Create scoped permission service (avoid capturing scoped services in controller) - using var scope = _serviceScopeFactory.CreateScope(); - var permissions = scope.ServiceProvider.GetRequiredService(); + var error = ApiResponse.ErrorResponse("User Context Not Found", "The current user context could not be resolved. Please re-authenticate and try again.", 403); + + return StatusCode(403, error); + } + + var employeeId = loggedInEmployee.Id; _logger.LogDebug("GetAppSideBarMenuAsync resolved employee context. EmployeeId: {EmployeeId}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", employeeId, tenantId, correlationId); - // Step 4: Fetch all menu sections for tenant (null-safe check) + // 3. Create scoped permission service + // - Avoid capturing scoped services directly in controller ctor when they depend on per-request state. + using var scope = _serviceScopeFactory.CreateScope(); + var permissionService = scope.ServiceProvider.GetRequiredService(); + + // 4. Preload all permission ids for the employee for efficient in-memory checks + var permissionIds = await permissionService.GetPermissionIdsByEmployeeId(employeeId); + + _logger.LogDebug("GetAppSideBarMenuAsync loaded {PermissionCount} permissions for EmployeeId: {EmployeeId}, CorrelationId: {CorrelationId}", + permissionIds.Count, employeeId, correlationId); + + // 5. Fetch all menu entries configured for this tenant var menus = await _sideBarMenuHelper.GetAllWebMenuSectionsAsync(tenantId); + if (menus == null || !menus.Any()) { - _logger.LogInfo("GetAppSideBarMenuAsync: No menu sections found. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + _logger.LogInfo("GetAppSideBarMenuAsync: No menu sections found for tenant. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); - return Ok(ApiResponse.SuccessResponse(new List(), "No sidebar menu sections configured for this tenant.", 200)); + + var emptyResponse = new List(); + + return Ok(ApiResponse.SuccessResponse(emptyResponse, "No sidebar menu sections are configured for the current tenant.", 200)); } - _logger.LogDebug("GetAppSideBarMenuAsync loaded {MenuSectionCount} menu sections. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + _logger.LogDebug("GetAppSideBarMenuAsync loaded {MenuSectionCount} raw menu records. TenantId: {TenantId}, CorrelationId: {CorrelationId}", menus.Count, tenantId, correlationId); - // Step 5: Filter and build menu response with permission checks - var response = new List(); + // 6. Build logical menu sections (root + children) and apply permission filtering + var responseSections = new List(); - foreach (var menuSection in menus) + // Root container section for the web UI. + var rootSection = new WebMenuSectionVM { - var sectionVM = _mapper.Map(menuSection); - if (menuSection.Items == null) + Id = Guid.Parse("4885d9f4-89b8-447d-9a95-7434b343dfda"), + Header = "Main Navigation", + Name = "Main Menu" + }; + + // To avoid multiple enumerations and improve readability, materialize once. + var menusList = menus.ToList(); + + foreach (var menu in menusList) + { + // Skip any non-root menu entry; these will be attached as children. + if (menu.ParentMenuId.HasValue) { - _logger.LogDebug("Skipping menu section with null items. SectionId: {SectionId}, TenantId: {TenantId}", menuSection.Id, tenantId); continue; } + var itemVm = _mapper.Map(menu); - foreach (var menuItem in menuSection.Items) + // If the menu requires any permission, check now. + if (menu.PermissionIds.Any()) { - // Skip items without permission check if required - if (menuItem.PermissionIds.Any()) + var hasMenuPermission = permissionService.HasPermissionAny(permissionIds, menu.PermissionIds, employeeId); + + if (!hasMenuPermission) { - var hasPermission = await permissions.HasPermissionAny(menuItem.PermissionIds, employeeId); - if (!hasPermission) + _logger.LogDebug("Menu item skipped due to insufficient permissions. MenuId: {MenuId}, EmployeeId: {EmployeeId}, CorrelationId: {CorrelationId}", + menu.Id, employeeId, correlationId); + continue; + } + } + + // Resolve submenus only for eligible root menu entries. + var subMenus = menusList + .Where(m => m.ParentMenuId.HasValue && m.ParentMenuId == menu.Id) + .ToList(); + + foreach (var subMenu in subMenus) + { + var subMenuVm = _mapper.Map(subMenu); + + // If the submenu requires permissions, validate before adding. + if (subMenu.PermissionIds.Any()) + { + var hasSubPermission = permissionService.HasPermissionAny(permissionIds, subMenu.PermissionIds, employeeId); + + if (!hasSubPermission) { - _logger.LogDebug("Menu item access denied due to missing permissions. ItemId: {ItemId}, EmployeeId: {EmployeeId}, TenantId: {TenantId}", - menuItem.Id, employeeId, tenantId); + _logger.LogDebug( + "Submenu item skipped due to insufficient permissions. MenuId: {MenuId}, ParentMenuId: {ParentMenuId}, EmployeeId: {EmployeeId}, CorrelationId: {CorrelationId}", + subMenu.Id, subMenu.ParentMenuId!, employeeId, correlationId); continue; } } - var itemVM = _mapper.Map(menuItem); - if (menuItem.ParentMenuId.HasValue) - { - sectionVM.Items.Where(i => i.Id == menuItem.ParentMenuId.Value).FirstOrDefault()?.Submenu.Add(itemVM); - } - else - { - sectionVM.Items.Add(itemVM); - } - } - - // Only include sections with at least one accessible item - if (sectionVM.Items.Any()) - { - response.Add(sectionVM); + // Add the submenu to the root section + itemVm.Submenu.Add(subMenuVm); } + // Add the root menu item + rootSection.Items.Add(itemVm); } - _logger.LogInfo("GetAppSideBarMenuAsync completed successfully. TenantId: {TenantId}, EmployeeId: {EmployeeId}, OriginalSections: {OriginalCount}, FilteredSections: {FilteredCount}, CorrelationId: {CorrelationId}", - tenantId, employeeId, menus.Count, response.Count, correlationId); + if (rootSection.Items.Any()) + { + responseSections.Add(rootSection); + } - return Ok(ApiResponse.SuccessResponse(response, $"Sidebar menu fetched successfully. {response.Count} sections returned.", 200)); + _logger.LogInfo( + "GetAppSideBarMenuAsync completed successfully. TenantId: {TenantId}, EmployeeId: {EmployeeId}, OriginalMenuCount: {OriginalCount}, ReturnedSectionCount: {SectionCount}, CorrelationId: {CorrelationId}", + tenantId, employeeId, menusList.Count, responseSections.Count, correlationId); + + return Ok(ApiResponse.SuccessResponse(responseSections, + responseSections.Any() + ? $"Sidebar menu fetched successfully. {responseSections.Count} section(s) returned." + : "No accessible sidebar menu items were found for the current user.", 200)); } catch (OperationCanceledException) { - _logger.LogWarning("GetAppSideBarMenuAsync cancelled. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + // This typically indicates client disconnected or explicit cancellation. + _logger.LogWarning("GetAppSideBarMenuAsync was cancelled. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); - return StatusCode(499, ApiResponse.ErrorResponse("Request Cancelled", "The request was cancelled by the client.", 499)); + var error = ApiResponse.ErrorResponse("Request Cancelled", "The operation was cancelled, likely due to a client disconnection or explicit cancellation request.", 499); // Non-standard but commonly used in APIs for client closed request. + + return StatusCode(499, error); } catch (UnauthorizedAccessException ex) { - _logger.LogError(ex, "GetAppSideBarMenuAsync authorization failed. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + // Handle authorization-related errors explicitly to avoid leaking details. + _logger.LogError(ex, "GetAppSideBarMenuAsync authorization failure. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); - return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied", "Insufficient permissions to access menu sections.", 403))); + var error = ApiResponse.ErrorResponse("Access Denied", "You do not have sufficient permissions to access the sidebar menu.", 403); + + return StatusCode(403, error); } catch (Exception ex) { - _logger.LogError(ex, "Unexpected error in GetAppSideBarMenuAsync. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + // Fallback handler for unexpected failures. Keep log detailed, response generic. + _logger.LogError(ex, "Unhandled exception in GetAppSideBarMenuAsync. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); - return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred while fetching the sidebar menu.", 500)); + var error = ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred while processing the sidebar menu request. Please try again later.", 500); + + return StatusCode(500, error); } } - [HttpPost("add/sidebar/menu-section")] - public async Task CreateAppSideBarMenuAsync([FromBody] CreateWebMenuSectionDto model) + + [HttpPost("add/side-menu")] + public async Task AddMenuItemAsync([FromBody] List model) { - // Step 1: Fetch logged-in user + // Step 1: Validate tenant context + if (tenantId == Guid.Empty) + { + _logger.LogWarning("GetAppSideBarMenuAsync rejected: Invalid TenantId."); + + return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "The tenant identifier provided is invalid or missing.", 400)); + } + + // Step 2: Fetch logged-in user var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var isRootUser = loggedInEmployee.ApplicationUser?.IsRootUser ?? false; - // Step 2: Authorization check - if (!isRootUser || tenantId != superTenantId) - { - _logger.LogWarning("Access denied: Employee {EmployeeId} attempted to create sidebar menu in Tenant {TenantId}", loggedInEmployee.Id, tenantId); - return StatusCode(403, ApiResponse.ErrorResponse("Access Denied", "User does not have permission.", 403)); - } - - // Step 3: Map DTO to entity - var sideMenuSection = _mapper.Map(model); - sideMenuSection.TenantId = tenantId; - - try - { - // Step 4: Save entity using helper - sideMenuSection = await _sideBarMenuHelper.CreateWebMenuSectionAsync(sideMenuSection); - - if (sideMenuSection == null) - { - _logger.LogWarning("Failed to create sidebar menu section. Tenant: {TenantId}, Request: {@MenuSectionDto}", tenantId, model); - return BadRequest(ApiResponse.ErrorResponse("Invalid MenuSection", 400)); - } - - // Step 5: Log success - _logger.LogInfo("Sidebar menu created successfully. SectionId: {SectionId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", - sideMenuSection.Id, tenantId, loggedInEmployee.Id); - - return Ok(ApiResponse.SuccessResponse(sideMenuSection, "Sidebar menu created successfully.", 201)); - } - catch (Exception ex) - { - // Step 6: Handle and log unexpected server errors - _logger.LogError(ex, "Unexpected error occurred while creating sidebar menu. Tenant: {TenantId}, EmployeeId: {EmployeeId}, Request: {@MenuSectionDto}", - tenantId, loggedInEmployee.Id, model); - - return StatusCode(500, ApiResponse.ErrorResponse("Server Error", "An unexpected error occurred.", 500)); - } - } - - [HttpPost("add/sidebar/section/{sectionId}/items")] - public async Task AddMenuItemAsync(Guid sectionId, [FromBody] List model) - { - // Step 1: Fetch logged-in user - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var isRootUser = loggedInEmployee.ApplicationUser?.IsRootUser ?? false; - - // Step 2: Authorization check + // Step 3: Authorization check if (!isRootUser && tenantId != superTenantId) { - _logger.LogWarning("Access denied: User {UserId} attempted to add menu item to section {SectionId} in Tenant {TenantId}", - loggedInEmployee.Id, sectionId, tenantId); + _logger.LogWarning("Access denied: User {UserId} attempted to add menu item in Tenant {TenantId}", + loggedInEmployee.Id, tenantId); return StatusCode(403, ApiResponse.ErrorResponse("Access Denied", "User does not have permission.", 403)); } - // Step 3: Input validation - if (sectionId == Guid.Empty || model == null) + // Step 4: Input validation + if (model == null) { - _logger.LogWarning("Invalid AddMenuItem request. Tenant: {TenantId}, SectionId: {SectionId}, UserId: {UserId}", - tenantId, sectionId, loggedInEmployee.Id); + _logger.LogWarning("Invalid AddMenuItem request. Tenant: {TenantId}, UserId: {UserId}", + tenantId, loggedInEmployee.Id); return BadRequest(ApiResponse.ErrorResponse("Invalid section ID or menu item payload.", 400)); } try { - // Step 4: Map DTO to entity + // Step 5: Map DTO to entity var menuItemEntity = _mapper.Map>(model); - // Step 5: Perform Add operation - var result = await _sideBarMenuHelper.AddWebMenuItemAsync(sectionId, menuItemEntity); + menuItemEntity.ForEach(m => m.TenantId = tenantId); + + // Step 6: Perform Add operation + var result = await _sideBarMenuHelper.AddWebMenuItemAsync(menuItemEntity); if (result == null) { - _logger.LogWarning("Menu section not found. Unable to add menu item. SectionId: {SectionId}, TenantId: {TenantId}, UserId: {UserId}", - sectionId, tenantId, loggedInEmployee.Id); + _logger.LogWarning("Menu section not found. Unable to add menu item. TenantId: {TenantId}, UserId: {UserId}", tenantId, loggedInEmployee.Id); return NotFound(ApiResponse.ErrorResponse("Menu section not found", 404)); } - // Step 6: Successful addition - _logger.LogInfo("Menu item added successfully. SectionId: {SectionId}, MenuItemId: {MenuItemId}, TenantId: {TenantId}, UserId: {UserId}", - sectionId, result.Id, tenantId, loggedInEmployee.Id); + // Step 7: Successful addition + _logger.LogInfo("Menu items added successfully TenantId: {TenantId}, UserId: {UserId}", + tenantId, loggedInEmployee.Id); return Ok(ApiResponse.SuccessResponse(result, "Menu item added successfully")); } catch (Exception ex) { - // Step 7: Handle unexpected errors - _logger.LogError(ex, "Error occurred while adding menu item. SectionId: {SectionId}, TenantId: {TenantId}, UserId: {UserId}, Payload: {@NewItemDto}", - sectionId, tenantId, loggedInEmployee.Id, model); + // Step 8: Handle unexpected errors + _logger.LogError(ex, "Error occurred while adding menu item. TenantId: {TenantId}, UserId: {UserId}, Payload: {@NewItemDto}", + tenantId, loggedInEmployee.Id, model); return StatusCode(500, ApiResponse.ErrorResponse("Server error", "An unexpected error occurred while adding the menu item.", 500)); } diff --git a/Marco.Pms.Services/Controllers/TenantController.cs b/Marco.Pms.Services/Controllers/TenantController.cs index 6f8f130..899e4a5 100644 --- a/Marco.Pms.Services/Controllers/TenantController.cs +++ b/Marco.Pms.Services/Controllers/TenantController.cs @@ -384,7 +384,10 @@ namespace Marco.Pms.Services.Controllers response.CreatedBy = createdBy; response.CurrentPlan = _mapper.Map(currentPlan); - response.CurrentPlan.PaymentDetail = paymentsDetails.FirstOrDefault(pd => currentPlan != null && pd.Id == currentPlan.PaymentDetailId); + if (currentPlan != null) + { + response.CurrentPlan.PaymentDetail = paymentsDetails.FirstOrDefault(pd => pd.Id == currentPlan.PaymentDetailId); + } response.CurrentPlanFeatures = await _featureDetailsHelper.GetFeatureDetails(currentPlan?.Plan?.FeaturesId ?? Guid.Empty); // Map subscription history plans to DTO diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index 4dd5377..a37ab63 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -809,7 +809,7 @@ namespace Marco.Pms.Services.Helpers } Task> getPermissionIdsTask = Task.Run(async () => { - using var context = _dbContextFactory.CreateDbContext(); + await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.RolePermissionMappings .Where(rp => roleIds.Contains(rp.ApplicationRoleId)) diff --git a/Marco.Pms.Services/Service/PermissionServices.cs b/Marco.Pms.Services/Service/PermissionServices.cs index be759dc..cbe5dd2 100644 --- a/Marco.Pms.Services/Service/PermissionServices.cs +++ b/Marco.Pms.Services/Service/PermissionServices.cs @@ -4,6 +4,7 @@ using Marco.Pms.Model.Entitlements; using Marco.Pms.Services.Helpers; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; +using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; namespace Marco.Pms.Services.Service @@ -11,15 +12,13 @@ namespace Marco.Pms.Services.Service public class PermissionServices { private readonly ApplicationDbContext _context; - private readonly RolesHelper _rolesHelper; private readonly CacheUpdateHelper _cache; private readonly ILoggingService _logger; private readonly Guid tenantId; - public PermissionServices(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache, ILoggingService logger, UserHelper userHelper) + public PermissionServices(ApplicationDbContext context, CacheUpdateHelper cache, ILoggingService logger, UserHelper userHelper) { _context = context; - _rolesHelper = rolesHelper; _cache = cache; _logger = logger; tenantId = userHelper.GetTenantId(); @@ -34,72 +33,23 @@ namespace Marco.Pms.Services.Service /// True if the user has the permission, otherwise false. public async Task HasPermission(Guid featurePermissionId, Guid employeeId, Guid? projectId = null) { - // 1. Try fetching permissions from cache (fast-path lookup). - var featurePermissionIds = await _cache.GetPermissions(employeeId, tenantId); + var featurePermissionIds = await GetPermissionIdsByEmployeeId(employeeId, projectId); - // If not found in cache, fallback to database (slower). - if (featurePermissionIds == null) - { - var featurePermissions = await _rolesHelper.GetFeaturePermissionByEmployeeId(employeeId, tenantId); - featurePermissionIds = featurePermissions.Select(fp => fp.Id).ToList(); - } - - // 2. Handle project-level permission overrides if a project is specified. - if (projectId.HasValue) - { - // Fetch permissions explicitly assigned to this employee in the project. - var projectLevelPermissionIds = await _context.ProjectLevelPermissionMappings - .AsNoTracking() - .Where(pl => pl.ProjectId == projectId.Value && pl.EmployeeId == employeeId) - .Select(pl => pl.PermissionId) - .ToListAsync(); - - if (projectLevelPermissionIds?.Any() ?? false) - { - - // Define modules where project-level overrides apply. - var projectLevelModuleIds = new HashSet - { - Guid.Parse("53176ebf-c75d-42e5-839f-4508ffac3def"), - Guid.Parse("9d4b5489-2079-40b9-bd77-6e1bf90bc19f"), - Guid.Parse("52c9cf54-1eb2-44d2-81bb-524cf29c0a94"), - Guid.Parse("a8cf4331-8f04-4961-8360-a3f7c3cc7462") - }; - - // Get all feature permissions under those modules where the user didn't have explicit project-level grants. - var allOverriddenPermissions = await _context.FeaturePermissions - .AsNoTracking() - .Where(fp => projectLevelModuleIds.Contains(fp.FeatureId) && - !projectLevelPermissionIds.Contains(fp.Id)) - .Select(fp => fp.Id) - .ToListAsync(); - - // Apply overrides: - // - Remove global permissions overridden by project-level rules. - // - Add explicit project-level permissions. - featurePermissionIds = featurePermissionIds - .Except(allOverriddenPermissions) // Remove overridden - .Concat(projectLevelPermissionIds) // Add project-level - .Distinct() // Ensure no duplicates - .ToList(); - } - } - - // 3. Final check: does the employee have the requested permission? return featurePermissionIds.Contains(featurePermissionId); } public async Task HasPermissionAny(List featurePermissionIds, Guid employeeId) { - var allFeaturePermissionIds = await _cache.GetPermissions(employeeId, tenantId); - if (allFeaturePermissionIds == null) - { - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeId(employeeId, tenantId); - allFeaturePermissionIds = featurePermission.Select(fp => fp.Id).ToList(); - } + var allFeaturePermissionIds = await GetPermissionIdsByEmployeeId(employeeId); + var hasPermission = featurePermissionIds.Any(f => allFeaturePermissionIds.Contains(f)); return hasPermission; } + public bool HasPermissionAny(List realPermissionIds, List toCheckPermissionIds, Guid employeeId) + { + var hasPermission = toCheckPermissionIds.Any(f => realPermissionIds.Contains(f)); + return hasPermission; + } public async Task HasProjectPermission(Employee LoggedInEmployee, Guid projectId) { var employeeId = LoggedInEmployee.Id; @@ -199,5 +149,164 @@ namespace Marco.Pms.Services.Service return false; } } + + /// + /// Retrieves permission IDs for an employee, supporting both global role-based permissions + /// and project-specific overrides with cache-first strategy. + /// + /// The ID of the employee to fetch permissions for. + /// Optional project ID for project-level permission overrides. + /// List of unique permission IDs the employee has access to. + /// Thrown when employeeId or tenantId is empty. + public async Task> GetPermissionIdsByEmployeeId(Guid employeeId, Guid? projectId = null) + { + // Input validation + if (employeeId == Guid.Empty) + { + _logger.LogWarning("EmployeeId cannot be empty."); + return new List(); + } + + if (tenantId == Guid.Empty) + { + _logger.LogWarning("TenantId cannot be empty."); + return new List(); + } + _logger.LogDebug( + "GetPermissionIdsByEmployeeId started. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}, TenantId: {TenantId}", + employeeId, projectId ?? Guid.Empty, tenantId); + + try + { + // Phase 1: Cache-first lookup for role-based permissions (fast path) + var featurePermissionIds = await _cache.GetPermissions(employeeId, tenantId); + var permissionsFromCache = featurePermissionIds != null; + + _logger.LogDebug( + "Permission lookup from cache: {CacheHit}, InitialPermissions: {PermissionCount}, EmployeeId: {EmployeeId}", + permissionsFromCache, featurePermissionIds?.Count ?? 0, employeeId); + + // Phase 2: Database fallback if cache miss + if (featurePermissionIds == null) + { + _logger.LogDebug( + "Cache miss detected, falling back to database lookup. EmployeeId: {EmployeeId}, TenantId: {TenantId}", + employeeId, tenantId); + + var roleIds = await _context.EmployeeRoleMappings + .Where(erm => erm.EmployeeId == employeeId && erm.TenantId == tenantId) + .Select(erm => erm.RoleId) + .ToListAsync(); + + if (!roleIds.Any()) + { + _logger.LogDebug( + "No roles found for employee. EmployeeId: {EmployeeId}, TenantId: {TenantId}", + employeeId, tenantId); + return new List(); + } + + featurePermissionIds = await _context.RolePermissionMappings + .Where(rpm => roleIds.Contains(rpm.ApplicationRoleId)) + .Select(rpm => rpm.FeaturePermissionId) + .Distinct() + .ToListAsync(); + + // The cache service might also need its own context, or you can pass the data directly. + // Assuming AddApplicationRole takes the data, not a context. + await _cache.AddApplicationRole(employeeId, roleIds, tenantId); + _logger.LogInfo("Successfully queued cache update for EmployeeId: {EmployeeId}", employeeId); + + _logger.LogDebug( + "Loaded {RoleCount} roles → {PermissionCount} permissions from database. EmployeeId: {EmployeeId}", + roleIds.Count, featurePermissionIds.Count, employeeId); + } + + // Early return for global permissions (no project context) + if (!projectId.HasValue) + { + _logger.LogDebug( + "Returning global permissions. Count: {PermissionCount}, EmployeeId: {EmployeeId}", + featurePermissionIds.Count, employeeId); + return featurePermissionIds; + } + + // Phase 3: Apply project-level permission overrides + _logger.LogDebug( + "Applying project-level overrides. ProjectId: {ProjectId}, EmployeeId: {EmployeeId}", + projectId.Value, employeeId); + + var projectLevelPermissionIds = await _context.ProjectLevelPermissionMappings + .AsNoTracking() + .Where(pl => pl.ProjectId == projectId.Value && + pl.EmployeeId == employeeId) + .Select(pl => pl.PermissionId) + .Distinct() + .ToListAsync(); + + if (!projectLevelPermissionIds.Any()) + { + _logger.LogDebug( + "No project-level permissions found. ProjectId: {ProjectId}, EmployeeId: {EmployeeId}", + projectId.Value, employeeId); + return featurePermissionIds; + } + + // Phase 4: Override logic for specific project modules + var projectOverrideModules = new HashSet + { + // Hard-coded module IDs for project-level override scope + // TODO: Consider moving to configuration or database lookup + Guid.Parse("53176ebf-c75d-42e5-839f-4508ffac3def"), // Module: Projects + Guid.Parse("9d4b5489-2079-40b9-bd77-6e1bf90bc19f"), // Module: Expenses + Guid.Parse("52c9cf54-1eb2-44d2-81bb-524cf29c0a94"), // Module: Invoices + Guid.Parse("a8cf4331-8f04-4961-8360-a3f7c3cc7462") // Module: Documents + }; + + // Find permissions in override modules that employee lacks at project level + var overriddenPermissions = await _context.FeaturePermissions + .AsNoTracking() + .Where(fp => projectOverrideModules.Contains(fp.FeatureId) && + !projectLevelPermissionIds.Contains(fp.Id)) + .Select(fp => fp.Id) + .ToListAsync(); + + // Apply override rules: + // 1. Remove global permissions overridden by project context + // 2. Add explicit project-level grants + // 3. Ensure uniqueness + var finalPermissions = featurePermissionIds + .Except(overriddenPermissions) // Remove overridden global perms + .Concat(projectLevelPermissionIds) // Add project-specific grants + .Distinct() // Deduplicate + .ToList(); + + _logger.LogDebug( + "Project override applied. Before: {BeforeCount}, After: {AfterCount}, Added: {AddedCount}, Removed: {RemovedCount}, EmployeeId: {EmployeeId}", + featurePermissionIds.Count, finalPermissions.Count, + projectLevelPermissionIds.Count, overriddenPermissions.Count, employeeId); + + return finalPermissions; + } + catch (OperationCanceledException) + { + _logger.LogWarning("GetPermissionIdsByEmployeeId cancelled. EmployeeId: {EmployeeId}", employeeId); + + return new List(); + } + catch (DbUpdateException ex) + { + _logger.LogError(ex, "Database error in GetPermissionIdsByEmployeeId. EmployeeId: {EmployeeId}, TenantId: {TenantId}", employeeId, tenantId); + + return new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error in GetPermissionIdsByEmployeeId. EmployeeId: {EmployeeId}, TenantId: {TenantId}", employeeId, tenantId); + + return new List(); + } + } + } }