diff --git a/Marco.Pms.DataAccess/Data/ApplicationDbContext.cs b/Marco.Pms.DataAccess/Data/ApplicationDbContext.cs index 4dc5793..af5b8ab 100644 --- a/Marco.Pms.DataAccess/Data/ApplicationDbContext.cs +++ b/Marco.Pms.DataAccess/Data/ApplicationDbContext.cs @@ -248,7 +248,6 @@ namespace Marco.Pms.DataAccess.Data public DbSet InvoiceAttachmentTypes { get; set; } #endregion - protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs b/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs index 5aa761d..1ca6795 100644 --- a/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs +++ b/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs @@ -1,14 +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 _mobileCollection; private readonly ILogger _logger; public SidebarMenuHelper(IConfiguration configuration, ILogger logger) @@ -18,206 +18,94 @@ 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"); + _mobileCollection = database.GetCollection("MobileSideMenus"); + } - public async Task CreateMenuSectionAsync(MenuSection section) + public async Task> GetAllWebMenuSectionsAsync(Guid tenantId) { try { - await _collection.InsertOneAsync(section); - return section; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while adding MenuSection."); - return null; - } - } + var filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - 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) + var result = await _webCollection + .Find(filter) + .ToListAsync(); + if (result.Any()) { - return await _collection.Find(s => s.Id == sectionId).FirstOrDefaultAsync(); + return result; } - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating MenuSection."); - return null; - } - } + tenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26"); + filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - 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 - .Find(filter) - .ToListAsync(); - if (result.Any()) - { + result = await _webCollection + .Find(filter) + .ToListAsync(); return result; } - - tenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26"); - filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - - result = await _collection - .Find(filter) - .ToListAsync(); - return result; + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while fetching Web Menu Sections."); + return new List(); + } } + public async Task> AddWebMenuItemAsync(List newItems) + { + try + { + await _webCollection.InsertManyAsync(newItems); + return newItems; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while adding Web Menu Section."); + return new List(); + } + } + public async Task> GetAllMobileMenuSectionsAsync(Guid tenantId) + { + try + { + var filter = Builders.Filter.Eq(e => e.TenantId, tenantId); + + var result = await _mobileCollection + .Find(filter) + .ToListAsync(); + if (result.Any()) + { + return result; + } + + tenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26"); + filter = Builders.Filter.Eq(e => e.TenantId, tenantId); + + result = await _mobileCollection + .Find(filter) + .ToListAsync(); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while fetching Web Menu Sections."); + return new List(); + } + } + public async Task> AddMobileMenuItemAsync(List newItems) + { + try + { + await _mobileCollection.InsertManyAsync(newItems); + return newItems; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while adding Mobile Menu Section."); + return new List(); + } + } + } diff --git a/Marco.Pms.Model/AppMenu/MobileMenu.cs b/Marco.Pms.Model/AppMenu/MobileMenu.cs new file mode 100644 index 0000000..fbe1075 --- /dev/null +++ b/Marco.Pms.Model/AppMenu/MobileMenu.cs @@ -0,0 +1,21 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Marco.Pms.Model.AppMenu +{ + public class MobileMenu + { + [BsonId] + [BsonRepresentation(BsonType.String)] + public Guid Id { get; set; } + public string? Name { get; set; } + public bool Available { get; set; } + public string? MobileLink { get; set; } + + [BsonRepresentation(BsonType.String)] + public List PermissionIds { get; set; } = new List(); + + [BsonRepresentation(BsonType.String)] + public Guid TenantId { get; set; } + } +} diff --git a/Marco.Pms.Model/AppMenu/WebMenuSection.cs b/Marco.Pms.Model/AppMenu/WebMenuSection.cs new file mode 100644 index 0000000..1ccfcab --- /dev/null +++ b/Marco.Pms.Model/AppMenu/WebMenuSection.cs @@ -0,0 +1,19 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Marco.Pms.Model.AppMenu +{ + public class WebMenuSection + { + [BsonId] + [BsonRepresentation(BsonType.String)] + public Guid Id { get; set; } = Guid.NewGuid(); + + public string? Header { get; set; } + public string? Name { get; set; } + public List Items { get; set; } = new List(); + + [BsonRepresentation(BsonType.String)] + public Guid TenantId { get; set; } + } +} diff --git a/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs b/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs new file mode 100644 index 0000000..d9e76ef --- /dev/null +++ b/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs @@ -0,0 +1,25 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Marco.Pms.Model.AppMenu +{ + public class WebSideMenuItem + { + [BsonId] + [BsonRepresentation(BsonType.String)] + public Guid Id { get; set; } = Guid.NewGuid(); + + [BsonRepresentation(BsonType.String)] + public Guid? ParentMenuId { get; set; } + public string? Name { get; set; } + public string? Icon { get; set; } + public bool Available { get; set; } = true; + public string Link { get; set; } = string.Empty; + + [BsonRepresentation(BsonType.String)] + public List PermissionIds { get; set; } = new List(); + + [BsonRepresentation(BsonType.String)] + public Guid TenantId { get; set; } + } +} diff --git a/Marco.Pms.Model/Dtos/AppMenu/CreateMenuItemDto.cs b/Marco.Pms.Model/Dtos/AppMenu/CreateMenuItemDto.cs deleted file mode 100644 index a932aa9..0000000 --- a/Marco.Pms.Model/Dtos/AppMenu/CreateMenuItemDto.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Marco.Pms.Model.Dtos.AppMenu -{ - public class CreateMenuItemDto - { - public required string Text { get; set; } - public required string Icon { get; set; } - public bool Available { get; set; } = true; - - public required string Link { get; set; } - public string? MobileLink { get; set; } - - // Changed from string → List - public List PermissionIds { get; set; } = new List(); - - public List Submenu { get; set; } = new List(); - } -} diff --git a/Marco.Pms.Model/Dtos/AppMenu/CreateMenuSectionDto.cs b/Marco.Pms.Model/Dtos/AppMenu/CreateMenuSectionDto.cs deleted file mode 100644 index e64c137..0000000 --- a/Marco.Pms.Model/Dtos/AppMenu/CreateMenuSectionDto.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Marco.Pms.Model.Dtos.AppMenu -{ - public class CreateMenuSectionDto - { - public required string Header { get; set; } - public required string Title { get; set; } - public List Items { get; set; } = new List(); - } -} \ No newline at end of file diff --git a/Marco.Pms.Model/Dtos/AppMenu/CreateMobileSideMenuItemDto.cs b/Marco.Pms.Model/Dtos/AppMenu/CreateMobileSideMenuItemDto.cs new file mode 100644 index 0000000..c7ba5b4 --- /dev/null +++ b/Marco.Pms.Model/Dtos/AppMenu/CreateMobileSideMenuItemDto.cs @@ -0,0 +1,13 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Marco.Pms.Model.Dtos.AppMenu +{ + public class CreateMobileSideMenuItemDto + { + public string? Name { get; set; } + public bool Available { get; set; } + public string? MobileLink { get; set; } + public List PermissionIds { get; set; } = new List(); + } +} diff --git a/Marco.Pms.Model/Dtos/AppMenu/CreateSubMenuItemDto.cs b/Marco.Pms.Model/Dtos/AppMenu/CreateSubMenuItemDto.cs deleted file mode 100644 index 60eb92c..0000000 --- a/Marco.Pms.Model/Dtos/AppMenu/CreateSubMenuItemDto.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Marco.Pms.Model.Dtos.AppMenu -{ - public class CreateSubMenuItemDto - { - public required string Text { get; set; } - public bool Available { get; set; } = true; - - public required string Link { get; set; } = string.Empty; - public string? MobileLink { get; set; } - // Changed from string → List - public List PermissionIds { get; set; } = new List(); - } -} diff --git a/Marco.Pms.Model/Dtos/AppMenu/UpdateMenuSectionDto.cs b/Marco.Pms.Model/Dtos/AppMenu/CreateWebMenuSectionDto.cs similarity index 52% rename from Marco.Pms.Model/Dtos/AppMenu/UpdateMenuSectionDto.cs rename to Marco.Pms.Model/Dtos/AppMenu/CreateWebMenuSectionDto.cs index f42794e..b5d432b 100644 --- a/Marco.Pms.Model/Dtos/AppMenu/UpdateMenuSectionDto.cs +++ b/Marco.Pms.Model/Dtos/AppMenu/CreateWebMenuSectionDto.cs @@ -1,9 +1,9 @@ namespace Marco.Pms.Model.Dtos.AppMenu { - public class UpdateMenuSectionDto + public class CreateWebMenuSectionDto { - public required Guid Id { get; set; } public required string Header { get; set; } public required string Title { get; set; } + public List Items { get; set; } = new List(); } } diff --git a/Marco.Pms.Model/Dtos/AppMenu/CreateWebSideMenuItemDto.cs b/Marco.Pms.Model/Dtos/AppMenu/CreateWebSideMenuItemDto.cs new file mode 100644 index 0000000..3415a09 --- /dev/null +++ b/Marco.Pms.Model/Dtos/AppMenu/CreateWebSideMenuItemDto.cs @@ -0,0 +1,15 @@ +namespace Marco.Pms.Model.Dtos.AppMenu +{ + public class CreateWebSideMenuItemDto + { + public Guid? Id { get; set; } + public Guid? ParentMenuId { get; set; } + public string? Name { get; set; } + public string? Icon { get; set; } + public bool Available { get; set; } = true; + public string Link { get; set; } = string.Empty; + + // Changed from string → List + public List PermissionIds { get; set; } = new List(); + } +} diff --git a/Marco.Pms.Model/Dtos/AppMenu/UpdateMenuItemDto.cs b/Marco.Pms.Model/Dtos/AppMenu/UpdateMenuItemDto.cs deleted file mode 100644 index b3433f7..0000000 --- a/Marco.Pms.Model/Dtos/AppMenu/UpdateMenuItemDto.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Marco.Pms.Model.Dtos.AppMenu -{ - public class UpdateMenuItemDto - { - public required Guid Id { get; set; } - - public required string Text { get; set; } - public required string Icon { get; set; } - public bool Available { get; set; } = true; - - public required string Link { get; set; } - public string? MobileLink { get; set; } - - // Changed from string → List - public List PermissionIds { get; set; } = new List(); - } -} diff --git a/Marco.Pms.Model/Dtos/AppMenu/UpdateSubMenuItemDto.cs b/Marco.Pms.Model/Dtos/AppMenu/UpdateSubMenuItemDto.cs deleted file mode 100644 index 746e1f4..0000000 --- a/Marco.Pms.Model/Dtos/AppMenu/UpdateSubMenuItemDto.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Marco.Pms.Model.Dtos.AppMenu -{ - public class UpdateSubMenuItemDto - { - public Guid Id { get; set; } - - public string? Text { get; set; } - public bool Available { get; set; } = true; - - public string Link { get; set; } = string.Empty; - public string? MobileLink { get; set; } - - // Changed from string → List - public List PermissionIds { get; set; } = new List(); - } -} diff --git a/Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs b/Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs new file mode 100644 index 0000000..6c78ece --- /dev/null +++ b/Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs @@ -0,0 +1,11 @@ +namespace Marco.Pms.Model.ViewModels.AppMenu +{ + public class WebMenuSectionVM + { + public Guid Id { get; set; } + + public string? Header { get; set; } + public string? Name { get; set; } + public List Items { get; set; } = new List(); + } +} diff --git a/Marco.Pms.Model/ViewModels/DocumentManager/MenuItemVM.cs b/Marco.Pms.Model/ViewModels/AppMenu/WebSideMenuItemVM.cs similarity index 55% rename from Marco.Pms.Model/ViewModels/DocumentManager/MenuItemVM.cs rename to Marco.Pms.Model/ViewModels/AppMenu/WebSideMenuItemVM.cs index 5925fe0..11446fa 100644 --- a/Marco.Pms.Model/ViewModels/DocumentManager/MenuItemVM.cs +++ b/Marco.Pms.Model/ViewModels/AppMenu/WebSideMenuItemVM.cs @@ -1,13 +1,12 @@ -namespace Marco.Pms.Model.ViewModels.DocumentManager +namespace Marco.Pms.Model.ViewModels.AppMenu { - public class MenuItemVM + public class WebSideMenuItemVM { public Guid Id { get; set; } - public string? Name { get; set; } public string? Icon { get; set; } public bool Available { get; set; } public string? Link { get; set; } - public List Submenu { get; set; } = new List(); + public List Submenu { get; set; } = new List(); } } diff --git a/Marco.Pms.Model/ViewModels/DocumentManager/MenuSectionVM.cs b/Marco.Pms.Model/ViewModels/DocumentManager/MenuSectionVM.cs deleted file mode 100644 index cae8e50..0000000 --- a/Marco.Pms.Model/ViewModels/DocumentManager/MenuSectionVM.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Marco.Pms.Model.ViewModels.DocumentManager -{ - public class MenuSectionVM - { - public Guid Id { get; set; } - - public string? Header { get; set; } - public string? Name { get; set; } - public List Items { get; set; } = new List(); - } -} diff --git a/Marco.Pms.Model/ViewModels/DocumentManager/SubMenuItemVM.cs b/Marco.Pms.Model/ViewModels/DocumentManager/SubMenuItemVM.cs deleted file mode 100644 index ef14686..0000000 --- a/Marco.Pms.Model/ViewModels/DocumentManager/SubMenuItemVM.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Marco.Pms.Model.ViewModels.DocumentManager -{ - public class SubMenuItemVM - { - public Guid Id { get; set; } - - public string? Name { get; set; } - public bool Available { get; set; } - - public string? Link { get; set; } - } -} diff --git a/Marco.Pms.Services/Controllers/AppMenuController.cs b/Marco.Pms.Services/Controllers/AppMenuController.cs index 82b122f..d098bbd 100644 --- a/Marco.Pms.Services/Controllers/AppMenuController.cs +++ b/Marco.Pms.Services/Controllers/AppMenuController.cs @@ -2,7 +2,6 @@ using Marco.Pms.CacheHelper; using Marco.Pms.Model.AppMenu; using Marco.Pms.Model.Dtos.AppMenu; -using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.AppMenu; using Marco.Pms.Model.ViewModels.DocumentManager; @@ -26,6 +25,7 @@ namespace Marco.Pms.Services.Controllers private readonly IMapper _mapper; private readonly ILoggingService _logger; private readonly IServiceScopeFactory _serviceScopeFactory; + private readonly PermissionServices _permissionService; private readonly Guid tenantId; private static readonly Guid superTenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26"); @@ -42,7 +42,8 @@ namespace Marco.Pms.Services.Controllers SidebarMenuHelper sideBarMenuHelper, IMapper mapper, ILoggingService logger, - IServiceScopeFactory serviceScopeFactory) + IServiceScopeFactory serviceScopeFactory, + PermissionServices permissionService) { _userHelper = userHelper; @@ -51,500 +52,445 @@ namespace Marco.Pms.Services.Controllers _logger = logger; _serviceScopeFactory = serviceScopeFactory; tenantId = userHelper.GetTenantId(); + _permissionService = permissionService; } - /// - /// Creates a new sidebar menu section for the tenant. - /// Only accessible by root users or for the super tenant. + /// Returns the sidebar menu for the current tenant and logged-in employee, + /// filtered by permission and structured for the web application UI. /// - /// The data for the new menu section. - /// HTTP response with result of the operation. - - [HttpPost("add/sidebar/menu-section")] - public async Task CreateAppSideBarMenu([FromBody] CreateMenuSectionDto menuSectionDto) + [HttpGet("get/menu")] + public async Task GetAppSideBarMenuAsync() { - // Step 1: Fetch logged-in user - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var isRootUser = loggedInEmployee.ApplicationUser?.IsRootUser ?? false; + // Correlation ID enables tracing this request across services and logs. + var correlationId = HttpContext.TraceIdentifier; - // 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(menuSectionDto); - sideMenuSection.TenantId = tenantId; + // 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 4: Save entity using helper - sideMenuSection = await _sideBarMenuHelper.CreateMenuSectionAsync(sideMenuSection); - - if (sideMenuSection == null) + // 1. Validate tenant context + if (tenantId == Guid.Empty) { - _logger.LogWarning("Failed to create sidebar menu section. Tenant: {TenantId}, Request: {@MenuSectionDto}", tenantId, menuSectionDto); - return BadRequest(ApiResponse.ErrorResponse("Invalid MenuSection", 400)); + _logger.LogWarning("GetAppSideBarMenuAsync rejected due to invalid tenant. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + 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 5: Log success - _logger.LogInfo("Sidebar menu created successfully. SectionId: {SectionId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", - sideMenuSection.Id, tenantId, loggedInEmployee.Id); + // 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(); - return Ok(ApiResponse.SuccessResponse(sideMenuSection, "Sidebar menu created successfully.", 201)); + if (loggedInEmployee is null) + { + _logger.LogWarning("GetAppSideBarMenuAsync failed: current employee not resolved. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + 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); + + // 3. 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); + + // 4. 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 for tenant. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + 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} raw menu records. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + menus.Count, tenantId, correlationId); + + // 5. Build logical menu sections (root + children) and apply permission filtering + var responseSections = new List(); + + // Root container section for the web UI. + var rootSection = new WebMenuSectionVM + { + 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) + { + continue; + } + + var itemVm = _mapper.Map(menu); + + // If the menu requires any permission, check now. + if (menu.PermissionIds.Any()) + { + var hasMenuPermission = _permissionService.HasPermissionAny(permissionIds, menu.PermissionIds, employeeId); + + if (!hasMenuPermission) + { + _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( + "Submenu item skipped due to insufficient permissions. MenuId: {MenuId}, ParentMenuId: {ParentMenuId}, EmployeeId: {EmployeeId}, CorrelationId: {CorrelationId}", + subMenu.Id, subMenu.ParentMenuId!, employeeId, correlationId); + continue; + } + } + + // Add the submenu to the root section + itemVm.Submenu.Add(subMenuVm); + } + // Add the root menu item + rootSection.Items.Add(itemVm); + } + + if (rootSection.Items.Any()) + { + responseSections.Add(rootSection); + } + + _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) + { + // This typically indicates client disconnected or explicit cancellation. + _logger.LogWarning("GetAppSideBarMenuAsync was cancelled. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + 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) + { + // Handle authorization-related errors explicitly to avoid leaking details. + _logger.LogError(ex, "GetAppSideBarMenuAsync authorization failure. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + 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) { - // 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, menuSectionDto); + // 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("Server Error", "An unexpected error occurred.", 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); } } - /// - /// Updates an existing sidebar menu section for the tenant. - /// Only accessible by root users or for the super tenant. - /// - /// The unique identifier of the section to update. - /// The updated data for the sidebar menu section. - /// HTTP response with the result of the operation. - - [HttpPut("edit/sidebar/menu-section/{sectionId}")] - public async Task UpdateMenuSection(Guid sectionId, [FromBody] UpdateMenuSectionDto updatedSection) + [HttpPost("add/side-menu")] + public async Task AddWebMenuItemAsync([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 + // Step 3: Authorization check if (!isRootUser && tenantId != superTenantId) { - _logger.LogWarning("Access denied: User {UserId} attempted to update sidebar menu 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: Validate request - if (sectionId == Guid.Empty || sectionId != updatedSection.Id) + // Step 4: Input validation + if (model == null) { - _logger.LogWarning("Invalid update request. Tenant: {TenantId}, SectionId: {SectionId}, PayloadId: {PayloadId}, UserId: {UserId}", - tenantId, sectionId, updatedSection.Id, loggedInEmployee.Id); - - return BadRequest(ApiResponse.ErrorResponse("Invalid section ID or mismatched payload.", 400)); - } - - // Step 4: Map DTO to entity - var menuSectionEntity = _mapper.Map(updatedSection); - - try - { - // Step 5: Perform update operation - var result = await _sideBarMenuHelper.UpdateMenuSectionAsync(sectionId, menuSectionEntity); - - if (result == null) - { - _logger.LogWarning("Menu section not found for update. SectionId: {SectionId}, TenantId: {TenantId}, UserId: {UserId}", - sectionId, tenantId, loggedInEmployee.Id); - return NotFound(ApiResponse.ErrorResponse("Menu section not found", 404)); - } - - // Step 6: Successful update - _logger.LogInfo("Menu section updated successfully. SectionId: {SectionId}, TenantId: {TenantId}, UserId: {UserId}", - sectionId, tenantId, loggedInEmployee.Id); - - return Ok(ApiResponse.SuccessResponse(result, "Menu section updated successfully")); - } - catch (Exception ex) - { - // Step 7: Unexpected server error - _logger.LogError(ex, "Failed to update menu section. SectionId: {SectionId}, TenantId: {TenantId}, UserId: {UserId}, Payload: {@UpdatedSection}", - sectionId, tenantId, loggedInEmployee.Id, updatedSection); - - return StatusCode(500, ApiResponse.ErrorResponse("Server error", "An unexpected error occurred while updating the menu section.", 500)); - } - } - - /// - /// Adds a new menu item to an existing sidebar menu section. - /// Only accessible by root users or for the super tenant. - /// - /// The unique identifier of the section the item will be added to. - /// The details of the new menu item. - /// HTTP response with the result of the operation. - - [HttpPost("add/sidebar/menus/{sectionId}/items")] - public async Task AddMenuItem(Guid sectionId, [FromBody] CreateMenuItemDto newItemDto) - { - // Step 1: 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: User {UserId} attempted to add menu item to section {SectionId} in Tenant {TenantId}", - loggedInEmployee.Id, sectionId, tenantId); - - return StatusCode(403, ApiResponse.ErrorResponse("Access Denied", "User does not have permission.", 403)); - } - - // Step 3: Input validation - if (sectionId == Guid.Empty || newItemDto == 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 - var menuItemEntity = _mapper.Map(newItemDto); + // Step 5: Map DTO to entity + var menuItemEntity = _mapper.Map>(model); - // Step 5: Perform Add operation - var result = await _sideBarMenuHelper.AddMenuItemAsync(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, newItemDto); + // 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)); } } /// - /// Updates an existing menu item inside a sidebar menu section. - /// Only accessible by root users or within the super tenant. + /// Retrieves the mobile sidebar menu sections for the authenticated employee within the current tenant, + /// filtered by employee permissions and structured for mobile application consumption. + /// Supports permission-based access control and tenant isolation. /// - /// The ID of the sidebar menu section. - /// The ID of the menu item to update. - /// The updated menu item details. - /// HTTP response with the result of the update operation. + /// A filtered list of accessible mobile menu sections or appropriate error response. + /// Returns filtered mobile menu sections successfully. + /// Invalid tenant identifier provided. + /// Employee context not resolved or insufficient permissions. + /// Internal server error during menu retrieval or processing. - [HttpPut("edit/sidebar/{sectionId}/items/{itemId}")] - public async Task UpdateMenuItem(Guid sectionId, Guid itemId, [FromBody] UpdateMenuItemDto updatedMenuItem) + [HttpGet("get/menu-mobile")] + public async Task GetAppSideBarMenuForMobileAsync() { - // Step 1: Fetch logged-in user - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var isRootUser = loggedInEmployee.ApplicationUser?.IsRootUser ?? false; + // Correlation ID enables distributed tracing across services, middleware, and structured logs. + var correlationId = HttpContext.TraceIdentifier; - // Step 2: Authorization check - if (!isRootUser && tenantId != superTenantId) - { - _logger.LogWarning("Access denied: User {UserId} attempted to update menu item {ItemId} in Section {SectionId}, Tenant {TenantId}", - loggedInEmployee.Id, itemId, sectionId, tenantId); - - return StatusCode(403, ApiResponse.ErrorResponse("Access Denied", "User does not have permission.", 403)); - } - - // Step 3: Input validation - if (sectionId == Guid.Empty || itemId == Guid.Empty || updatedMenuItem == null || updatedMenuItem.Id != itemId) - { - _logger.LogWarning("Invalid UpdateMenuItem request. Tenant: {TenantId}, SectionId: {SectionId}, ItemId: {ItemId}, UserId: {UserId}", - tenantId, sectionId, itemId, loggedInEmployee.Id); - - return BadRequest(ApiResponse.ErrorResponse("Invalid section ID, item ID, or menu item payload.", 400)); - } - - // Step 4: Map DTO to entity - var menuItemEntity = _mapper.Map(updatedMenuItem); + _logger.LogInfo("GetAppSideBarMenuForMobileAsync started. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); try { - // Step 5: Perform update operation - var result = await _sideBarMenuHelper.UpdateMenuItemAsync(sectionId, itemId, menuItemEntity); - - if (result == null) + // 1. Validate tenant isolation - critical for multi-tenant security + if (tenantId == Guid.Empty) { - _logger.LogWarning("Menu item not found or update failed. Tenant: {TenantId}, SectionId: {SectionId}, ItemId: {ItemId}, UserId: {UserId}", - tenantId, sectionId, itemId, loggedInEmployee.Id); - return NotFound(ApiResponse.ErrorResponse("Menu item not found or update failed.", 404)); + _logger.LogWarning("GetAppSideBarMenuForMobileAsync rejected: invalid tenant context. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + var error = ApiResponse.ErrorResponse( + "InvalidTenantContext", + "Tenant identifier is missing or invalid. Verify tenant context and retry.", + 400); + + return BadRequest(error); } - // Step 6: Success log - _logger.LogInfo("Menu item updated successfully. Tenant: {TenantId}, SectionId: {SectionId}, ItemId: {ItemId}, UserId: {UserId}", - tenantId, sectionId, itemId, loggedInEmployee.Id); - - return Ok(ApiResponse.SuccessResponse(result, "Sidebar menu item updated successfully.")); - } - catch (Exception ex) - { - // ✅ Step 7: Handle server errors - _logger.LogError(ex, "Error occurred while updating menu item. Tenant: {TenantId}, SectionId: {SectionId}, ItemId: {ItemId}, UserId: {UserId}, Payload: {@UpdatedMenuItem}", - tenantId, sectionId, itemId, loggedInEmployee.Id, updatedMenuItem); - - return StatusCode( - 500, - ApiResponse.ErrorResponse("Server Error", "An unexpected error occurred while updating the menu item.", 500) - ); - } - } - - /// - /// Adds a new sub-menu item to an existing menu item inside a sidebar menu section. - /// Only accessible by root users or within the super tenant. - /// - /// The ID of the sidebar menu section. - /// The ID of the parent menu item. - /// The details of the new sub-menu item. - /// HTTP response with the result of the add operation. - - [HttpPost("add/sidebar/menus/{sectionId}/items/{itemId}/subitems")] - public async Task AddSubMenuItem(Guid sectionId, Guid itemId, [FromBody] CreateSubMenuItemDto newSubItem) - { - // Step 1: 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: User {UserId} attempted to add sub-menu item in Section {SectionId}, MenuItem {ItemId}, Tenant {TenantId}", - loggedInEmployee.Id, sectionId, itemId, tenantId); - - return StatusCode(403, ApiResponse.ErrorResponse("Access Denied", "User does not have permission.", 403)); - } - - // Step 3: Validate input - if (sectionId == Guid.Empty || itemId == Guid.Empty || newSubItem == null) - { - _logger.LogWarning("Invalid AddSubMenuItem request. Tenant: {TenantId}, SectionId: {SectionId}, ItemId: {ItemId}, UserId: {UserId}", - tenantId, sectionId, itemId, loggedInEmployee.Id); - - return BadRequest(ApiResponse.ErrorResponse("Invalid section ID, item ID, or sub-menu item payload.", 400)); - } - - try - { - // Step 4: Map DTO to entity - var subMenuItemEntity = _mapper.Map(newSubItem); - - // Step 5: Perform add operation - var result = await _sideBarMenuHelper.AddSubMenuItemAsync(sectionId, itemId, subMenuItemEntity); - - if (result == null) + // 2. Resolve authenticated employee context with tenant isolation + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + if (loggedInEmployee is null) { - _logger.LogWarning("Parent menu item not found. Failed to add sub-menu item. Tenant: {TenantId}, SectionId: {SectionId}, ItemId: {ItemId}, UserId: {UserId}", - tenantId, sectionId, itemId, loggedInEmployee.Id); + _logger.LogWarning("GetAppSideBarMenuForMobileAsync failed: employee context not resolved. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); - return NotFound(ApiResponse.ErrorResponse("Parent menu item not found.", 404)); + var error = ApiResponse.ErrorResponse("EmployeeContextNotFound", "Current employee context could not be resolved. Please authenticate and retry.", 403); + + return StatusCode(403, error); } - // Step 6: Success logging - _logger.LogInfo("Sub-menu item added successfully. Tenant: {TenantId}, SectionId: {SectionId}, ParentItemId: {ItemId}, SubItemId: {SubItemId}, UserId: {UserId}", - tenantId, sectionId, itemId, result.Id, loggedInEmployee.Id); + var employeeId = loggedInEmployee.Id; + _logger.LogDebug("GetAppSideBarMenuForMobileAsync resolved employee: EmployeeId: {EmployeeId}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", + employeeId, tenantId, correlationId); - return Ok(ApiResponse.SuccessResponse(result, "Sub-menu item added successfully.")); - } - catch (Exception ex) - { - // Step 7: Handle unexpected errors - _logger.LogError(ex, "Error occurred while adding sub-menu item. Tenant: {TenantId}, SectionId: {SectionId}, ItemId: {ItemId}, UserId: {UserId}, Payload: {@NewSubItem}", - tenantId, sectionId, itemId, loggedInEmployee.Id, newSubItem); + // 3. Bulk-load employee permissions for efficient in-memory permission checks (avoids N+1 queries) + var permissionIds = await _permissionService.GetPermissionIdsByEmployeeId(employeeId); + _logger.LogDebug("GetAppSideBarMenuForMobileAsync loaded {PermissionCount} permissions for EmployeeId: {EmployeeId}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", + permissionIds?.Count ?? 0, employeeId, tenantId, correlationId); - return StatusCode(500, ApiResponse.ErrorResponse("Server Error", "An unexpected error occurred while adding the sub-menu item.", 500)); - } - } - - /// - /// Updates an existing sub-menu item inside a sidebar menu section. - /// Only accessible by root users or within the super tenant. - /// - /// The ID of the sidebar menu section. - /// The ID of the parent menu item. - /// The ID of the sub-menu item to update. - /// The updated sub-menu item details. - /// HTTP response with the result of the update operation. - - [HttpPut("edit/sidebar/{sectionId}/items/{itemId}/subitems/{subItemId}")] - public async Task UpdateSubmenuItem(Guid sectionId, Guid itemId, Guid subItemId, [FromBody] UpdateSubMenuItemDto updatedSubMenuItem) - { - // Step 1: 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: User {UserId} attempted to update sub-menu {SubItemId} under MenuItem {ItemId} in Section {SectionId}, Tenant {TenantId}", - loggedInEmployee.Id, subItemId, itemId, sectionId, tenantId); - - return StatusCode(403, ApiResponse.ErrorResponse("Access Denied", "User does not have permission.", 403)); - } - - // Step 3: Input validation - if (sectionId == Guid.Empty || itemId == Guid.Empty || subItemId == Guid.Empty || updatedSubMenuItem == null || updatedSubMenuItem.Id != subItemId) - { - _logger.LogWarning("Invalid UpdateSubMenuItem request. Tenant: {TenantId}, SectionId: {SectionId}, ItemId: {ItemId}, SubItemId: {SubItemId}, UserId: {UserId}", - tenantId, sectionId, itemId, subItemId, loggedInEmployee.Id); - - return BadRequest(ApiResponse.ErrorResponse("Invalid section ID, menu item ID, sub-item ID, or payload mismatch.", 400)); - } - - try - { - // Step 4: Map DTO to entity - var subMenuEntity = _mapper.Map(updatedSubMenuItem); - - // Step 5: Perform update operation - var result = await _sideBarMenuHelper.UpdateSubmenuItemAsync(sectionId, itemId, subItemId, subMenuEntity); - - if (result == null) + // 4. Fetch tenant-specific mobile menu configuration + var allMenus = await _sideBarMenuHelper.GetAllMobileMenuSectionsAsync(tenantId); + if (allMenus == null || !allMenus.Any()) { - _logger.LogWarning("Sub-menu item not found or update failed. Tenant: {TenantId}, SectionId: {SectionId}, ItemId: {ItemId}, SubItemId: {SubItemId}, UserId: {UserId}", - tenantId, sectionId, itemId, subItemId, loggedInEmployee.Id); + _logger.LogInfo("GetAppSideBarMenuForMobileAsync: no mobile menu sections configured. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); - return NotFound(ApiResponse.ErrorResponse("Sub-menu item not found.", 404)); + var emptyResponse = new List(); + return Ok(ApiResponse.SuccessResponse(emptyResponse, + "No mobile sidebar menu sections configured for this tenant.", 200)); } - // Step 6: Log success - _logger.LogInfo("Sub-menu item updated successfully. Tenant: {TenantId}, SectionId: {SectionId}, MenuItemId: {ItemId}, SubItemId: {SubItemId}, UserId: {UserId}", - tenantId, sectionId, itemId, subItemId, loggedInEmployee.Id); + _logger.LogDebug("GetAppSideBarMenuForMobileAsync loaded {MenuCount} raw sections. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + allMenus.Count, tenantId, correlationId); - return Ok(ApiResponse.SuccessResponse(result, "Sub-menu item updated successfully.")); - } - catch (Exception ex) - { - // Step 7: Handle unexpected errors - _logger.LogError(ex, "Error occurred while updating sub-menu item. Tenant: {TenantId}, SectionId: {SectionId}, MenuItemId: {ItemId}, SubItemId: {SubItemId}, UserId: {UserId}, Payload: {@UpdatedSubMenuItem}", - tenantId, sectionId, itemId, subItemId, loggedInEmployee.Id, updatedSubMenuItem); - - return StatusCode(500, ApiResponse.ErrorResponse("Server Error", "An unexpected error occurred while updating the sub-menu item.", 500)); - } - } - - /// - /// Fetches the sidebar menu for the current tenant and filters items based on employee permissions. - /// - /// The sidebar menu with only the items/sub-items the employee has access to. - - [HttpGet("get/menu")] - public async Task GetAppSideBarMenu() - { - // Step 1: Get logged-in employee - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var employeeId = loggedInEmployee.Id; - - using var scope = _serviceScopeFactory.CreateScope(); - var _permissions = scope.ServiceProvider.GetRequiredService(); - - try - { - // Step 2: Fetch all menu sections for the tenant - var menus = await _sideBarMenuHelper.GetAllMenuSectionsAsync(tenantId); - - if (!(menus?.Any() ?? false)) + // 5. Filter menus by employee permissions (in-memory for performance) + var accessibleMenus = new List(); + foreach (var menuSection in allMenus) { - menus = new List + // Skip permission check for public menu items + if (!menuSection.PermissionIds.Any()) { - MenuStaticMaster.menu - }; - } - - foreach (var menu in menus) - { - var allowedItems = new List(); - - foreach (var item in menu.Items) - { - // --- Item permission check --- - if (!item.PermissionIds.Any()) - { - allowedItems.Add(item); - } - else - { - // Convert permission string IDs to GUIDs - var menuPermissionIds = item.PermissionIds - .Select(Guid.Parse) - .ToList(); - - bool isAllowed = await _permissions.HasPermissionAny(menuPermissionIds, employeeId); - - // If allowed, filter its submenus as well - if (isAllowed) - { - if (item.Submenu?.Any() == true) - { - var allowedSubmenus = new List(); - - foreach (var subItem in item.Submenu) - { - if (!subItem.PermissionIds.Any()) - { - allowedSubmenus.Add(subItem); - continue; - } - - var subMenuPermissionIds = subItem.PermissionIds - .Select(Guid.Parse) - .ToList(); - - bool isSubItemAllowed = await _permissions.HasPermissionAny(subMenuPermissionIds, employeeId); - - if (isSubItemAllowed) - { - allowedSubmenus.Add(subItem); - } - } - - // Replace with filtered submenus - item.Submenu = allowedSubmenus; - } - - allowedItems.Add(item); - } - } + accessibleMenus.Add(menuSection); + continue; } - // Replace with filtered items - menu.Items = allowedItems; + // Perform permission intersection check + var hasAccess = _permissionService.HasPermissionAny(permissionIds ?? new List(), + menuSection.PermissionIds, employeeId); + + if (hasAccess) + { + accessibleMenus.Add(menuSection); + _logger.LogDebug("GetAppSideBarMenuForMobileAsync granted menu access. MenuId: {MenuId}, EmployeeId: {EmployeeId}, CorrelationId: {CorrelationId}", + menuSection.Id, employeeId, correlationId); + } + else + { + _logger.LogDebug("GetAppSideBarMenuForMobileAsync denied menu access. MenuId: {MenuId}, EmployeeId: {EmployeeId}, CorrelationId: {CorrelationId}", + menuSection.Id, employeeId, correlationId); + } } - // Step 3: Log success - _logger.LogInfo("Fetched sidebar menu successfully. Tenant: {TenantId}, EmployeeId: {EmployeeId}, SectionsReturned: {Count}", - tenantId, employeeId, menus.Count); + // 6. Defensive mapping with null-safety + var response = _mapper.Map>(accessibleMenus); + _logger.LogInfo("GetAppSideBarMenuForMobileAsync completed successfully. AccessibleMenus: {AccessibleCount}/{TotalCount}, EmployeeId: {EmployeeId}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", + accessibleMenus.Count, allMenus.Count, employeeId, tenantId, correlationId); - var response = _mapper.Map>(menus); - return Ok(ApiResponse.SuccessResponse(response, "Sidebar menu fetched successfully")); + return Ok(ApiResponse.SuccessResponse(response, + $"Mobile sidebar menu fetched successfully ({response?.Count ?? 0} sections accessible).", 200)); + } + catch (OperationCanceledException) + { + _logger.LogWarning("GetAppSideBarMenuForMobileAsync cancelled. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + return StatusCode(499, ApiResponse.ErrorResponse("RequestCancelled", + "Request was cancelled by client or timeout.", 499)); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogError(ex, "GetAppSideBarMenuForMobileAsync authorization failed. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + return StatusCode(403, ApiResponse.ErrorResponse("AuthorizationFailed", + "Insufficient permissions to access mobile menu sections.", 403)); } catch (Exception ex) { - // Step 4: Handle unexpected errors - _logger.LogError(ex, "Error occurred while fetching sidebar menu. Tenant: {TenantId}, EmployeeId: {EmployeeId}", - tenantId, employeeId); + _logger.LogError(ex, "GetAppSideBarMenuForMobileAsync failed unexpectedly. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); - return StatusCode(500, ApiResponse.ErrorResponse("Server Error", "An unexpected error occurred while fetching the sidebar menu.", 500)); + return StatusCode(500, ApiResponse.ErrorResponse("InternalServerError", + "An unexpected error occurred while fetching the mobile sidebar menu. Please contact support if issue persists.", 500)); } } - /// - /// Retrieves the master menu list based on enabled features for the current tenant. - /// - /// List of master menu items available for the tenant + + [HttpPost("add/mobile/side-menu")] + public async Task AddMobileMenuItemAsync([FromBody] List model) + { + // 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 3: Authorization check + if (!isRootUser && tenantId != superTenantId) + { + _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 4: Input validation + if (model == null) + { + _logger.LogWarning("Invalid AddMenuItem request. Tenant: {TenantId}, UserId: {UserId}", + tenantId, loggedInEmployee.Id); + + return BadRequest(ApiResponse.ErrorResponse("Invalid section ID or menu item payload.", 400)); + } + + // Step 5: Map DTO to entity + var menuItemEntity = _mapper.Map>(model); + + menuItemEntity.ForEach(m => m.TenantId = tenantId); + + // Step 6: Perform Add operation + var result = await _sideBarMenuHelper.AddMobileMenuItemAsync(menuItemEntity); + + if (result == null) + { + _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 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")); + } [HttpGet("get/master-list")] public async Task GetMasterList() @@ -626,236 +572,6 @@ namespace Marco.Pms.Services.Controllers } } - [HttpGet("get/menu-mobile")] - public async Task GetAppSideBarMenuForobile() - { - // Step 1: Get logged-in employee - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var employeeId = loggedInEmployee.Id; - - using var scope = _serviceScopeFactory.CreateScope(); - var _permissions = scope.ServiceProvider.GetRequiredService(); - - try - { - // Step 2: Fetch all menu sections for the tenant - var menus = await _sideBarMenuHelper.GetAllMenuSectionsAsync(tenantId); - if (!(menus?.Any() ?? false)) - { - menus = new List - { - MenuStaticMaster.menu - }; - } - List response = new List(); - - foreach (var menu in menus) - { - var allowedItems = new List(); - - foreach (var item in menu.Items) - { - // --- Item permission check --- - if (!item.PermissionIds.Any()) - { - MenuSectionApplicationVM menuVM = new MenuSectionApplicationVM - { - Id = item.Id, - Name = item.Text, - Available = true, - MobileLink = item.MobileLink, - }; - response.Add(menuVM); - - if (item.Submenu?.Any() == true) - { - var allowedSubmenus = new List(); - - foreach (var subItem in item.Submenu) - { - if (!subItem.PermissionIds.Any()) - { - MenuSectionApplicationVM subMenuVM = new MenuSectionApplicationVM - { - Id = subItem.Id, - Name = subItem.Text, - Available = true, - MobileLink = subItem.MobileLink - }; - response.Add(subMenuVM); - continue; - } - - var subMenuPermissionIds = subItem.PermissionIds - .Select(Guid.Parse) - .ToList(); - - bool isSubItemAllowed = await _permissions.HasPermissionAny(subMenuPermissionIds, employeeId); - - if (isSubItemAllowed) - { - MenuSectionApplicationVM subMenuVM = new MenuSectionApplicationVM - { - Id = subItem.Id, - Name = subItem.Text, - Available = true, - MobileLink = subItem.MobileLink - }; - response.Add(subMenuVM); - } - } - - // Replace with filtered submenus - item.Submenu = allowedSubmenus; - } - } - else - { - // Convert permission string IDs to GUIDs - var menuPermissionIds = item.PermissionIds - .Select(Guid.Parse) - .ToList(); - - bool isAllowed = await _permissions.HasPermissionAny(menuPermissionIds, employeeId); - - // If allowed, filter its submenus as well - if (isAllowed) - { - if (item.Submenu?.Any() == true) - { - var allowedSubmenus = new List(); - - foreach (var subItem in item.Submenu) - { - if (!subItem.PermissionIds.Any()) - { - MenuSectionApplicationVM subMenuVM = new MenuSectionApplicationVM - { - Id = subItem.Id, - Name = subItem.Text, - Available = true, - MobileLink = subItem.MobileLink - }; - response.Add(subMenuVM); - continue; - } - - var subMenuPermissionIds = subItem.PermissionIds - .Select(Guid.Parse) - .ToList(); - - bool isSubItemAllowed = await _permissions.HasPermissionAny(subMenuPermissionIds, employeeId); - - if (isSubItemAllowed) - { - MenuSectionApplicationVM subMenuVM = new MenuSectionApplicationVM - { - Id = subItem.Id, - Name = subItem.Text, - Available = true, - MobileLink = subItem.MobileLink, - }; - response.Add(subMenuVM); - } - } - - // Replace with filtered submenus - item.Submenu = allowedSubmenus; - } - - MenuSectionApplicationVM menuVM = new MenuSectionApplicationVM - { - Id = item.Id, - Name = item.Text, - Available = true, - MobileLink = item.MobileLink - }; - response.Add(menuVM); - } - } - } - - // Replace with filtered items - menu.Items = allowedItems; - } - - var viewDocumentTask = Task.Run(async () => - { - using var taskScope = _serviceScopeFactory.CreateScope(); - var permissions = taskScope.ServiceProvider.GetRequiredService(); - return await permissions.HasPermission(PermissionsMaster.ViewDocument, employeeId); - }); - - var uploadDocumentTask = Task.Run(async () => - { - using var taskScope = _serviceScopeFactory.CreateScope(); - var permissions = taskScope.ServiceProvider.GetRequiredService(); - return await permissions.HasPermission(PermissionsMaster.UploadDocument, employeeId); - }); - - var verifyDocumentTask = Task.Run(async () => - { - using var taskScope = _serviceScopeFactory.CreateScope(); - var permissions = taskScope.ServiceProvider.GetRequiredService(); - return await permissions.HasPermission(PermissionsMaster.VerifyDocument, employeeId); - }); - - var downloadDocumentTask = Task.Run(async () => - { - using var taskScope = _serviceScopeFactory.CreateScope(); - var permissions = taskScope.ServiceProvider.GetRequiredService(); - return await permissions.HasPermission(PermissionsMaster.DownloadDocument, employeeId); - }); - - await Task.WhenAll(viewDocumentTask, uploadDocumentTask, verifyDocumentTask, downloadDocumentTask); - - var viewDocument = viewDocumentTask.Result; - var uploadDocument = uploadDocumentTask.Result; - var verifyDocument = verifyDocumentTask.Result; - var downloadDocument = downloadDocumentTask.Result; - - if (viewDocument || uploadDocument || verifyDocument || downloadDocument) - { - response.Add(new MenuSectionApplicationVM - { - Id = Guid.Parse("443d6444-250b-4164-89fd-bcd7cedd9e43"), - Name = "Documents", - Available = true, - MobileLink = "/dashboard/document-main-page" - }); - } - - response.Add(new MenuSectionApplicationVM - { - Id = Guid.Parse("7faddfe7-994b-4712-91c2-32ba44129d9b"), - Name = "Service Projects", - Available = true, - MobileLink = "/dashboard/service-projects" - }); - response.Add(new MenuSectionApplicationVM - { - Id = Guid.Parse("5fab4b88-c9a0-417b-aca2-130980fdb0cf"), - Name = "Infra Projects", - Available = true, - MobileLink = "/dashboard/infra-projects" - }); - - // Step 3: Log success - response = response.Where(ms => !string.IsNullOrWhiteSpace(ms.MobileLink)).ToList(); - _logger.LogInfo("Fetched sidebar menu successfully. Tenant: {TenantId}, EmployeeId: {EmployeeId}, SectionsReturned: {Count}", - tenantId, employeeId, menus.Count); - return Ok(ApiResponse.SuccessResponse(response, "Sidebar menu fetched successfully", 200)); - } - catch (Exception ex) - { - // Step 4: Handle unexpected errors - _logger.LogError(ex, "Error occurred while fetching sidebar menu. Tenant: {TenantId}, EmployeeId: {EmployeeId}", - tenantId, employeeId); - - return StatusCode(500, ApiResponse.ErrorResponse("Server Error", "An unexpected error occurred while fetching the sidebar menu.", 500)); - } - } - } } diff --git a/Marco.Pms.Services/Controllers/DashboardController.cs b/Marco.Pms.Services/Controllers/DashboardController.cs index a03c01c..bf5ec4e 100644 --- a/Marco.Pms.Services/Controllers/DashboardController.cs +++ b/Marco.Pms.Services/Controllers/DashboardController.cs @@ -3,10 +3,13 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Attendance; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Expenses; +using Marco.Pms.Model.OrganizationModel; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.AttendanceVM; using Marco.Pms.Model.ViewModels.DashBoard; +using Marco.Pms.Model.ViewModels.Organization; +using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Service; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; @@ -23,11 +26,11 @@ namespace Marco.Pms.Services.Controllers [ApiController] public class DashboardController : ControllerBase { + private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly IProjectServices _projectServices; private readonly ILoggingService _logger; - private readonly PermissionServices _permissionServices; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IMapper _mapper; @@ -46,17 +49,17 @@ namespace Marco.Pms.Services.Controllers IProjectServices projectServices, IServiceScopeFactory serviceScopeFactory, ILoggingService logger, - PermissionServices permissionServices, - IMapper mapper) + IMapper mapper, + IDbContextFactory dbContextFactory) { _context = context; _userHelper = userHelper; _projectServices = projectServices; _logger = logger; _serviceScopeFactory = serviceScopeFactory; - _permissionServices = permissionServices; _mapper = mapper; tenantId = userHelper.GetTenantId(); + _dbContextFactory = dbContextFactory; } /// @@ -263,8 +266,10 @@ namespace Marco.Pms.Services.Controllers if (projectId.HasValue) { + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); // Security Check: Ensure the requested project is in the user's accessible list. - var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value); + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId} (not active or not accessible).", loggedInEmployee.Id, projectId.Value); @@ -349,9 +354,11 @@ namespace Marco.Pms.Services.Controllers if (projectId.HasValue) { // --- Logic for a SINGLE Project --- + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); // 2a. Security Check: Verify permission for the specific project. - var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value); + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId.Value); @@ -678,7 +685,11 @@ namespace Marco.Pms.Services.Controllers // Step 3: Check if logged-in employee has permission for this project var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - bool hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee!, projectId); + + using var scope = _serviceScopeFactory.CreateScope(); + var _permission = scope.ServiceProvider.GetRequiredService(); + + bool hasPermission = await _permission.HasProjectPermission(loggedInEmployee!, projectId); if (!hasPermission) { _logger.LogWarning("Unauthorized access by EmployeeId: {EmployeeId} to ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); @@ -756,7 +767,6 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(sortedResult, $"{sortedResult.Count} records fetched for attendance overview", 200)); } - [HttpGet("expense/monthly")] public async Task GetExpenseReportByProjectsAsync([FromQuery] Guid? projectId, [FromQuery] Guid? categoryId, [FromQuery] int months) { @@ -1204,5 +1214,425 @@ namespace Marco.Pms.Services.Controllers } } + /// + /// Returns a high-level collection overview (aging buckets, due vs collected, top client) + /// for invoices of the current tenant, optionally filtered by project. + /// + /// Optional project identifier to filter invoices. + /// Standardized API response with collection KPIs. + [HttpGet("collection-overview")] + public async Task GetCollectionOverviewAsync([FromQuery] Guid? projectId) + { + // Correlation ID pattern for distributed tracing (if you use one) + var correlationId = HttpContext.TraceIdentifier; + if (tenantId == Guid.Empty) + { + _logger.LogWarning("Invalid request: TenantId is empty on progression endpoint"); + return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400)); + } + _logger.LogInfo("Started GetCollectionOverviewAsync. CorrelationId: {CorrelationId}, ProjectId: {ProjectId}", correlationId, projectId ?? Guid.Empty); + + try + { + // Validate and identify current employee/tenant context + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // Base invoice query for this tenant; AsNoTracking for read-only performance [web:1][web:5] + var invoiceQuery = _context.Invoices + .Where(i => i.TenantId == tenantId && i.IsActive) + .Include(i => i.BilledTo) + .AsNoTracking(); + + // Fetch infra and service projects in parallel using factory-created contexts + // NOTE: Avoid Task.Run over async IO where possible. Here each uses its own context instance. [web:6][web:15] + var infraProjectTask = GetInfraProjectsAsync(tenantId); + var serviceProjectTask = GetServiceProjectsAsync(tenantId); + + await Task.WhenAll(infraProjectTask, serviceProjectTask); + + var projects = infraProjectTask.Result + .Union(serviceProjectTask.Result) + .ToList(); + + // Optional project filter: validate existence in cached list first + if (projectId.HasValue) + { + var project = projects.FirstOrDefault(p => p.Id == projectId.Value); + if (project == null) + { + _logger.LogWarning( + "Project {ProjectId} not found for tenant {TenantId} in GetCollectionOverviewAsync. CorrelationId: {CorrelationId}", + projectId, tenantId, correlationId); + + return StatusCode( + StatusCodes.Status404NotFound, + ApiResponse.ErrorResponse( + "Project Not Found", + "The requested project does not exist or is not associated with the current tenant.", + StatusCodes.Status404NotFound)); + } + + invoiceQuery = invoiceQuery.Where(i => i.ProjectId == projectId.Value); + } + + var invoices = await invoiceQuery.ToListAsync(); + + if (invoices.Count == 0) + { + _logger.LogInfo( + "No invoices found for tenant {TenantId} in GetCollectionOverviewAsync. CorrelationId: {CorrelationId}", + tenantId, correlationId); + + // Return an empty but valid overview instead of 404 – endpoint is conceptually valid + var emptyResponse = new + { + TotalDueAmount = 0d, + TotalCollectedAmount = 0d, + TotalValue = 0d, + PendingPercentage = 0d, + CollectedPercentage = 0d, + Bucket0To30Invoices = 0, + Bucket30To60Invoices = 0, + Bucket60To90Invoices = 0, + Bucket90PlusInvoices = 0, + Bucket0To30Amount = 0d, + Bucket30To60Amount = 0d, + Bucket60To90Amount = 0d, + Bucket90PlusAmount = 0d, + TopClientBalance = 0d, + TopClient = new BasicOrganizationVm() + }; + + return Ok(ApiResponse.SuccessResponse(emptyResponse, "No invoices found for the current tenant and filters; returning empty collection overview.", 200)); + } + + var invoiceIds = invoices.Select(i => i.Id).ToList(); + + // Pre-aggregate payments per invoice in the DB where possible [web:1][web:17] + var paymentGroups = await _context.ReceivedInvoicePayments + .AsNoTracking() + .Where(p => invoiceIds.Contains(p.InvoiceId) && p.TenantId == tenantId) + .GroupBy(p => p.InvoiceId) + .Select(g => new + { + InvoiceId = g.Key, + PaidAmount = g.Sum(p => p.Amount) + }) + .ToListAsync(); + + // Create a lookup to avoid repeated LINQ Where on each iteration + var paymentsLookup = paymentGroups.ToDictionary(p => p.InvoiceId, p => p.PaidAmount); + + double totalDueAmount = 0; + var today = DateTime.UtcNow.Date; // use UTC for consistency [web:17] + + var bucketOneInvoices = 0; + double bucketOneAmount = 0; + var bucketTwoInvoices = 0; + double bucketTwoAmount = 0; + var bucketThreeInvoices = 0; + double bucketThreeAmount = 0; + var bucketFourInvoices = 0; + double bucketFourAmount = 0; + + // Main aging calculation loop + foreach (var invoice in invoices) + { + var total = invoice.BasicAmount + invoice.TaxAmount; + var paid = paymentsLookup.TryGetValue(invoice.Id, out var paidAmount) + ? paidAmount + : 0d; + var balance = total - paid; + + // Skip fully paid or explicitly completed invoices + if (balance <= 0 || invoice.MarkAsCompleted) + continue; + + totalDueAmount += balance; + + // Only consider invoices with expected payment date up to today for aging + var expectedDate = invoice.ExceptedPaymentDate.Date; + if (expectedDate > today) + continue; + + var days = (today - expectedDate).Days; + + if (days <= 30 && days > 0) + { + bucketOneInvoices++; + bucketOneAmount += balance; + } + else if (days > 30 && days <= 60) + { + bucketTwoInvoices++; + bucketTwoAmount += balance; + } + else if (days > 60 && days <= 90) + { + bucketThreeInvoices++; + bucketThreeAmount += balance; + } + else if (days > 90) + { + bucketFourInvoices++; + bucketFourAmount += balance; + } + } + + var totalCollectedAmount = paymentGroups.Sum(p => p.PaidAmount); + var totalValue = totalDueAmount + totalCollectedAmount; + var pendingPercentage = totalValue > 0 ? (totalDueAmount / totalValue) * 100 : 0; + var collectedPercentage = totalValue > 0 ? (totalCollectedAmount / totalValue) * 100 : 0; + + // Determine top client by outstanding balance + double topClientBalance = 0; + Organization topClient = new Organization(); + + var groupedByClient = invoices + .Where(i => i.BilledToId.HasValue && i.BilledTo != null) + .GroupBy(i => i.BilledToId); + + foreach (var group in groupedByClient) + { + var clientInvoiceIds = group.Select(i => i.Id).ToList(); + var totalForClient = group.Sum(i => i.BasicAmount + i.TaxAmount); + var paidForClient = paymentGroups + .Where(pg => clientInvoiceIds.Contains(pg.InvoiceId)) + .Sum(pg => pg.PaidAmount); + + var clientBalance = totalForClient - paidForClient; + if (clientBalance > topClientBalance) + { + topClientBalance = clientBalance; + topClient = group.First()!.BilledTo!; + } + } + + BasicOrganizationVm topClientVm = new BasicOrganizationVm(); + if (topClient != null) + { + topClientVm = new BasicOrganizationVm + { + Id = topClient.Id, + Name = topClient.Name, + Email = topClient.Email, + ContactPerson = topClient.ContactPerson, + ContactNumber = topClient.ContactNumber, + Address = topClient.Address, + GSTNumber = topClient.GSTNumber, + SPRID = topClient.SPRID + }; + } + + var response = new + { + TotalDueAmount = totalDueAmount, + TotalCollectedAmount = totalCollectedAmount, + TotalValue = totalValue, + PendingPercentage = Math.Round(pendingPercentage, 2), + CollectedPercentage = Math.Round(collectedPercentage, 2), + Bucket0To30Invoices = bucketOneInvoices, + Bucket30To60Invoices = bucketTwoInvoices, + Bucket60To90Invoices = bucketThreeInvoices, + Bucket90PlusInvoices = bucketFourInvoices, + Bucket0To30Amount = bucketOneAmount, + Bucket30To60Amount = bucketTwoAmount, + Bucket60To90Amount = bucketThreeAmount, + Bucket90PlusAmount = bucketFourAmount, + TopClientBalance = topClientBalance, + TopClient = topClientVm + }; + + _logger.LogInfo("Successfully completed GetCollectionOverviewAsync for tenant {TenantId}. CorrelationId: {CorrelationId}, TotalInvoices: {InvoiceCount}, TotalValue: {TotalValue}", + tenantId, correlationId, invoices.Count, totalValue); + + return Ok(ApiResponse.SuccessResponse(response, "Collection overview fetched successfully.", 200)); + } + catch (Exception ex) + { + // Centralized logging for unhandled exceptions with context, no sensitive data [web:1][web:5][web:10] + _logger.LogError(ex, "Unhandled exception in GetCollectionOverviewAsync. CorrelationId: {CorrelationId}", correlationId); + + // Generic but consistent error payload; let global exception handler standardize if you use ProblemDetails [web:10][web:13][web:16] + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", + "An unexpected error occurred while generating the collection overview. Please try again or contact support with the correlation identifier.", 500)); + } + } + + [HttpGet("purchase-invoice-overview")] + public async Task GetPurchaseInvoiceOverview() + { + // Correlation id for tracing this request across services/logs. + var correlationId = HttpContext.TraceIdentifier; + + _logger.LogInfo("GetPurchaseInvoiceOverview started. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); + + // Basic guard: invalid tenant. + if (tenantId == Guid.Empty) + { + _logger.LogWarning("GetPurchaseInvoiceOverview rejected due to empty TenantId. CorrelationId: {CorrelationId}", correlationId); + + return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "The tenant identifier provided is invalid or missing.", 400)); + } + + try + { + // Fetch current employee context (if needed for authorization/audit). + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // Run project queries in parallel to reduce latency. + var infraProjectTask = GetInfraProjectsAsync(tenantId); + var serviceProjectTask = GetServiceProjectsAsync(tenantId); + + await Task.WhenAll(infraProjectTask, serviceProjectTask); + + var projects = infraProjectTask.Result + .Union(serviceProjectTask.Result) + .ToList(); + + _logger.LogDebug("GetPurchaseInvoiceOverview loaded projects. Count: {ProjectCount}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", projects.Count, tenantId, correlationId); + + // Query purchase invoices for the tenant. + var purchaseInvoices = await _context.PurchaseInvoiceDetails + .Include(pid => pid.Supplier) + .Include(pid => pid.Status) + .AsNoTracking() + .Where(pid => pid.TenantId == tenantId && pid.IsActive) + .ToListAsync(); + + _logger.LogInfo("GetPurchaseInvoiceOverview loaded invoices. InvoiceCount: {InvoiceCount}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", + purchaseInvoices.Count, tenantId, correlationId); + + if (!purchaseInvoices.Any()) + { + // No invoices is not an error; return an empty but well-structured overview. + _logger.LogInfo("GetPurchaseInvoiceOverview: No active purchase invoices found. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); + + var emptyResponse = new + { + TotalInvoices = 0, + TotalValue = 0m, + AverageValue = 0m, + StatusBreakdown = Array.Empty(), + ProjectBreakdown = Array.Empty(), + TopSupplier = (object?)null + }; + + return Ok(ApiResponse.SuccessResponse( + emptyResponse, + "No active purchase invoices found for the specified tenant.", + StatusCodes.Status200OK)); + } + + var totalInvoices = purchaseInvoices.Count; + var totalValue = purchaseInvoices.Sum(pid => pid.BaseAmount); + + // Guard against divide-by-zero (in case BaseAmount is all zero). + var averageValue = totalInvoices > 0 + ? totalValue / totalInvoices + : 0; + + // Status-wise aggregation + var statusBreakdown = purchaseInvoices + .Where(pid => pid.Status != null) + .GroupBy(pid => pid.StatusId) + .Select(g => new + { + Id = g.Key, + Name = g.First().Status!.DisplayName, + Count = g.Count(), + TotalValue = g.Sum(pid => pid.BaseAmount), + Percentage = totalValue > 0 + ? Math.Round(g.Sum(pid => pid.BaseAmount) / totalValue * 100, 2) + : 0 + }) + .OrderByDescending(x => x.TotalValue) + .ToList(); + + // Project-wise aggregation (top 3 by value) + var projectBreakdown = purchaseInvoices + .GroupBy(pid => pid.ProjectId) + .Select(g => new + { + Id = g.Key, + Name = projects.FirstOrDefault(p => p.Id == g.Key)?.Name ?? "Unknown Project", + Count = g.Count(), + TotalValue = g.Sum(pid => pid.BaseAmount), + Percentage = totalValue > 0 + ? Math.Round(g.Sum(pid => pid.BaseAmount) / totalValue * 100, 2) + : 0 + }) + .OrderByDescending(pid => pid.TotalValue) + .Take(3) + .ToList(); + + // Top supplier by total value + var supplierBreakdown = purchaseInvoices + .Where(pid => pid.Supplier != null) + .GroupBy(pid => pid.SupplierId) + .Select(g => new + { + Id = g.Key, + Name = g.First().Supplier!.Name, + Count = g.Count(), + TotalValue = g.Sum(pid => pid.BaseAmount), + Percentage = totalValue > 0 + ? Math.Round(g.Sum(pid => pid.BaseAmount) / totalValue * 100, 2) + : 0 + }) + .OrderByDescending(pid => pid.TotalValue) + .FirstOrDefault(); + + var response = new + { + TotalInvoices = totalInvoices, + TotalValue = Math.Round(totalValue, 2), + AverageValue = Math.Round(averageValue, 2), + StatusBreakdown = statusBreakdown, + ProjectBreakdown = projectBreakdown, + TopSupplier = supplierBreakdown + }; + + _logger.LogInfo("GetPurchaseInvoiceOverview completed successfully. TenantId: {TenantId}, TotalInvoices: {TotalInvoices}, TotalValue: {TotalValue}, CorrelationId: {CorrelationId}", + tenantId, totalInvoices, totalValue, correlationId); + + return Ok(ApiResponse.SuccessResponse(response, "Purchase invoice overview retrieved successfully.", 200)); + } + catch (Exception ex) + { + // Capture complete context for diagnostics, but ensure no sensitive data is logged. + _logger.LogError(ex, "Error occurred while processing GetPurchaseInvoiceOverview. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + // Do not expose internal details to clients. Return a generic 500 response. + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred while processing the purchase invoice overview.", 500)); + } + } + + /// + /// Gets infrastructure projects for a tenant as a lightweight view model. + /// + private async Task> GetInfraProjectsAsync(Guid tenantId) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.Projects + .AsNoTracking() + .Where(p => p.TenantId == tenantId) + .Select(p => new BasicProjectVM { Id = p.Id, Name = p.Name }) + .ToListAsync(); + } + + /// + /// Gets service projects for a tenant as a lightweight view model. + /// + private async Task> GetServiceProjectsAsync(Guid tenantId) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.ServiceProjects + .AsNoTracking() + .Where(sp => sp.TenantId == tenantId) + .Select(sp => new BasicProjectVM { Id = sp.Id, Name = sp.Name }) + .ToListAsync(); + } } } 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/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 9a9dac8..bd6b2dc 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -34,6 +34,7 @@ using Marco.Pms.Model.ServiceProject; using Marco.Pms.Model.TenantModels; using Marco.Pms.Model.TenantModels.MongoDBModel; using Marco.Pms.Model.ViewModels.Activities; +using Marco.Pms.Model.ViewModels.AppMenu; using Marco.Pms.Model.ViewModels.Collection; using Marco.Pms.Model.ViewModels.Directory; using Marco.Pms.Model.ViewModels.DocumentManager; @@ -563,26 +564,25 @@ namespace Marco.Pms.Services.MappingProfiles #endregion #region ======================================================= AppMenu ======================================================= - CreateMap(); - CreateMap(); - CreateMap() - .ForMember( - dest => dest.Name, - opt => opt.MapFrom(src => src.Title)); + CreateMap(); - CreateMap(); - CreateMap(); - CreateMap() + CreateMap() .ForMember( - dest => dest.Name, - opt => opt.MapFrom(src => src.Text)); + dest => dest.Items, + opt => opt.MapFrom(src => new List())); - CreateMap(); - CreateMap(); - CreateMap() + + CreateMap() .ForMember( - dest => dest.Name, - opt => opt.MapFrom(src => src.Text)); + dest => dest.Id, + opt => opt.MapFrom(src => src.Id.HasValue ? src.Id.Value : Guid.NewGuid())); + + + CreateMap(); + + CreateMap(); + CreateMap(); + #endregion #region ======================================================= Directory ======================================================= 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(); + } + } + } }