From 94e2e4f18b60efd72017af4cc536414c42b286ab Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Fri, 5 Dec 2025 14:42:15 +0530 Subject: [PATCH 1/6] Added an API to get collection overview --- .../Controllers/DashboardController.cs | 294 +++++++++++++++++- 1 file changed, 287 insertions(+), 7 deletions(-) diff --git a/Marco.Pms.Services/Controllers/DashboardController.cs b/Marco.Pms.Services/Controllers/DashboardController.cs index eee93bd..a02f7a8 100644 --- a/Marco.Pms.Services/Controllers/DashboardController.cs +++ b/Marco.Pms.Services/Controllers/DashboardController.cs @@ -2,8 +2,11 @@ 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.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; @@ -20,11 +23,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; public static readonly Guid ActiveId = Guid.Parse("b74da4c2-d07e-46f2-9919-e75e49b12731"); private static readonly Guid Draft = Guid.Parse("297e0d8f-f668-41b5-bfea-e03b354251c8"); @@ -40,16 +43,17 @@ namespace Marco.Pms.Services.Controllers IProjectServices projectServices, IServiceScopeFactory serviceScopeFactory, ILoggingService logger, - PermissionServices permissionServices) + IDbContextFactory dbContextFactory) { _context = context; _userHelper = userHelper; _projectServices = projectServices; _logger = logger; _serviceScopeFactory = serviceScopeFactory; - _permissionServices = permissionServices; tenantId = userHelper.GetTenantId(); + _dbContextFactory = dbContextFactory; } + /// /// Fetches project progression data (planned and completed tasks) in graph form for a tenant and specified (or all) projects over a date range. /// @@ -254,8 +258,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); @@ -340,9 +346,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); @@ -669,7 +677,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); @@ -747,7 +759,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) { @@ -1074,5 +1085,274 @@ namespace Marco.Pms.Services.Controllers ApiResponse.ErrorResponse("An error occurred while fetching pending expenses.", "An error occurred while fetching pending expenses.", 500)); // [Error Response] } } + + [HttpGet("collection/overview")] + + /// + /// 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; + + _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)); + } + } + + /// + /// 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(); + } } } -- 2.43.0 From 6bcc67bb636930e2416b3c896dbb91e1143b71c5 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Fri, 5 Dec 2025 15:40:26 +0530 Subject: [PATCH 2/6] Added an API to get purchase invoice overview --- .../Controllers/DashboardController.cs | 156 +++++++++++++++++- 1 file changed, 155 insertions(+), 1 deletion(-) diff --git a/Marco.Pms.Services/Controllers/DashboardController.cs b/Marco.Pms.Services/Controllers/DashboardController.cs index 964160e..bf5ec4e 100644 --- a/Marco.Pms.Services/Controllers/DashboardController.cs +++ b/Marco.Pms.Services/Controllers/DashboardController.cs @@ -1225,7 +1225,11 @@ namespace Marco.Pms.Services.Controllers { // 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 @@ -1455,6 +1459,156 @@ namespace Marco.Pms.Services.Controllers } } + [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. /// -- 2.43.0 From 852ddc7e02ac20930404afb6363bd4e92391b0f3 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Fri, 5 Dec 2025 19:06:21 +0530 Subject: [PATCH 3/6] Optimized the side menu APIs --- .../Data/ApplicationDbContext.cs | 1 - .../Utility/SidebarMenuHelper.cs | 68 +++ Marco.Pms.Model/AppMenu/WebMenuSection.cs | 19 + Marco.Pms.Model/AppMenu/WebSideMenuItem.cs | 22 + .../Dtos/AppMenu/CreateWebMenuSectionDto.cs | 9 + .../Dtos/AppMenu/CreateWebSideMenuItemDto.cs | 15 + .../ViewModels/AppMenu/WebMenuSectionVM.cs | 12 + .../ViewModels/AppMenu/WebSideMenuItemVM.cs | 12 + .../Controllers/AppMenuController.cs | 519 +++++------------- .../MappingProfiles/MappingProfile.cs | 20 + 10 files changed, 301 insertions(+), 396 deletions(-) create mode 100644 Marco.Pms.Model/AppMenu/WebMenuSection.cs create mode 100644 Marco.Pms.Model/AppMenu/WebSideMenuItem.cs create mode 100644 Marco.Pms.Model/Dtos/AppMenu/CreateWebMenuSectionDto.cs create mode 100644 Marco.Pms.Model/Dtos/AppMenu/CreateWebSideMenuItemDto.cs create mode 100644 Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs create mode 100644 Marco.Pms.Model/ViewModels/AppMenu/WebSideMenuItemVM.cs 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..9a17110 100644 --- a/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs +++ b/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs @@ -9,6 +9,7 @@ namespace Marco.Pms.CacheHelper public class SidebarMenuHelper { private readonly IMongoCollection _collection; + private readonly IMongoCollection _webCollection; private readonly ILogger _logger; public SidebarMenuHelper(IConfiguration configuration, ILogger logger) @@ -19,8 +20,75 @@ namespace Marco.Pms.CacheHelper var client = new MongoClient(mongoUrl); var database = client.GetDatabase(mongoUrl.DatabaseName); _collection = database.GetCollection("Menus"); + _webCollection = database.GetCollection("WebSideMenus"); } + public async Task> GetAllWebMenuSectionsAsync(Guid tenantId) + { + try + { + var filter = Builders.Filter.Eq(e => e.TenantId, tenantId); + + var result = await _webCollection + .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 _webCollection + .Find(filter) + .ToListAsync(); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while fetching Web Menu Sections."); + return new List(); + } + } + + public async Task CreateWebMenuSectionAsync(WebMenuSection section) + { + try + { + await _webCollection.InsertOneAsync(section); + return section; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while adding Web Menu Section."); + return null; + } + } + public async Task AddWebMenuItemAsync(Guid sectionId, List newItems) + { + try + { + + var filter = Builders.Filter.Eq(s => s.Id, sectionId); + + var update = Builders.Update.PushEach(s => s.Items, newItems); + + var result = await _webCollection.UpdateOneAsync(filter, update); + + if (result.ModifiedCount > 0) + { + return await _webCollection.Find(s => s.Id == sectionId).FirstOrDefaultAsync(); + } + + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding menu item."); + return null; + } + } public async Task CreateMenuSectionAsync(MenuSection section) { try diff --git a/Marco.Pms.Model/AppMenu/WebMenuSection.cs b/Marco.Pms.Model/AppMenu/WebMenuSection.cs new file mode 100644 index 0000000..121d918 --- /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? Title { 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..f45bf6f --- /dev/null +++ b/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs @@ -0,0 +1,22 @@ +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? Text { 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(); + } +} diff --git a/Marco.Pms.Model/Dtos/AppMenu/CreateWebMenuSectionDto.cs b/Marco.Pms.Model/Dtos/AppMenu/CreateWebMenuSectionDto.cs new file mode 100644 index 0000000..b5d432b --- /dev/null +++ b/Marco.Pms.Model/Dtos/AppMenu/CreateWebMenuSectionDto.cs @@ -0,0 +1,9 @@ +namespace Marco.Pms.Model.Dtos.AppMenu +{ + public class CreateWebMenuSectionDto + { + 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..e07244c --- /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? Text { 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/ViewModels/AppMenu/WebMenuSectionVM.cs b/Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs new file mode 100644 index 0000000..d233cf8 --- /dev/null +++ b/Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs @@ -0,0 +1,12 @@ +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(); + public Guid TenantId { get; set; } + } +} diff --git a/Marco.Pms.Model/ViewModels/AppMenu/WebSideMenuItemVM.cs b/Marco.Pms.Model/ViewModels/AppMenu/WebSideMenuItemVM.cs new file mode 100644 index 0000000..11446fa --- /dev/null +++ b/Marco.Pms.Model/ViewModels/AppMenu/WebSideMenuItemVM.cs @@ -0,0 +1,12 @@ +namespace Marco.Pms.Model.ViewModels.AppMenu +{ + 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(); + } +} diff --git a/Marco.Pms.Services/Controllers/AppMenuController.cs b/Marco.Pms.Services/Controllers/AppMenuController.cs index 82b122f..f42d1f5 100644 --- a/Marco.Pms.Services/Controllers/AppMenuController.cs +++ b/Marco.Pms.Services/Controllers/AppMenuController.cs @@ -53,16 +53,123 @@ namespace Marco.Pms.Services.Controllers tenantId = userHelper.GetTenantId(); } + [HttpGet("get/menu")] + public async Task GetAppSideBarMenuAsync() + { + // Correlation ID for distributed tracing across services and logs. + var correlationId = HttpContext.TraceIdentifier; - /// - /// Creates a new sidebar menu section for the tenant. - /// Only accessible by root users or for the super tenant. - /// - /// The data for the new menu section. - /// HTTP response with result of the operation. + _logger.LogInfo("GetAppSideBarMenuAsync started. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); + + try + { + // Step 1: Validate tenant context + if (tenantId == Guid.Empty) + { + _logger.LogWarning("GetAppSideBarMenuAsync rejected: Invalid TenantId. CorrelationId: {CorrelationId}", correlationId); + + return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "The tenant identifier provided is invalid or missing.", 400)); + } + + // Step 2: Resolve current employee context for permission checks + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + var employeeId = loggedInEmployee.Id; + + // Step 3: Create scoped permission service (avoid capturing scoped services in controller) + using var scope = _serviceScopeFactory.CreateScope(); + var permissions = scope.ServiceProvider.GetRequiredService(); + + _logger.LogDebug("GetAppSideBarMenuAsync resolved employee context. EmployeeId: {EmployeeId}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", + employeeId, tenantId, correlationId); + + // Step 4: Fetch all menu sections for tenant (null-safe check) + var menus = await _sideBarMenuHelper.GetAllWebMenuSectionsAsync(tenantId); + if (menus == null || !menus.Any()) + { + _logger.LogInfo("GetAppSideBarMenuAsync: No menu sections found. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + return Ok(ApiResponse.SuccessResponse(new List(), "No sidebar menu sections configured for this tenant.", 200)); + } + + _logger.LogDebug("GetAppSideBarMenuAsync loaded {MenuSectionCount} menu sections. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + menus.Count, tenantId, correlationId); + + // Step 5: Filter and build menu response with permission checks + var response = new List(); + + foreach (var menuSection in menus) + { + var sectionVM = _mapper.Map(menuSection); + if (menuSection.Items == null) + { + _logger.LogDebug("Skipping menu section with null items. SectionId: {SectionId}, TenantId: {TenantId}", menuSection.Id, tenantId); + continue; + } + + + foreach (var menuItem in menuSection.Items) + { + // Skip items without permission check if required + if (menuItem.PermissionIds.Any()) + { + var hasPermission = await permissions.HasPermissionAny(menuItem.PermissionIds, employeeId); + if (!hasPermission) + { + _logger.LogDebug("Menu item access denied due to missing permissions. ItemId: {ItemId}, EmployeeId: {EmployeeId}, TenantId: {TenantId}", + menuItem.Id, employeeId, tenantId); + continue; + } + } + + var itemVM = _mapper.Map(menuItem); + if (menuItem.ParentMenuId.HasValue) + { + sectionVM.Items.Where(i => i.Id == menuItem.ParentMenuId.Value).FirstOrDefault()?.Submenu.Add(itemVM); + } + else + { + sectionVM.Items.Add(itemVM); + } + } + + // Only include sections with at least one accessible item + if (sectionVM.Items.Any()) + { + response.Add(sectionVM); + } + } + + _logger.LogInfo("GetAppSideBarMenuAsync completed successfully. TenantId: {TenantId}, EmployeeId: {EmployeeId}, OriginalSections: {OriginalCount}, FilteredSections: {FilteredCount}, CorrelationId: {CorrelationId}", + tenantId, employeeId, menus.Count, response.Count, correlationId); + + return Ok(ApiResponse.SuccessResponse(response, $"Sidebar menu fetched successfully. {response.Count} sections returned.", 200)); + } + catch (OperationCanceledException) + { + _logger.LogWarning("GetAppSideBarMenuAsync cancelled. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + return StatusCode(499, ApiResponse.ErrorResponse("Request Cancelled", "The request was cancelled by the client.", 499)); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogError(ex, "GetAppSideBarMenuAsync authorization failed. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied", "Insufficient permissions to access menu sections.", 403))); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error in GetAppSideBarMenuAsync. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred while fetching the sidebar menu.", 500)); + } + } [HttpPost("add/sidebar/menu-section")] - public async Task CreateAppSideBarMenu([FromBody] CreateMenuSectionDto menuSectionDto) + public async Task CreateAppSideBarMenuAsync([FromBody] CreateWebMenuSectionDto model) { // Step 1: Fetch logged-in user var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); @@ -76,17 +183,17 @@ namespace Marco.Pms.Services.Controllers } // Step 3: Map DTO to entity - var sideMenuSection = _mapper.Map(menuSectionDto); + var sideMenuSection = _mapper.Map(model); sideMenuSection.TenantId = tenantId; try { // Step 4: Save entity using helper - sideMenuSection = await _sideBarMenuHelper.CreateMenuSectionAsync(sideMenuSection); + sideMenuSection = await _sideBarMenuHelper.CreateWebMenuSectionAsync(sideMenuSection); if (sideMenuSection == null) { - _logger.LogWarning("Failed to create sidebar menu section. Tenant: {TenantId}, Request: {@MenuSectionDto}", tenantId, menuSectionDto); + _logger.LogWarning("Failed to create sidebar menu section. Tenant: {TenantId}, Request: {@MenuSectionDto}", tenantId, model); return BadRequest(ApiResponse.ErrorResponse("Invalid MenuSection", 400)); } @@ -100,86 +207,14 @@ namespace Marco.Pms.Services.Controllers { // 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); + tenantId, loggedInEmployee.Id, model); return StatusCode(500, ApiResponse.ErrorResponse("Server Error", "An unexpected error occurred.", 500)); } } - /// - /// 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) - { - // 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 sidebar menu section {SectionId} in Tenant {TenantId}", - loggedInEmployee.Id, sectionId, 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) - { - _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) + [HttpPost("add/sidebar/section/{sectionId}/items")] + public async Task AddMenuItemAsync(Guid sectionId, [FromBody] List model) { // Step 1: Fetch logged-in user var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); @@ -195,7 +230,7 @@ namespace Marco.Pms.Services.Controllers } // Step 3: Input validation - if (sectionId == Guid.Empty || newItemDto == null) + if (sectionId == Guid.Empty || model == null) { _logger.LogWarning("Invalid AddMenuItem request. Tenant: {TenantId}, SectionId: {SectionId}, UserId: {UserId}", tenantId, sectionId, loggedInEmployee.Id); @@ -206,10 +241,10 @@ namespace Marco.Pms.Services.Controllers try { // Step 4: Map DTO to entity - var menuItemEntity = _mapper.Map(newItemDto); + var menuItemEntity = _mapper.Map>(model); // Step 5: Perform Add operation - var result = await _sideBarMenuHelper.AddMenuItemAsync(sectionId, menuItemEntity); + var result = await _sideBarMenuHelper.AddWebMenuItemAsync(sectionId, menuItemEntity); if (result == null) { @@ -229,318 +264,12 @@ namespace Marco.Pms.Services.Controllers { // 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); + sectionId, 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. - /// - /// 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. - - [HttpPut("edit/sidebar/{sectionId}/items/{itemId}")] - public async Task UpdateMenuItem(Guid sectionId, Guid itemId, [FromBody] UpdateMenuItemDto updatedMenuItem) - { - // 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 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); - - try - { - // Step 5: Perform update operation - var result = await _sideBarMenuHelper.UpdateMenuItemAsync(sectionId, itemId, menuItemEntity); - - if (result == null) - { - _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)); - } - - // 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) - { - _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); - - return NotFound(ApiResponse.ErrorResponse("Parent menu item not found.", 404)); - } - - // 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); - - 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); - - 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) - { - _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); - - return NotFound(ApiResponse.ErrorResponse("Sub-menu item not found.", 404)); - } - - // 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); - - 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)) - { - menus = new List - { - 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); - } - } - } - - // Replace with filtered items - menu.Items = allowedItems; - } - - // Step 3: Log success - _logger.LogInfo("Fetched sidebar menu successfully. Tenant: {TenantId}, EmployeeId: {EmployeeId}, SectionsReturned: {Count}", - tenantId, employeeId, menus.Count); - - var response = _mapper.Map>(menus); - return Ok(ApiResponse.SuccessResponse(response, "Sidebar menu fetched successfully")); - } - 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)); - } - } - /// /// Retrieves the master menu list based on enabled features for the current tenant. /// diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 9a9dac8..b190bc0 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; @@ -564,19 +565,38 @@ namespace Marco.Pms.Services.MappingProfiles #region ======================================================= AppMenu ======================================================= CreateMap(); + CreateMap(); CreateMap(); CreateMap() .ForMember( dest => dest.Name, opt => opt.MapFrom(src => src.Title)); + CreateMap() + .ForMember( + dest => dest.Name, + opt => opt.MapFrom(src => src.Title)) + .ForMember( + dest => dest.Items, + opt => opt.MapFrom(src => new List())); + CreateMap(); + CreateMap() + .ForMember( + dest => dest.Id, + opt => opt.MapFrom(src => src.Id.HasValue ? src.Id.Value : Guid.NewGuid())); + CreateMap(); CreateMap() .ForMember( dest => dest.Name, opt => opt.MapFrom(src => src.Text)); + CreateMap() + .ForMember( + dest => dest.Name, + opt => opt.MapFrom(src => src.Text)); + CreateMap(); CreateMap(); CreateMap() -- 2.43.0 From 4c284f9904485114a9a0df91561678cce85d97dc Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Sat, 6 Dec 2025 12:06:41 +0530 Subject: [PATCH 4/6] Modified side menu APIs --- .../Utility/SidebarMenuHelper.cs | 233 ++------------- Marco.Pms.Model/AppMenu/WebSideMenuItem.cs | 3 + .../ViewModels/AppMenu/WebMenuSectionVM.cs | 1 - .../Controllers/AppMenuController.cs | 270 ++++++++++-------- .../Controllers/TenantController.cs | 5 +- .../Helpers/CacheUpdateHelper.cs | 2 +- .../Service/PermissionServices.cs | 229 +++++++++++---- 7 files changed, 346 insertions(+), 397 deletions(-) diff --git a/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs b/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs index 9a17110..844c584 100644 --- a/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs +++ b/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs @@ -1,15 +1,14 @@ using Marco.Pms.Model.AppMenu; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using MongoDB.Bson; using MongoDB.Driver; namespace Marco.Pms.CacheHelper { public class SidebarMenuHelper { - private readonly IMongoCollection _collection; - private readonly IMongoCollection _webCollection; + private readonly IMongoCollection _oldCollection; + private readonly IMongoCollection _collection; private readonly ILogger _logger; public SidebarMenuHelper(IConfiguration configuration, ILogger logger) @@ -19,17 +18,17 @@ namespace Marco.Pms.CacheHelper var mongoUrl = new MongoUrl(connectionString); var client = new MongoClient(mongoUrl); var database = client.GetDatabase(mongoUrl.DatabaseName); - _collection = database.GetCollection("Menus"); - _webCollection = database.GetCollection("WebSideMenus"); + _oldCollection = database.GetCollection("Menus"); + _collection = database.GetCollection("WebSideMenus"); } - public async Task> GetAllWebMenuSectionsAsync(Guid tenantId) + public async Task> GetAllWebMenuSectionsAsync(Guid tenantId) { try { - var filter = Builders.Filter.Eq(e => e.TenantId, tenantId); + var filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - var result = await _webCollection + var result = await _collection .Find(filter) .ToListAsync(); if (result.Any()) @@ -38,9 +37,9 @@ namespace Marco.Pms.CacheHelper } tenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26"); - filter = Builders.Filter.Eq(e => e.TenantId, tenantId); + filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - result = await _webCollection + result = await _collection .Find(filter) .ToListAsync(); return result; @@ -48,229 +47,29 @@ namespace Marco.Pms.CacheHelper catch (Exception ex) { _logger.LogError(ex, "Error occurred while fetching Web Menu Sections."); - return new List(); + return new List(); } } - public async Task CreateWebMenuSectionAsync(WebMenuSection section) + public async Task> AddWebMenuItemAsync(List newItems) { try { - await _webCollection.InsertOneAsync(section); - return section; + await _collection.InsertManyAsync(newItems); + return newItems; } catch (Exception ex) { _logger.LogError(ex, "Error occurred while adding Web Menu Section."); - return null; + return new List(); } } - public async Task AddWebMenuItemAsync(Guid sectionId, List newItems) - { - try - { - - var filter = Builders.Filter.Eq(s => s.Id, sectionId); - - var update = Builders.Update.PushEach(s => s.Items, newItems); - - var result = await _webCollection.UpdateOneAsync(filter, update); - - if (result.ModifiedCount > 0) - { - return await _webCollection.Find(s => s.Id == sectionId).FirstOrDefaultAsync(); - } - - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adding menu item."); - return null; - } - } - public async Task CreateMenuSectionAsync(MenuSection section) - { - try - { - await _collection.InsertOneAsync(section); - return section; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error occurred while adding MenuSection."); - return null; - } - } - - public async Task UpdateMenuSectionAsync(Guid sectionId, MenuSection updatedSection) - { - try - { - var filter = Builders.Filter.Eq(s => s.Id, sectionId); - - var update = Builders.Update - .Set(s => s.Header, updatedSection.Header) - .Set(s => s.Title, updatedSection.Title) - .Set(s => s.Items, updatedSection.Items); - - var result = await _collection.UpdateOneAsync(filter, update); - - if (result.ModifiedCount > 0) - { - return await _collection.Find(s => s.Id == sectionId).FirstOrDefaultAsync(); - } - - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating MenuSection."); - return null; - } - } - - public async Task AddMenuItemAsync(Guid sectionId, MenuItem newItem) - { - try - { - newItem.Id = Guid.NewGuid(); - - var filter = Builders.Filter.Eq(s => s.Id, sectionId); - - var update = Builders.Update.Push(s => s.Items, newItem); - - var result = await _collection.UpdateOneAsync(filter, update); - - if (result.ModifiedCount > 0) - { - return await _collection.Find(s => s.Id == sectionId).FirstOrDefaultAsync(); - } - - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adding menu item."); - return null; - } - } - - public async Task UpdateMenuItemAsync(Guid sectionId, Guid itemId, MenuItem updatedItem) - { - try - { - var filter = Builders.Filter.And( - Builders.Filter.Eq(s => s.Id, sectionId), - Builders.Filter.ElemMatch(s => s.Items, i => i.Id == itemId) - ); - - var update = Builders.Update - .Set("Items.$.Text", updatedItem.Text) - .Set("Items.$.Icon", updatedItem.Icon) - .Set("Items.$.Available", updatedItem.Available) - .Set("Items.$.Link", updatedItem.Link) - .Set("Items.$.PermissionIds", updatedItem.PermissionIds); - - var result = await _collection.UpdateOneAsync(filter, update); - - if (result.ModifiedCount > 0) - { - // Re-fetch section and return the updated item - var section = await _collection.Find(s => s.Id == sectionId).FirstOrDefaultAsync(); - return section?.Items.FirstOrDefault(i => i.Id == itemId); - } - - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating MenuItem."); - return null; - } - } - - public async Task AddSubMenuItemAsync(Guid sectionId, Guid itemId, SubMenuItem newSubItem) - { - try - { - newSubItem.Id = Guid.NewGuid(); - - // Match the MenuSection and the specific MenuItem inside it - var filter = Builders.Filter.And( - Builders.Filter.Eq(s => s.Id, sectionId), - Builders.Filter.ElemMatch(s => s.Items, i => i.Id == itemId) - ); - - // Use positional operator `$` to target matched MenuItem and push into its Submenu - var update = Builders.Update.Push("Items.$.Submenu", newSubItem); - - var result = await _collection.UpdateOneAsync(filter, update); - - if (result.ModifiedCount > 0) - { - return await _collection.Find(s => s.Id == sectionId).FirstOrDefaultAsync(); - } - - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adding submenu item."); - return null; - } - } - - public async Task UpdateSubmenuItemAsync(Guid sectionId, Guid itemId, Guid subItemId, SubMenuItem updatedSub) - { - try - { - var filter = Builders.Filter.Eq(s => s.Id, sectionId); - - var arrayFilters = new List - { - new BsonDocumentArrayFilterDefinition( - new BsonDocument("item._id", itemId.ToString())), - new BsonDocumentArrayFilterDefinition( - new BsonDocument("sub._id", subItemId.ToString())) - }; - - var update = Builders.Update - .Set("Items.$[item].Submenu.$[sub].Text", updatedSub.Text) - .Set("Items.$[item].Submenu.$[sub].Available", updatedSub.Available) - .Set("Items.$[item].Submenu.$[sub].Link", updatedSub.Link) - .Set("Items.$[item].Submenu.$[sub].PermissionKeys", updatedSub.PermissionIds); - - var options = new UpdateOptions { ArrayFilters = arrayFilters }; - - var result = await _collection.UpdateOneAsync(filter, update, options); - - if (result.ModifiedCount == 0) - return null; - - var updatedSection = await _collection.Find(x => x.Id == sectionId).FirstOrDefaultAsync(); - - var subItem = updatedSection?.Items - .FirstOrDefault(i => i.Id == itemId)? - .Submenu - .FirstOrDefault(s => s.Id == subItemId); - - return subItem; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error updating SubMenuItem."); - return null; - } - } - - public async Task> GetAllMenuSectionsAsync(Guid tenantId) { var filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - var result = await _collection + var result = await _oldCollection .Find(filter) .ToListAsync(); if (result.Any()) @@ -281,7 +80,7 @@ namespace Marco.Pms.CacheHelper tenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26"); filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - result = await _collection + result = await _oldCollection .Find(filter) .ToListAsync(); return result; diff --git a/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs b/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs index f45bf6f..50483da 100644 --- a/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs +++ b/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs @@ -18,5 +18,8 @@ namespace Marco.Pms.Model.AppMenu [BsonRepresentation(BsonType.String)] public List PermissionIds { get; set; } = new List(); + + [BsonRepresentation(BsonType.String)] + public Guid TenantId { get; set; } } } diff --git a/Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs b/Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs index d233cf8..6c78ece 100644 --- a/Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs +++ b/Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs @@ -7,6 +7,5 @@ public string? Header { get; set; } public string? Name { get; set; } public List Items { get; set; } = new List(); - public Guid TenantId { get; set; } } } diff --git a/Marco.Pms.Services/Controllers/AppMenuController.cs b/Marco.Pms.Services/Controllers/AppMenuController.cs index f42d1f5..0e8a09f 100644 --- a/Marco.Pms.Services/Controllers/AppMenuController.cs +++ b/Marco.Pms.Services/Controllers/AppMenuController.cs @@ -53,218 +53,254 @@ namespace Marco.Pms.Services.Controllers tenantId = userHelper.GetTenantId(); } + /// + /// Returns the sidebar menu for the current tenant and logged-in employee, + /// filtered by permission and structured for the web application UI. + /// [HttpGet("get/menu")] public async Task GetAppSideBarMenuAsync() { - // Correlation ID for distributed tracing across services and logs. + // Correlation ID enables tracing this request across services and logs. var correlationId = HttpContext.TraceIdentifier; - _logger.LogInfo("GetAppSideBarMenuAsync started. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); + // Log the high-level intent and core context up front (no PII, no secrets). + _logger.LogInfo("GetAppSideBarMenuAsync started. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); try { - // Step 1: Validate tenant context + // 1. Validate tenant context if (tenantId == Guid.Empty) { - _logger.LogWarning("GetAppSideBarMenuAsync rejected: Invalid TenantId. CorrelationId: {CorrelationId}", correlationId); + _logger.LogWarning("GetAppSideBarMenuAsync rejected due to invalid tenant. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); - return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "The tenant identifier provided is invalid or missing.", 400)); + var error = ApiResponse.ErrorResponse("Invalid Tenant Identifier", "The tenant identifier is missing or invalid. Please verify the tenant context and try again.", 400); + + return BadRequest(error); } - // Step 2: Resolve current employee context for permission checks + // 2. Resolve current employee context + // - This call should throw or return a known result if the user is not authenticated. var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var employeeId = loggedInEmployee.Id; + if (loggedInEmployee is null) + { + _logger.LogWarning("GetAppSideBarMenuAsync failed: current employee not resolved. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); - // Step 3: Create scoped permission service (avoid capturing scoped services in controller) - using var scope = _serviceScopeFactory.CreateScope(); - var permissions = scope.ServiceProvider.GetRequiredService(); + var error = ApiResponse.ErrorResponse("User Context Not Found", "The current user context could not be resolved. Please re-authenticate and try again.", 403); + + return StatusCode(403, error); + } + + var employeeId = loggedInEmployee.Id; _logger.LogDebug("GetAppSideBarMenuAsync resolved employee context. EmployeeId: {EmployeeId}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", employeeId, tenantId, correlationId); - // Step 4: Fetch all menu sections for tenant (null-safe check) + // 3. Create scoped permission service + // - Avoid capturing scoped services directly in controller ctor when they depend on per-request state. + using var scope = _serviceScopeFactory.CreateScope(); + var permissionService = scope.ServiceProvider.GetRequiredService(); + + // 4. Preload all permission ids for the employee for efficient in-memory checks + var permissionIds = await permissionService.GetPermissionIdsByEmployeeId(employeeId); + + _logger.LogDebug("GetAppSideBarMenuAsync loaded {PermissionCount} permissions for EmployeeId: {EmployeeId}, CorrelationId: {CorrelationId}", + permissionIds.Count, employeeId, correlationId); + + // 5. Fetch all menu entries configured for this tenant var menus = await _sideBarMenuHelper.GetAllWebMenuSectionsAsync(tenantId); + if (menus == null || !menus.Any()) { - _logger.LogInfo("GetAppSideBarMenuAsync: No menu sections found. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + _logger.LogInfo("GetAppSideBarMenuAsync: No menu sections found for tenant. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); - return Ok(ApiResponse.SuccessResponse(new List(), "No sidebar menu sections configured for this tenant.", 200)); + + var emptyResponse = new List(); + + return Ok(ApiResponse.SuccessResponse(emptyResponse, "No sidebar menu sections are configured for the current tenant.", 200)); } - _logger.LogDebug("GetAppSideBarMenuAsync loaded {MenuSectionCount} menu sections. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + _logger.LogDebug("GetAppSideBarMenuAsync loaded {MenuSectionCount} raw menu records. TenantId: {TenantId}, CorrelationId: {CorrelationId}", menus.Count, tenantId, correlationId); - // Step 5: Filter and build menu response with permission checks - var response = new List(); + // 6. Build logical menu sections (root + children) and apply permission filtering + var responseSections = new List(); - foreach (var menuSection in menus) + // Root container section for the web UI. + var rootSection = new WebMenuSectionVM { - var sectionVM = _mapper.Map(menuSection); - if (menuSection.Items == null) + Id = Guid.Parse("4885d9f4-89b8-447d-9a95-7434b343dfda"), + Header = "Main Navigation", + Name = "Main Menu" + }; + + // To avoid multiple enumerations and improve readability, materialize once. + var menusList = menus.ToList(); + + foreach (var menu in menusList) + { + // Skip any non-root menu entry; these will be attached as children. + if (menu.ParentMenuId.HasValue) { - _logger.LogDebug("Skipping menu section with null items. SectionId: {SectionId}, TenantId: {TenantId}", menuSection.Id, tenantId); continue; } + var itemVm = _mapper.Map(menu); - foreach (var menuItem in menuSection.Items) + // If the menu requires any permission, check now. + if (menu.PermissionIds.Any()) { - // Skip items without permission check if required - if (menuItem.PermissionIds.Any()) + var hasMenuPermission = permissionService.HasPermissionAny(permissionIds, menu.PermissionIds, employeeId); + + if (!hasMenuPermission) { - var hasPermission = await permissions.HasPermissionAny(menuItem.PermissionIds, employeeId); - if (!hasPermission) + _logger.LogDebug("Menu item skipped due to insufficient permissions. MenuId: {MenuId}, EmployeeId: {EmployeeId}, CorrelationId: {CorrelationId}", + menu.Id, employeeId, correlationId); + continue; + } + } + + // Resolve submenus only for eligible root menu entries. + var subMenus = menusList + .Where(m => m.ParentMenuId.HasValue && m.ParentMenuId == menu.Id) + .ToList(); + + foreach (var subMenu in subMenus) + { + var subMenuVm = _mapper.Map(subMenu); + + // If the submenu requires permissions, validate before adding. + if (subMenu.PermissionIds.Any()) + { + var hasSubPermission = permissionService.HasPermissionAny(permissionIds, subMenu.PermissionIds, employeeId); + + if (!hasSubPermission) { - _logger.LogDebug("Menu item access denied due to missing permissions. ItemId: {ItemId}, EmployeeId: {EmployeeId}, TenantId: {TenantId}", - menuItem.Id, employeeId, tenantId); + _logger.LogDebug( + "Submenu item skipped due to insufficient permissions. MenuId: {MenuId}, ParentMenuId: {ParentMenuId}, EmployeeId: {EmployeeId}, CorrelationId: {CorrelationId}", + subMenu.Id, subMenu.ParentMenuId!, employeeId, correlationId); continue; } } - var itemVM = _mapper.Map(menuItem); - if (menuItem.ParentMenuId.HasValue) - { - sectionVM.Items.Where(i => i.Id == menuItem.ParentMenuId.Value).FirstOrDefault()?.Submenu.Add(itemVM); - } - else - { - sectionVM.Items.Add(itemVM); - } - } - - // Only include sections with at least one accessible item - if (sectionVM.Items.Any()) - { - response.Add(sectionVM); + // Add the submenu to the root section + itemVm.Submenu.Add(subMenuVm); } + // Add the root menu item + rootSection.Items.Add(itemVm); } - _logger.LogInfo("GetAppSideBarMenuAsync completed successfully. TenantId: {TenantId}, EmployeeId: {EmployeeId}, OriginalSections: {OriginalCount}, FilteredSections: {FilteredCount}, CorrelationId: {CorrelationId}", - tenantId, employeeId, menus.Count, response.Count, correlationId); + if (rootSection.Items.Any()) + { + responseSections.Add(rootSection); + } - return Ok(ApiResponse.SuccessResponse(response, $"Sidebar menu fetched successfully. {response.Count} sections returned.", 200)); + _logger.LogInfo( + "GetAppSideBarMenuAsync completed successfully. TenantId: {TenantId}, EmployeeId: {EmployeeId}, OriginalMenuCount: {OriginalCount}, ReturnedSectionCount: {SectionCount}, CorrelationId: {CorrelationId}", + tenantId, employeeId, menusList.Count, responseSections.Count, correlationId); + + return Ok(ApiResponse.SuccessResponse(responseSections, + responseSections.Any() + ? $"Sidebar menu fetched successfully. {responseSections.Count} section(s) returned." + : "No accessible sidebar menu items were found for the current user.", 200)); } catch (OperationCanceledException) { - _logger.LogWarning("GetAppSideBarMenuAsync cancelled. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + // This typically indicates client disconnected or explicit cancellation. + _logger.LogWarning("GetAppSideBarMenuAsync was cancelled. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); - return StatusCode(499, ApiResponse.ErrorResponse("Request Cancelled", "The request was cancelled by the client.", 499)); + var error = ApiResponse.ErrorResponse("Request Cancelled", "The operation was cancelled, likely due to a client disconnection or explicit cancellation request.", 499); // Non-standard but commonly used in APIs for client closed request. + + return StatusCode(499, error); } catch (UnauthorizedAccessException ex) { - _logger.LogError(ex, "GetAppSideBarMenuAsync authorization failed. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + // Handle authorization-related errors explicitly to avoid leaking details. + _logger.LogError(ex, "GetAppSideBarMenuAsync authorization failure. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); - return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied", "Insufficient permissions to access menu sections.", 403))); + var error = ApiResponse.ErrorResponse("Access Denied", "You do not have sufficient permissions to access the sidebar menu.", 403); + + return StatusCode(403, error); } catch (Exception ex) { - _logger.LogError(ex, "Unexpected error in GetAppSideBarMenuAsync. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + // Fallback handler for unexpected failures. Keep log detailed, response generic. + _logger.LogError(ex, "Unhandled exception in GetAppSideBarMenuAsync. TenantId: {TenantId}, CorrelationId: {CorrelationId}", tenantId, correlationId); - return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred while fetching the sidebar menu.", 500)); + var error = ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred while processing the sidebar menu request. Please try again later.", 500); + + return StatusCode(500, error); } } - [HttpPost("add/sidebar/menu-section")] - public async Task CreateAppSideBarMenuAsync([FromBody] CreateWebMenuSectionDto model) + + [HttpPost("add/side-menu")] + public async Task AddMenuItemAsync([FromBody] List model) { - // Step 1: Fetch logged-in user + // Step 1: Validate tenant context + if (tenantId == Guid.Empty) + { + _logger.LogWarning("GetAppSideBarMenuAsync rejected: Invalid TenantId."); + + return BadRequest(ApiResponse.ErrorResponse("Invalid TenantId", "The tenant identifier provided is invalid or missing.", 400)); + } + + // Step 2: Fetch logged-in user var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var isRootUser = loggedInEmployee.ApplicationUser?.IsRootUser ?? false; - // Step 2: Authorization check - if (!isRootUser || tenantId != superTenantId) - { - _logger.LogWarning("Access denied: Employee {EmployeeId} attempted to create sidebar menu in Tenant {TenantId}", loggedInEmployee.Id, tenantId); - return StatusCode(403, ApiResponse.ErrorResponse("Access Denied", "User does not have permission.", 403)); - } - - // Step 3: Map DTO to entity - var sideMenuSection = _mapper.Map(model); - sideMenuSection.TenantId = tenantId; - - try - { - // Step 4: Save entity using helper - sideMenuSection = await _sideBarMenuHelper.CreateWebMenuSectionAsync(sideMenuSection); - - if (sideMenuSection == null) - { - _logger.LogWarning("Failed to create sidebar menu section. Tenant: {TenantId}, Request: {@MenuSectionDto}", tenantId, model); - return BadRequest(ApiResponse.ErrorResponse("Invalid MenuSection", 400)); - } - - // Step 5: Log success - _logger.LogInfo("Sidebar menu created successfully. SectionId: {SectionId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", - sideMenuSection.Id, tenantId, loggedInEmployee.Id); - - return Ok(ApiResponse.SuccessResponse(sideMenuSection, "Sidebar menu created successfully.", 201)); - } - catch (Exception ex) - { - // Step 6: Handle and log unexpected server errors - _logger.LogError(ex, "Unexpected error occurred while creating sidebar menu. Tenant: {TenantId}, EmployeeId: {EmployeeId}, Request: {@MenuSectionDto}", - tenantId, loggedInEmployee.Id, model); - - return StatusCode(500, ApiResponse.ErrorResponse("Server Error", "An unexpected error occurred.", 500)); - } - } - - [HttpPost("add/sidebar/section/{sectionId}/items")] - public async Task AddMenuItemAsync(Guid sectionId, [FromBody] List model) - { - // Step 1: Fetch logged-in user - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var isRootUser = loggedInEmployee.ApplicationUser?.IsRootUser ?? false; - - // Step 2: Authorization check + // Step 3: Authorization check if (!isRootUser && tenantId != superTenantId) { - _logger.LogWarning("Access denied: User {UserId} attempted to add menu item to section {SectionId} in Tenant {TenantId}", - loggedInEmployee.Id, sectionId, tenantId); + _logger.LogWarning("Access denied: User {UserId} attempted to add menu item in Tenant {TenantId}", + loggedInEmployee.Id, tenantId); return StatusCode(403, ApiResponse.ErrorResponse("Access Denied", "User does not have permission.", 403)); } - // Step 3: Input validation - if (sectionId == Guid.Empty || model == null) + // Step 4: Input validation + if (model == null) { - _logger.LogWarning("Invalid AddMenuItem request. Tenant: {TenantId}, SectionId: {SectionId}, UserId: {UserId}", - tenantId, sectionId, loggedInEmployee.Id); + _logger.LogWarning("Invalid AddMenuItem request. Tenant: {TenantId}, UserId: {UserId}", + tenantId, loggedInEmployee.Id); return BadRequest(ApiResponse.ErrorResponse("Invalid section ID or menu item payload.", 400)); } try { - // Step 4: Map DTO to entity + // Step 5: Map DTO to entity var menuItemEntity = _mapper.Map>(model); - // Step 5: Perform Add operation - var result = await _sideBarMenuHelper.AddWebMenuItemAsync(sectionId, menuItemEntity); + menuItemEntity.ForEach(m => m.TenantId = tenantId); + + // Step 6: Perform Add operation + var result = await _sideBarMenuHelper.AddWebMenuItemAsync(menuItemEntity); if (result == null) { - _logger.LogWarning("Menu section not found. Unable to add menu item. SectionId: {SectionId}, TenantId: {TenantId}, UserId: {UserId}", - sectionId, tenantId, loggedInEmployee.Id); + _logger.LogWarning("Menu section not found. Unable to add menu item. TenantId: {TenantId}, UserId: {UserId}", tenantId, loggedInEmployee.Id); return NotFound(ApiResponse.ErrorResponse("Menu section not found", 404)); } - // Step 6: Successful addition - _logger.LogInfo("Menu item added successfully. SectionId: {SectionId}, MenuItemId: {MenuItemId}, TenantId: {TenantId}, UserId: {UserId}", - sectionId, result.Id, tenantId, loggedInEmployee.Id); + // Step 7: Successful addition + _logger.LogInfo("Menu items added successfully TenantId: {TenantId}, UserId: {UserId}", + tenantId, loggedInEmployee.Id); return Ok(ApiResponse.SuccessResponse(result, "Menu item added successfully")); } catch (Exception ex) { - // Step 7: Handle unexpected errors - _logger.LogError(ex, "Error occurred while adding menu item. SectionId: {SectionId}, TenantId: {TenantId}, UserId: {UserId}, Payload: {@NewItemDto}", - sectionId, tenantId, loggedInEmployee.Id, model); + // Step 8: Handle unexpected errors + _logger.LogError(ex, "Error occurred while adding menu item. TenantId: {TenantId}, UserId: {UserId}, Payload: {@NewItemDto}", + tenantId, loggedInEmployee.Id, model); return StatusCode(500, ApiResponse.ErrorResponse("Server error", "An unexpected error occurred while adding the menu item.", 500)); } diff --git a/Marco.Pms.Services/Controllers/TenantController.cs b/Marco.Pms.Services/Controllers/TenantController.cs index 6f8f130..899e4a5 100644 --- a/Marco.Pms.Services/Controllers/TenantController.cs +++ b/Marco.Pms.Services/Controllers/TenantController.cs @@ -384,7 +384,10 @@ namespace Marco.Pms.Services.Controllers response.CreatedBy = createdBy; response.CurrentPlan = _mapper.Map(currentPlan); - response.CurrentPlan.PaymentDetail = paymentsDetails.FirstOrDefault(pd => currentPlan != null && pd.Id == currentPlan.PaymentDetailId); + if (currentPlan != null) + { + response.CurrentPlan.PaymentDetail = paymentsDetails.FirstOrDefault(pd => pd.Id == currentPlan.PaymentDetailId); + } response.CurrentPlanFeatures = await _featureDetailsHelper.GetFeatureDetails(currentPlan?.Plan?.FeaturesId ?? Guid.Empty); // Map subscription history plans to DTO diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index 4dd5377..a37ab63 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -809,7 +809,7 @@ namespace Marco.Pms.Services.Helpers } Task> getPermissionIdsTask = Task.Run(async () => { - using var context = _dbContextFactory.CreateDbContext(); + await using var context = await _dbContextFactory.CreateDbContextAsync(); return await context.RolePermissionMappings .Where(rp => roleIds.Contains(rp.ApplicationRoleId)) diff --git a/Marco.Pms.Services/Service/PermissionServices.cs b/Marco.Pms.Services/Service/PermissionServices.cs index be759dc..cbe5dd2 100644 --- a/Marco.Pms.Services/Service/PermissionServices.cs +++ b/Marco.Pms.Services/Service/PermissionServices.cs @@ -4,6 +4,7 @@ using Marco.Pms.Model.Entitlements; using Marco.Pms.Services.Helpers; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; +using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; namespace Marco.Pms.Services.Service @@ -11,15 +12,13 @@ namespace Marco.Pms.Services.Service public class PermissionServices { private readonly ApplicationDbContext _context; - private readonly RolesHelper _rolesHelper; private readonly CacheUpdateHelper _cache; private readonly ILoggingService _logger; private readonly Guid tenantId; - public PermissionServices(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache, ILoggingService logger, UserHelper userHelper) + public PermissionServices(ApplicationDbContext context, CacheUpdateHelper cache, ILoggingService logger, UserHelper userHelper) { _context = context; - _rolesHelper = rolesHelper; _cache = cache; _logger = logger; tenantId = userHelper.GetTenantId(); @@ -34,72 +33,23 @@ namespace Marco.Pms.Services.Service /// True if the user has the permission, otherwise false. public async Task HasPermission(Guid featurePermissionId, Guid employeeId, Guid? projectId = null) { - // 1. Try fetching permissions from cache (fast-path lookup). - var featurePermissionIds = await _cache.GetPermissions(employeeId, tenantId); + var featurePermissionIds = await GetPermissionIdsByEmployeeId(employeeId, projectId); - // If not found in cache, fallback to database (slower). - if (featurePermissionIds == null) - { - var featurePermissions = await _rolesHelper.GetFeaturePermissionByEmployeeId(employeeId, tenantId); - featurePermissionIds = featurePermissions.Select(fp => fp.Id).ToList(); - } - - // 2. Handle project-level permission overrides if a project is specified. - if (projectId.HasValue) - { - // Fetch permissions explicitly assigned to this employee in the project. - var projectLevelPermissionIds = await _context.ProjectLevelPermissionMappings - .AsNoTracking() - .Where(pl => pl.ProjectId == projectId.Value && pl.EmployeeId == employeeId) - .Select(pl => pl.PermissionId) - .ToListAsync(); - - if (projectLevelPermissionIds?.Any() ?? false) - { - - // Define modules where project-level overrides apply. - var projectLevelModuleIds = new HashSet - { - Guid.Parse("53176ebf-c75d-42e5-839f-4508ffac3def"), - Guid.Parse("9d4b5489-2079-40b9-bd77-6e1bf90bc19f"), - Guid.Parse("52c9cf54-1eb2-44d2-81bb-524cf29c0a94"), - Guid.Parse("a8cf4331-8f04-4961-8360-a3f7c3cc7462") - }; - - // Get all feature permissions under those modules where the user didn't have explicit project-level grants. - var allOverriddenPermissions = await _context.FeaturePermissions - .AsNoTracking() - .Where(fp => projectLevelModuleIds.Contains(fp.FeatureId) && - !projectLevelPermissionIds.Contains(fp.Id)) - .Select(fp => fp.Id) - .ToListAsync(); - - // Apply overrides: - // - Remove global permissions overridden by project-level rules. - // - Add explicit project-level permissions. - featurePermissionIds = featurePermissionIds - .Except(allOverriddenPermissions) // Remove overridden - .Concat(projectLevelPermissionIds) // Add project-level - .Distinct() // Ensure no duplicates - .ToList(); - } - } - - // 3. Final check: does the employee have the requested permission? return featurePermissionIds.Contains(featurePermissionId); } public async Task HasPermissionAny(List featurePermissionIds, Guid employeeId) { - var allFeaturePermissionIds = await _cache.GetPermissions(employeeId, tenantId); - if (allFeaturePermissionIds == null) - { - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeId(employeeId, tenantId); - allFeaturePermissionIds = featurePermission.Select(fp => fp.Id).ToList(); - } + var allFeaturePermissionIds = await GetPermissionIdsByEmployeeId(employeeId); + var hasPermission = featurePermissionIds.Any(f => allFeaturePermissionIds.Contains(f)); return hasPermission; } + public bool HasPermissionAny(List realPermissionIds, List toCheckPermissionIds, Guid employeeId) + { + var hasPermission = toCheckPermissionIds.Any(f => realPermissionIds.Contains(f)); + return hasPermission; + } public async Task HasProjectPermission(Employee LoggedInEmployee, Guid projectId) { var employeeId = LoggedInEmployee.Id; @@ -199,5 +149,164 @@ namespace Marco.Pms.Services.Service return false; } } + + /// + /// Retrieves permission IDs for an employee, supporting both global role-based permissions + /// and project-specific overrides with cache-first strategy. + /// + /// The ID of the employee to fetch permissions for. + /// Optional project ID for project-level permission overrides. + /// List of unique permission IDs the employee has access to. + /// Thrown when employeeId or tenantId is empty. + public async Task> GetPermissionIdsByEmployeeId(Guid employeeId, Guid? projectId = null) + { + // Input validation + if (employeeId == Guid.Empty) + { + _logger.LogWarning("EmployeeId cannot be empty."); + return new List(); + } + + if (tenantId == Guid.Empty) + { + _logger.LogWarning("TenantId cannot be empty."); + return new List(); + } + _logger.LogDebug( + "GetPermissionIdsByEmployeeId started. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}, TenantId: {TenantId}", + employeeId, projectId ?? Guid.Empty, tenantId); + + try + { + // Phase 1: Cache-first lookup for role-based permissions (fast path) + var featurePermissionIds = await _cache.GetPermissions(employeeId, tenantId); + var permissionsFromCache = featurePermissionIds != null; + + _logger.LogDebug( + "Permission lookup from cache: {CacheHit}, InitialPermissions: {PermissionCount}, EmployeeId: {EmployeeId}", + permissionsFromCache, featurePermissionIds?.Count ?? 0, employeeId); + + // Phase 2: Database fallback if cache miss + if (featurePermissionIds == null) + { + _logger.LogDebug( + "Cache miss detected, falling back to database lookup. EmployeeId: {EmployeeId}, TenantId: {TenantId}", + employeeId, tenantId); + + var roleIds = await _context.EmployeeRoleMappings + .Where(erm => erm.EmployeeId == employeeId && erm.TenantId == tenantId) + .Select(erm => erm.RoleId) + .ToListAsync(); + + if (!roleIds.Any()) + { + _logger.LogDebug( + "No roles found for employee. EmployeeId: {EmployeeId}, TenantId: {TenantId}", + employeeId, tenantId); + return new List(); + } + + featurePermissionIds = await _context.RolePermissionMappings + .Where(rpm => roleIds.Contains(rpm.ApplicationRoleId)) + .Select(rpm => rpm.FeaturePermissionId) + .Distinct() + .ToListAsync(); + + // The cache service might also need its own context, or you can pass the data directly. + // Assuming AddApplicationRole takes the data, not a context. + await _cache.AddApplicationRole(employeeId, roleIds, tenantId); + _logger.LogInfo("Successfully queued cache update for EmployeeId: {EmployeeId}", employeeId); + + _logger.LogDebug( + "Loaded {RoleCount} roles → {PermissionCount} permissions from database. EmployeeId: {EmployeeId}", + roleIds.Count, featurePermissionIds.Count, employeeId); + } + + // Early return for global permissions (no project context) + if (!projectId.HasValue) + { + _logger.LogDebug( + "Returning global permissions. Count: {PermissionCount}, EmployeeId: {EmployeeId}", + featurePermissionIds.Count, employeeId); + return featurePermissionIds; + } + + // Phase 3: Apply project-level permission overrides + _logger.LogDebug( + "Applying project-level overrides. ProjectId: {ProjectId}, EmployeeId: {EmployeeId}", + projectId.Value, employeeId); + + var projectLevelPermissionIds = await _context.ProjectLevelPermissionMappings + .AsNoTracking() + .Where(pl => pl.ProjectId == projectId.Value && + pl.EmployeeId == employeeId) + .Select(pl => pl.PermissionId) + .Distinct() + .ToListAsync(); + + if (!projectLevelPermissionIds.Any()) + { + _logger.LogDebug( + "No project-level permissions found. ProjectId: {ProjectId}, EmployeeId: {EmployeeId}", + projectId.Value, employeeId); + return featurePermissionIds; + } + + // Phase 4: Override logic for specific project modules + var projectOverrideModules = new HashSet + { + // Hard-coded module IDs for project-level override scope + // TODO: Consider moving to configuration or database lookup + Guid.Parse("53176ebf-c75d-42e5-839f-4508ffac3def"), // Module: Projects + Guid.Parse("9d4b5489-2079-40b9-bd77-6e1bf90bc19f"), // Module: Expenses + Guid.Parse("52c9cf54-1eb2-44d2-81bb-524cf29c0a94"), // Module: Invoices + Guid.Parse("a8cf4331-8f04-4961-8360-a3f7c3cc7462") // Module: Documents + }; + + // Find permissions in override modules that employee lacks at project level + var overriddenPermissions = await _context.FeaturePermissions + .AsNoTracking() + .Where(fp => projectOverrideModules.Contains(fp.FeatureId) && + !projectLevelPermissionIds.Contains(fp.Id)) + .Select(fp => fp.Id) + .ToListAsync(); + + // Apply override rules: + // 1. Remove global permissions overridden by project context + // 2. Add explicit project-level grants + // 3. Ensure uniqueness + var finalPermissions = featurePermissionIds + .Except(overriddenPermissions) // Remove overridden global perms + .Concat(projectLevelPermissionIds) // Add project-specific grants + .Distinct() // Deduplicate + .ToList(); + + _logger.LogDebug( + "Project override applied. Before: {BeforeCount}, After: {AfterCount}, Added: {AddedCount}, Removed: {RemovedCount}, EmployeeId: {EmployeeId}", + featurePermissionIds.Count, finalPermissions.Count, + projectLevelPermissionIds.Count, overriddenPermissions.Count, employeeId); + + return finalPermissions; + } + catch (OperationCanceledException) + { + _logger.LogWarning("GetPermissionIdsByEmployeeId cancelled. EmployeeId: {EmployeeId}", employeeId); + + return new List(); + } + catch (DbUpdateException ex) + { + _logger.LogError(ex, "Database error in GetPermissionIdsByEmployeeId. EmployeeId: {EmployeeId}, TenantId: {TenantId}", employeeId, tenantId); + + return new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error in GetPermissionIdsByEmployeeId. EmployeeId: {EmployeeId}, TenantId: {TenantId}", employeeId, tenantId); + + return new List(); + } + } + } } -- 2.43.0 From c5949606aa404ccf48e3c61fed4f843fe4970d35 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Sat, 6 Dec 2025 12:49:46 +0530 Subject: [PATCH 5/6] Changed the text to name --- Marco.Pms.Model/AppMenu/WebMenuSection.cs | 2 +- Marco.Pms.Model/AppMenu/WebSideMenuItem.cs | 2 +- .../Dtos/AppMenu/CreateMenuItemDto.cs | 17 ------------ .../Dtos/AppMenu/CreateMenuSectionDto.cs | 9 ------- .../Dtos/AppMenu/CreateSubMenuItemDto.cs | 13 --------- .../Dtos/AppMenu/UpdateMenuItemDto.cs | 17 ------------ .../Dtos/AppMenu/UpdateMenuSectionDto.cs | 9 ------- .../Dtos/AppMenu/UpdateSubMenuItemDto.cs | 16 ----------- .../ViewModels/DocumentManager/MenuItemVM.cs | 13 --------- .../DocumentManager/MenuSectionVM.cs | 11 -------- .../DocumentManager/SubMenuItemVM.cs | 12 --------- .../Controllers/AppMenuController.cs | 1 - .../MappingProfiles/MappingProfile.cs | 27 ++----------------- 13 files changed, 4 insertions(+), 145 deletions(-) delete mode 100644 Marco.Pms.Model/Dtos/AppMenu/CreateMenuItemDto.cs delete mode 100644 Marco.Pms.Model/Dtos/AppMenu/CreateMenuSectionDto.cs delete mode 100644 Marco.Pms.Model/Dtos/AppMenu/CreateSubMenuItemDto.cs delete mode 100644 Marco.Pms.Model/Dtos/AppMenu/UpdateMenuItemDto.cs delete mode 100644 Marco.Pms.Model/Dtos/AppMenu/UpdateMenuSectionDto.cs delete mode 100644 Marco.Pms.Model/Dtos/AppMenu/UpdateSubMenuItemDto.cs delete mode 100644 Marco.Pms.Model/ViewModels/DocumentManager/MenuItemVM.cs delete mode 100644 Marco.Pms.Model/ViewModels/DocumentManager/MenuSectionVM.cs delete mode 100644 Marco.Pms.Model/ViewModels/DocumentManager/SubMenuItemVM.cs diff --git a/Marco.Pms.Model/AppMenu/WebMenuSection.cs b/Marco.Pms.Model/AppMenu/WebMenuSection.cs index 121d918..1ccfcab 100644 --- a/Marco.Pms.Model/AppMenu/WebMenuSection.cs +++ b/Marco.Pms.Model/AppMenu/WebMenuSection.cs @@ -10,7 +10,7 @@ namespace Marco.Pms.Model.AppMenu public Guid Id { get; set; } = Guid.NewGuid(); public string? Header { get; set; } - public string? Title { get; set; } + public string? Name { get; set; } public List Items { get; set; } = new List(); [BsonRepresentation(BsonType.String)] diff --git a/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs b/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs index 50483da..d9e76ef 100644 --- a/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs +++ b/Marco.Pms.Model/AppMenu/WebSideMenuItem.cs @@ -11,7 +11,7 @@ namespace Marco.Pms.Model.AppMenu [BsonRepresentation(BsonType.String)] public Guid? ParentMenuId { get; set; } - public string? Text { 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; 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/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/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/UpdateMenuSectionDto.cs b/Marco.Pms.Model/Dtos/AppMenu/UpdateMenuSectionDto.cs deleted file mode 100644 index f42794e..0000000 --- a/Marco.Pms.Model/Dtos/AppMenu/UpdateMenuSectionDto.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Marco.Pms.Model.Dtos.AppMenu -{ - public class UpdateMenuSectionDto - { - public required Guid Id { get; set; } - public required string Header { get; set; } - public required string Title { get; set; } - } -} 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/DocumentManager/MenuItemVM.cs b/Marco.Pms.Model/ViewModels/DocumentManager/MenuItemVM.cs deleted file mode 100644 index 5925fe0..0000000 --- a/Marco.Pms.Model/ViewModels/DocumentManager/MenuItemVM.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Marco.Pms.Model.ViewModels.DocumentManager -{ - public class MenuItemVM - { - 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(); - } -} 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 0e8a09f..2fe63dd 100644 --- a/Marco.Pms.Services/Controllers/AppMenuController.cs +++ b/Marco.Pms.Services/Controllers/AppMenuController.cs @@ -239,7 +239,6 @@ namespace Marco.Pms.Services.Controllers } } - [HttpPost("add/side-menu")] public async Task AddMenuItemAsync([FromBody] List model) { diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index b190bc0..865e668 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -564,45 +564,22 @@ namespace Marco.Pms.Services.MappingProfiles #endregion #region ======================================================= AppMenu ======================================================= - CreateMap(); CreateMap(); - CreateMap(); - CreateMap() - .ForMember( - dest => dest.Name, - opt => opt.MapFrom(src => src.Title)); CreateMap() - .ForMember( - dest => dest.Name, - opt => opt.MapFrom(src => src.Title)) .ForMember( dest => dest.Items, opt => opt.MapFrom(src => new List())); - CreateMap(); + CreateMap() .ForMember( dest => dest.Id, opt => opt.MapFrom(src => src.Id.HasValue ? src.Id.Value : Guid.NewGuid())); - CreateMap(); - CreateMap() - .ForMember( - dest => dest.Name, - opt => opt.MapFrom(src => src.Text)); - CreateMap() - .ForMember( - dest => dest.Name, - opt => opt.MapFrom(src => src.Text)); + CreateMap(); - CreateMap(); - CreateMap(); - CreateMap() - .ForMember( - dest => dest.Name, - opt => opt.MapFrom(src => src.Text)); #endregion #region ======================================================= Directory ======================================================= -- 2.43.0 From 20df833c48d6b8dacbb2cc1afcf533b7a0301325 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Sat, 6 Dec 2025 15:14:57 +0530 Subject: [PATCH 6/6] Added API to get side menu for mobile --- .../Utility/SidebarMenuHelper.cs | 69 ++- Marco.Pms.Model/AppMenu/MobileMenu.cs | 21 + .../AppMenu/CreateMobileSideMenuItemDto.cs | 13 + .../Dtos/AppMenu/CreateWebSideMenuItemDto.cs | 2 +- .../Controllers/AppMenuController.cs | 444 ++++++++---------- .../MappingProfiles/MappingProfile.cs | 3 + 6 files changed, 281 insertions(+), 271 deletions(-) create mode 100644 Marco.Pms.Model/AppMenu/MobileMenu.cs create mode 100644 Marco.Pms.Model/Dtos/AppMenu/CreateMobileSideMenuItemDto.cs diff --git a/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs b/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs index 844c584..1ca6795 100644 --- a/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs +++ b/Marco.Pms.Helpers/Utility/SidebarMenuHelper.cs @@ -7,8 +7,8 @@ namespace Marco.Pms.CacheHelper { public class SidebarMenuHelper { - private readonly IMongoCollection _oldCollection; - private readonly IMongoCollection _collection; + private readonly IMongoCollection _webCollection; + private readonly IMongoCollection _mobileCollection; private readonly ILogger _logger; public SidebarMenuHelper(IConfiguration configuration, ILogger logger) @@ -18,8 +18,9 @@ namespace Marco.Pms.CacheHelper var mongoUrl = new MongoUrl(connectionString); var client = new MongoClient(mongoUrl); var database = client.GetDatabase(mongoUrl.DatabaseName); - _oldCollection = database.GetCollection("Menus"); - _collection = database.GetCollection("WebSideMenus"); + _webCollection = database.GetCollection("WebSideMenus"); + _mobileCollection = database.GetCollection("MobileSideMenus"); + } public async Task> GetAllWebMenuSectionsAsync(Guid tenantId) @@ -28,7 +29,7 @@ namespace Marco.Pms.CacheHelper { var filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - var result = await _collection + var result = await _webCollection .Find(filter) .ToListAsync(); if (result.Any()) @@ -39,7 +40,7 @@ namespace Marco.Pms.CacheHelper tenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26"); filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - result = await _collection + result = await _webCollection .Find(filter) .ToListAsync(); return result; @@ -50,12 +51,11 @@ namespace Marco.Pms.CacheHelper return new List(); } } - public async Task> AddWebMenuItemAsync(List newItems) { try { - await _collection.InsertManyAsync(newItems); + await _webCollection.InsertManyAsync(newItems); return newItems; } catch (Exception ex) @@ -64,27 +64,48 @@ namespace Marco.Pms.CacheHelper return new List(); } } - - public async Task> GetAllMenuSectionsAsync(Guid tenantId) + public async Task> GetAllMobileMenuSectionsAsync(Guid tenantId) { - var filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - - var result = await _oldCollection - .Find(filter) - .ToListAsync(); - if (result.Any()) + 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; } - - tenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26"); - filter = Builders.Filter.Eq(e => e.TenantId, tenantId); - - result = await _oldCollection - .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/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/CreateWebSideMenuItemDto.cs b/Marco.Pms.Model/Dtos/AppMenu/CreateWebSideMenuItemDto.cs index e07244c..3415a09 100644 --- a/Marco.Pms.Model/Dtos/AppMenu/CreateWebSideMenuItemDto.cs +++ b/Marco.Pms.Model/Dtos/AppMenu/CreateWebSideMenuItemDto.cs @@ -4,7 +4,7 @@ { public Guid? Id { get; set; } public Guid? ParentMenuId { get; set; } - public string? Text { 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; diff --git a/Marco.Pms.Services/Controllers/AppMenuController.cs b/Marco.Pms.Services/Controllers/AppMenuController.cs index 2fe63dd..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,6 +52,7 @@ namespace Marco.Pms.Services.Controllers _logger = logger; _serviceScopeFactory = serviceScopeFactory; tenantId = userHelper.GetTenantId(); + _permissionService = permissionService; } /// @@ -99,18 +101,13 @@ namespace Marco.Pms.Services.Controllers _logger.LogDebug("GetAppSideBarMenuAsync resolved employee context. EmployeeId: {EmployeeId}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", employeeId, tenantId, correlationId); - // 3. Create scoped permission service - // - Avoid capturing scoped services directly in controller ctor when they depend on per-request state. - using var scope = _serviceScopeFactory.CreateScope(); - var permissionService = scope.ServiceProvider.GetRequiredService(); - - // 4. Preload all permission ids for the employee for efficient in-memory checks - var permissionIds = await permissionService.GetPermissionIdsByEmployeeId(employeeId); + // 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); - // 5. Fetch all menu entries configured for this tenant + // 4. Fetch all menu entries configured for this tenant var menus = await _sideBarMenuHelper.GetAllWebMenuSectionsAsync(tenantId); if (menus == null || !menus.Any()) @@ -126,7 +123,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogDebug("GetAppSideBarMenuAsync loaded {MenuSectionCount} raw menu records. TenantId: {TenantId}, CorrelationId: {CorrelationId}", menus.Count, tenantId, correlationId); - // 6. Build logical menu sections (root + children) and apply permission filtering + // 5. Build logical menu sections (root + children) and apply permission filtering var responseSections = new List(); // Root container section for the web UI. @@ -153,7 +150,7 @@ namespace Marco.Pms.Services.Controllers // If the menu requires any permission, check now. if (menu.PermissionIds.Any()) { - var hasMenuPermission = permissionService.HasPermissionAny(permissionIds, menu.PermissionIds, employeeId); + var hasMenuPermission = _permissionService.HasPermissionAny(permissionIds, menu.PermissionIds, employeeId); if (!hasMenuPermission) { @@ -175,7 +172,7 @@ namespace Marco.Pms.Services.Controllers // If the submenu requires permissions, validate before adding. if (subMenu.PermissionIds.Any()) { - var hasSubPermission = permissionService.HasPermissionAny(permissionIds, subMenu.PermissionIds, employeeId); + var hasSubPermission = _permissionService.HasPermissionAny(permissionIds, subMenu.PermissionIds, employeeId); if (!hasSubPermission) { @@ -240,7 +237,7 @@ namespace Marco.Pms.Services.Controllers } [HttpPost("add/side-menu")] - public async Task AddMenuItemAsync([FromBody] List model) + public async Task AddWebMenuItemAsync([FromBody] List model) { // Step 1: Validate tenant context if (tenantId == Guid.Empty) @@ -306,9 +303,194 @@ namespace Marco.Pms.Services.Controllers } /// - /// Retrieves the master menu list based on enabled features for the current 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. /// - /// List of master menu items available for the tenant + /// 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. + + [HttpGet("get/menu-mobile")] + public async Task GetAppSideBarMenuForMobileAsync() + { + // Correlation ID enables distributed tracing across services, middleware, and structured logs. + var correlationId = HttpContext.TraceIdentifier; + + _logger.LogInfo("GetAppSideBarMenuForMobileAsync started. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + try + { + // 1. Validate tenant isolation - critical for multi-tenant security + if (tenantId == Guid.Empty) + { + _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); + } + + // 2. Resolve authenticated employee context with tenant isolation + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + if (loggedInEmployee is null) + { + _logger.LogWarning("GetAppSideBarMenuForMobileAsync failed: employee context not resolved. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + var error = ApiResponse.ErrorResponse("EmployeeContextNotFound", "Current employee context could not be resolved. Please authenticate and retry.", 403); + + return StatusCode(403, error); + } + + var employeeId = loggedInEmployee.Id; + _logger.LogDebug("GetAppSideBarMenuForMobileAsync resolved employee: EmployeeId: {EmployeeId}, TenantId: {TenantId}, CorrelationId: {CorrelationId}", + employeeId, tenantId, correlationId); + + // 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); + + // 4. Fetch tenant-specific mobile menu configuration + var allMenus = await _sideBarMenuHelper.GetAllMobileMenuSectionsAsync(tenantId); + if (allMenus == null || !allMenus.Any()) + { + _logger.LogInfo("GetAppSideBarMenuForMobileAsync: no mobile menu sections configured. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + var emptyResponse = new List(); + return Ok(ApiResponse.SuccessResponse(emptyResponse, + "No mobile sidebar menu sections configured for this tenant.", 200)); + } + + _logger.LogDebug("GetAppSideBarMenuForMobileAsync loaded {MenuCount} raw sections. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + allMenus.Count, tenantId, correlationId); + + // 5. Filter menus by employee permissions (in-memory for performance) + var accessibleMenus = new List(); + foreach (var menuSection in allMenus) + { + // Skip permission check for public menu items + if (!menuSection.PermissionIds.Any()) + { + accessibleMenus.Add(menuSection); + continue; + } + + // 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); + } + } + + // 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); + + 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) + { + _logger.LogError(ex, "GetAppSideBarMenuForMobileAsync failed unexpectedly. TenantId: {TenantId}, CorrelationId: {CorrelationId}", + tenantId, correlationId); + + return StatusCode(500, ApiResponse.ErrorResponse("InternalServerError", + "An unexpected error occurred while fetching the mobile sidebar menu. Please contact support if issue persists.", 500)); + } + } + + + [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() @@ -390,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/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 865e668..bd6b2dc 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -580,6 +580,9 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); + CreateMap(); + CreateMap(); + #endregion #region ======================================================= Directory ======================================================= -- 2.43.0