SideMenu_Management #156
@ -248,7 +248,6 @@ namespace Marco.Pms.DataAccess.Data
|
||||
public DbSet<InvoiceAttachmentType> InvoiceAttachmentTypes { get; set; }
|
||||
#endregion
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
using Marco.Pms.Model.AppMenu;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Marco.Pms.CacheHelper
|
||||
{
|
||||
public class SidebarMenuHelper
|
||||
{
|
||||
private readonly IMongoCollection<MenuSection> _collection;
|
||||
private readonly IMongoCollection<WebSideMenuItem> _webCollection;
|
||||
private readonly IMongoCollection<MobileMenu> _mobileCollection;
|
||||
private readonly ILogger<SidebarMenuHelper> _logger;
|
||||
|
||||
public SidebarMenuHelper(IConfiguration configuration, ILogger<SidebarMenuHelper> logger)
|
||||
@ -18,191 +18,18 @@ namespace Marco.Pms.CacheHelper
|
||||
var mongoUrl = new MongoUrl(connectionString);
|
||||
var client = new MongoClient(mongoUrl);
|
||||
var database = client.GetDatabase(mongoUrl.DatabaseName);
|
||||
_collection = database.GetCollection<MenuSection>("Menus");
|
||||
_webCollection = database.GetCollection<WebSideMenuItem>("WebSideMenus");
|
||||
_mobileCollection = database.GetCollection<MobileMenu>("MobileSideMenus");
|
||||
|
||||
}
|
||||
|
||||
public async Task<MenuSection?> CreateMenuSectionAsync(MenuSection section)
|
||||
public async Task<List<WebSideMenuItem>> GetAllWebMenuSectionsAsync(Guid tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _collection.InsertOneAsync(section);
|
||||
return section;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error occurred while adding MenuSection.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
var filter = Builders<WebSideMenuItem>.Filter.Eq(e => e.TenantId, tenantId);
|
||||
|
||||
public async Task<MenuSection?> UpdateMenuSectionAsync(Guid sectionId, MenuSection updatedSection)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filter = Builders<MenuSection>.Filter.Eq(s => s.Id, sectionId);
|
||||
|
||||
var update = Builders<MenuSection>.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<MenuSection?> AddMenuItemAsync(Guid sectionId, MenuItem newItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
newItem.Id = Guid.NewGuid();
|
||||
|
||||
var filter = Builders<MenuSection>.Filter.Eq(s => s.Id, sectionId);
|
||||
|
||||
var update = Builders<MenuSection>.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<MenuItem?> UpdateMenuItemAsync(Guid sectionId, Guid itemId, MenuItem updatedItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filter = Builders<MenuSection>.Filter.And(
|
||||
Builders<MenuSection>.Filter.Eq(s => s.Id, sectionId),
|
||||
Builders<MenuSection>.Filter.ElemMatch(s => s.Items, i => i.Id == itemId)
|
||||
);
|
||||
|
||||
var update = Builders<MenuSection>.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<MenuSection?> AddSubMenuItemAsync(Guid sectionId, Guid itemId, SubMenuItem newSubItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
newSubItem.Id = Guid.NewGuid();
|
||||
|
||||
// Match the MenuSection and the specific MenuItem inside it
|
||||
var filter = Builders<MenuSection>.Filter.And(
|
||||
Builders<MenuSection>.Filter.Eq(s => s.Id, sectionId),
|
||||
Builders<MenuSection>.Filter.ElemMatch(s => s.Items, i => i.Id == itemId)
|
||||
);
|
||||
|
||||
// Use positional operator `$` to target matched MenuItem and push into its Submenu
|
||||
var update = Builders<MenuSection>.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<SubMenuItem?> UpdateSubmenuItemAsync(Guid sectionId, Guid itemId, Guid subItemId, SubMenuItem updatedSub)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filter = Builders<MenuSection>.Filter.Eq(s => s.Id, sectionId);
|
||||
|
||||
var arrayFilters = new List<ArrayFilterDefinition>
|
||||
{
|
||||
new BsonDocumentArrayFilterDefinition<BsonDocument>(
|
||||
new BsonDocument("item._id", itemId.ToString())),
|
||||
new BsonDocumentArrayFilterDefinition<BsonDocument>(
|
||||
new BsonDocument("sub._id", subItemId.ToString()))
|
||||
};
|
||||
|
||||
var update = Builders<MenuSection>.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<List<MenuSection>> GetAllMenuSectionsAsync(Guid tenantId)
|
||||
{
|
||||
var filter = Builders<MenuSection>.Filter.Eq(e => e.TenantId, tenantId);
|
||||
|
||||
var result = await _collection
|
||||
var result = await _webCollection
|
||||
.Find(filter)
|
||||
.ToListAsync();
|
||||
if (result.Any())
|
||||
@ -211,13 +38,74 @@ namespace Marco.Pms.CacheHelper
|
||||
}
|
||||
|
||||
tenantId = Guid.Parse("b3466e83-7e11-464c-b93a-daf047838b26");
|
||||
filter = Builders<MenuSection>.Filter.Eq(e => e.TenantId, tenantId);
|
||||
filter = Builders<WebSideMenuItem>.Filter.Eq(e => e.TenantId, tenantId);
|
||||
|
||||
result = await _collection
|
||||
result = await _webCollection
|
||||
.Find(filter)
|
||||
.ToListAsync();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error occurred while fetching Web Menu Sections.");
|
||||
return new List<WebSideMenuItem>();
|
||||
}
|
||||
}
|
||||
public async Task<List<WebSideMenuItem>> AddWebMenuItemAsync(List<WebSideMenuItem> newItems)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _webCollection.InsertManyAsync(newItems);
|
||||
return newItems;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error occurred while adding Web Menu Section.");
|
||||
return new List<WebSideMenuItem>();
|
||||
}
|
||||
}
|
||||
public async Task<List<MobileMenu>> GetAllMobileMenuSectionsAsync(Guid tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filter = Builders<MobileMenu>.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<MobileMenu>.Filter.Eq(e => e.TenantId, tenantId);
|
||||
|
||||
result = await _mobileCollection
|
||||
.Find(filter)
|
||||
.ToListAsync();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error occurred while fetching Web Menu Sections.");
|
||||
return new List<MobileMenu>();
|
||||
}
|
||||
}
|
||||
public async Task<List<MobileMenu>> AddMobileMenuItemAsync(List<MobileMenu> newItems)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _mobileCollection.InsertManyAsync(newItems);
|
||||
return newItems;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error occurred while adding Mobile Menu Section.");
|
||||
return new List<MobileMenu>();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
21
Marco.Pms.Model/AppMenu/MobileMenu.cs
Normal file
21
Marco.Pms.Model/AppMenu/MobileMenu.cs
Normal file
@ -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<Guid> PermissionIds { get; set; } = new List<Guid>();
|
||||
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
}
|
||||
19
Marco.Pms.Model/AppMenu/WebMenuSection.cs
Normal file
19
Marco.Pms.Model/AppMenu/WebMenuSection.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Marco.Pms.Model.AppMenu
|
||||
{
|
||||
public class WebMenuSection
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public string? Header { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public List<WebSideMenuItem> Items { get; set; } = new List<WebSideMenuItem>();
|
||||
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
}
|
||||
25
Marco.Pms.Model/AppMenu/WebSideMenuItem.cs
Normal file
25
Marco.Pms.Model/AppMenu/WebSideMenuItem.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Marco.Pms.Model.AppMenu
|
||||
{
|
||||
public class WebSideMenuItem
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid? ParentMenuId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Icon { get; set; }
|
||||
public bool Available { get; set; } = true;
|
||||
public string Link { get; set; } = string.Empty;
|
||||
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public List<Guid> PermissionIds { get; set; } = new List<Guid>();
|
||||
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
}
|
||||
@ -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<string>
|
||||
public List<string> PermissionIds { get; set; } = new List<string>();
|
||||
|
||||
public List<CreateSubMenuItemDto> Submenu { get; set; } = new List<CreateSubMenuItemDto>();
|
||||
}
|
||||
}
|
||||
@ -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<CreateMenuItemDto> Items { get; set; } = new List<CreateMenuItemDto>();
|
||||
}
|
||||
}
|
||||
13
Marco.Pms.Model/Dtos/AppMenu/CreateMobileSideMenuItemDto.cs
Normal file
13
Marco.Pms.Model/Dtos/AppMenu/CreateMobileSideMenuItemDto.cs
Normal file
@ -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<Guid> PermissionIds { get; set; } = new List<Guid>();
|
||||
}
|
||||
}
|
||||
@ -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<string>
|
||||
public List<string> PermissionIds { get; set; } = new List<string>();
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
namespace Marco.Pms.Model.Dtos.AppMenu
|
||||
{
|
||||
public class UpdateMenuSectionDto
|
||||
public class CreateWebMenuSectionDto
|
||||
{
|
||||
public required Guid Id { get; set; }
|
||||
public required string Header { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public List<CreateWebSideMenuItemDto> Items { get; set; } = new List<CreateWebSideMenuItemDto>();
|
||||
}
|
||||
}
|
||||
15
Marco.Pms.Model/Dtos/AppMenu/CreateWebSideMenuItemDto.cs
Normal file
15
Marco.Pms.Model/Dtos/AppMenu/CreateWebSideMenuItemDto.cs
Normal file
@ -0,0 +1,15 @@
|
||||
namespace Marco.Pms.Model.Dtos.AppMenu
|
||||
{
|
||||
public class CreateWebSideMenuItemDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid? ParentMenuId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Icon { get; set; }
|
||||
public bool Available { get; set; } = true;
|
||||
public string Link { get; set; } = string.Empty;
|
||||
|
||||
// Changed from string → List<string>
|
||||
public List<Guid> PermissionIds { get; set; } = new List<Guid>();
|
||||
}
|
||||
}
|
||||
@ -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<string>
|
||||
public List<string> PermissionIds { get; set; } = new List<string>();
|
||||
}
|
||||
}
|
||||
@ -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<string>
|
||||
public List<string> PermissionIds { get; set; } = new List<string>();
|
||||
}
|
||||
}
|
||||
11
Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs
Normal file
11
Marco.Pms.Model/ViewModels/AppMenu/WebMenuSectionVM.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace Marco.Pms.Model.ViewModels.AppMenu
|
||||
{
|
||||
public class WebMenuSectionVM
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string? Header { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public List<WebSideMenuItemVM> Items { get; set; } = new List<WebSideMenuItemVM>();
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,12 @@
|
||||
namespace Marco.Pms.Model.ViewModels.DocumentManager
|
||||
namespace Marco.Pms.Model.ViewModels.AppMenu
|
||||
{
|
||||
public class MenuItemVM
|
||||
public class WebSideMenuItemVM
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string? Name { get; set; }
|
||||
public string? Icon { get; set; }
|
||||
public bool Available { get; set; }
|
||||
public string? Link { get; set; }
|
||||
public List<SubMenuItemVM> Submenu { get; set; } = new List<SubMenuItemVM>();
|
||||
public List<WebSideMenuItemVM> Submenu { get; set; } = new List<WebSideMenuItemVM>();
|
||||
}
|
||||
}
|
||||
@ -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<MenuItemVM> Items { get; set; } = new List<MenuItemVM>();
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,10 +3,13 @@ using Marco.Pms.DataAccess.Data;
|
||||
using Marco.Pms.Model.Dtos.Attendance;
|
||||
using Marco.Pms.Model.Entitlements;
|
||||
using Marco.Pms.Model.Expenses;
|
||||
using Marco.Pms.Model.OrganizationModel;
|
||||
using Marco.Pms.Model.Utilities;
|
||||
using Marco.Pms.Model.ViewModels.Activities;
|
||||
using Marco.Pms.Model.ViewModels.AttendanceVM;
|
||||
using Marco.Pms.Model.ViewModels.DashBoard;
|
||||
using Marco.Pms.Model.ViewModels.Organization;
|
||||
using Marco.Pms.Model.ViewModels.Projects;
|
||||
using Marco.Pms.Services.Service;
|
||||
using Marco.Pms.Services.Service.ServiceInterfaces;
|
||||
using MarcoBMS.Services.Helpers;
|
||||
@ -23,11 +26,11 @@ namespace Marco.Pms.Services.Controllers
|
||||
[ApiController]
|
||||
public class DashboardController : ControllerBase
|
||||
{
|
||||
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly UserHelper _userHelper;
|
||||
private readonly IProjectServices _projectServices;
|
||||
private readonly ILoggingService _logger;
|
||||
private readonly PermissionServices _permissionServices;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
@ -46,17 +49,17 @@ namespace Marco.Pms.Services.Controllers
|
||||
IProjectServices projectServices,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ILoggingService logger,
|
||||
PermissionServices permissionServices,
|
||||
IMapper mapper)
|
||||
IMapper mapper,
|
||||
IDbContextFactory<ApplicationDbContext> dbContextFactory)
|
||||
{
|
||||
_context = context;
|
||||
_userHelper = userHelper;
|
||||
_projectServices = projectServices;
|
||||
_logger = logger;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_permissionServices = permissionServices;
|
||||
_mapper = mapper;
|
||||
tenantId = userHelper.GetTenantId();
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -263,8 +266,10 @@ namespace Marco.Pms.Services.Controllers
|
||||
|
||||
if (projectId.HasValue)
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
||||
// Security Check: Ensure the requested project is in the user's accessible list.
|
||||
var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value);
|
||||
var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value);
|
||||
if (!hasPermission)
|
||||
{
|
||||
_logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId} (not active or not accessible).", loggedInEmployee.Id, projectId.Value);
|
||||
@ -349,9 +354,11 @@ namespace Marco.Pms.Services.Controllers
|
||||
if (projectId.HasValue)
|
||||
{
|
||||
// --- Logic for a SINGLE Project ---
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
||||
|
||||
// 2a. Security Check: Verify permission for the specific project.
|
||||
var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value);
|
||||
var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value);
|
||||
if (!hasPermission)
|
||||
{
|
||||
_logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId.Value);
|
||||
@ -678,7 +685,11 @@ namespace Marco.Pms.Services.Controllers
|
||||
|
||||
// Step 3: Check if logged-in employee has permission for this project
|
||||
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
||||
bool hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee!, projectId);
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var _permission = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
||||
|
||||
bool hasPermission = await _permission.HasProjectPermission(loggedInEmployee!, projectId);
|
||||
if (!hasPermission)
|
||||
{
|
||||
_logger.LogWarning("Unauthorized access by EmployeeId: {EmployeeId} to ProjectId: {ProjectId}", loggedInEmployee.Id, projectId);
|
||||
@ -756,7 +767,6 @@ namespace Marco.Pms.Services.Controllers
|
||||
return Ok(ApiResponse<object>.SuccessResponse(sortedResult, $"{sortedResult.Count} records fetched for attendance overview", 200));
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("expense/monthly")]
|
||||
public async Task<IActionResult> GetExpenseReportByProjectsAsync([FromQuery] Guid? projectId, [FromQuery] Guid? categoryId, [FromQuery] int months)
|
||||
{
|
||||
@ -1204,5 +1214,425 @@ namespace Marco.Pms.Services.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a high-level collection overview (aging buckets, due vs collected, top client)
|
||||
/// for invoices of the current tenant, optionally filtered by project.
|
||||
/// </summary>
|
||||
/// <param name="projectId">Optional project identifier to filter invoices.</param>
|
||||
/// <returns>Standardized API response with collection KPIs.</returns>
|
||||
[HttpGet("collection-overview")]
|
||||
public async Task<IActionResult> GetCollectionOverviewAsync([FromQuery] Guid? projectId)
|
||||
{
|
||||
// Correlation ID pattern for distributed tracing (if you use one)
|
||||
var correlationId = HttpContext.TraceIdentifier;
|
||||
if (tenantId == Guid.Empty)
|
||||
{
|
||||
_logger.LogWarning("Invalid request: TenantId is empty on progression endpoint");
|
||||
return BadRequest(ApiResponse<object>.ErrorResponse("Invalid TenantId", "Provided Invalid TenantId", 400));
|
||||
}
|
||||
_logger.LogInfo("Started GetCollectionOverviewAsync. CorrelationId: {CorrelationId}, ProjectId: {ProjectId}", correlationId, projectId ?? Guid.Empty);
|
||||
|
||||
try
|
||||
{
|
||||
// Validate and identify current employee/tenant context
|
||||
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
||||
|
||||
// Base invoice query for this tenant; AsNoTracking for read-only performance [web:1][web:5]
|
||||
var invoiceQuery = _context.Invoices
|
||||
.Where(i => i.TenantId == tenantId && i.IsActive)
|
||||
.Include(i => i.BilledTo)
|
||||
.AsNoTracking();
|
||||
|
||||
// Fetch infra and service projects in parallel using factory-created contexts
|
||||
// NOTE: Avoid Task.Run over async IO where possible. Here each uses its own context instance. [web:6][web:15]
|
||||
var infraProjectTask = GetInfraProjectsAsync(tenantId);
|
||||
var serviceProjectTask = GetServiceProjectsAsync(tenantId);
|
||||
|
||||
await Task.WhenAll(infraProjectTask, serviceProjectTask);
|
||||
|
||||
var projects = infraProjectTask.Result
|
||||
.Union(serviceProjectTask.Result)
|
||||
.ToList();
|
||||
|
||||
// Optional project filter: validate existence in cached list first
|
||||
if (projectId.HasValue)
|
||||
{
|
||||
var project = projects.FirstOrDefault(p => p.Id == projectId.Value);
|
||||
if (project == null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Project {ProjectId} not found for tenant {TenantId} in GetCollectionOverviewAsync. CorrelationId: {CorrelationId}",
|
||||
projectId, tenantId, correlationId);
|
||||
|
||||
return StatusCode(
|
||||
StatusCodes.Status404NotFound,
|
||||
ApiResponse<object>.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<object>.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<object>.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<object>.ErrorResponse("Internal Server Error",
|
||||
"An unexpected error occurred while generating the collection overview. Please try again or contact support with the correlation identifier.", 500));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("purchase-invoice-overview")]
|
||||
public async Task<IActionResult> 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<object>.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<object>(),
|
||||
ProjectBreakdown = Array.Empty<object>(),
|
||||
TopSupplier = (object?)null
|
||||
};
|
||||
|
||||
return Ok(ApiResponse<object>.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<object>.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<object>.ErrorResponse("Internal Server Error", "An unexpected error occurred while processing the purchase invoice overview.", 500));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets infrastructure projects for a tenant as a lightweight view model.
|
||||
/// </summary>
|
||||
private async Task<List<BasicProjectVM>> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets service projects for a tenant as a lightweight view model.
|
||||
/// </summary>
|
||||
private async Task<List<BasicProjectVM>> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -384,7 +384,10 @@ namespace Marco.Pms.Services.Controllers
|
||||
response.CreatedBy = createdBy;
|
||||
|
||||
response.CurrentPlan = _mapper.Map<SubscriptionPlanDetailsVM>(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
|
||||
|
||||
@ -809,7 +809,7 @@ namespace Marco.Pms.Services.Helpers
|
||||
}
|
||||
Task<List<string>> 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))
|
||||
|
||||
@ -34,6 +34,7 @@ using Marco.Pms.Model.ServiceProject;
|
||||
using Marco.Pms.Model.TenantModels;
|
||||
using Marco.Pms.Model.TenantModels.MongoDBModel;
|
||||
using Marco.Pms.Model.ViewModels.Activities;
|
||||
using Marco.Pms.Model.ViewModels.AppMenu;
|
||||
using Marco.Pms.Model.ViewModels.Collection;
|
||||
using Marco.Pms.Model.ViewModels.Directory;
|
||||
using Marco.Pms.Model.ViewModels.DocumentManager;
|
||||
@ -563,26 +564,25 @@ namespace Marco.Pms.Services.MappingProfiles
|
||||
#endregion
|
||||
|
||||
#region ======================================================= AppMenu =======================================================
|
||||
CreateMap<CreateMenuSectionDto, MenuSection>();
|
||||
CreateMap<UpdateMenuSectionDto, MenuSection>();
|
||||
CreateMap<MenuSection, MenuSectionVM>()
|
||||
.ForMember(
|
||||
dest => dest.Name,
|
||||
opt => opt.MapFrom(src => src.Title));
|
||||
CreateMap<CreateWebMenuSectionDto, WebMenuSection>();
|
||||
|
||||
CreateMap<CreateMenuItemDto, MenuItem>();
|
||||
CreateMap<UpdateMenuItemDto, MenuItem>();
|
||||
CreateMap<MenuItem, MenuItemVM>()
|
||||
CreateMap<WebMenuSection, WebMenuSectionVM>()
|
||||
.ForMember(
|
||||
dest => dest.Name,
|
||||
opt => opt.MapFrom(src => src.Text));
|
||||
dest => dest.Items,
|
||||
opt => opt.MapFrom(src => new List<WebSideMenuItem>()));
|
||||
|
||||
CreateMap<CreateSubMenuItemDto, SubMenuItem>();
|
||||
CreateMap<UpdateSubMenuItemDto, SubMenuItem>();
|
||||
CreateMap<SubMenuItem, SubMenuItemVM>()
|
||||
|
||||
CreateMap<CreateWebSideMenuItemDto, WebSideMenuItem>()
|
||||
.ForMember(
|
||||
dest => dest.Name,
|
||||
opt => opt.MapFrom(src => src.Text));
|
||||
dest => dest.Id,
|
||||
opt => opt.MapFrom(src => src.Id.HasValue ? src.Id.Value : Guid.NewGuid()));
|
||||
|
||||
|
||||
CreateMap<WebSideMenuItem, WebSideMenuItemVM>();
|
||||
|
||||
CreateMap<CreateMobileSideMenuItemDto, MobileMenu>();
|
||||
CreateMap<MobileMenu, MenuSectionApplicationVM>();
|
||||
|
||||
#endregion
|
||||
|
||||
#region ======================================================= Directory =======================================================
|
||||
|
||||
@ -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
|
||||
/// <returns>True if the user has the permission, otherwise false.</returns>
|
||||
public async Task<bool> 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>
|
||||
{
|
||||
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<bool> HasPermissionAny(List<Guid> featurePermissionIds, Guid employeeId)
|
||||
{
|
||||
var allFeaturePermissionIds = await _cache.GetPermissions(employeeId, tenantId);
|
||||
if (allFeaturePermissionIds == null)
|
||||
{
|
||||
List<FeaturePermission> 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<Guid> realPermissionIds, List<Guid> toCheckPermissionIds, Guid employeeId)
|
||||
{
|
||||
var hasPermission = toCheckPermissionIds.Any(f => realPermissionIds.Contains(f));
|
||||
return hasPermission;
|
||||
}
|
||||
public async Task<bool> HasProjectPermission(Employee LoggedInEmployee, Guid projectId)
|
||||
{
|
||||
var employeeId = LoggedInEmployee.Id;
|
||||
@ -199,5 +149,164 @@ namespace Marco.Pms.Services.Service
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves permission IDs for an employee, supporting both global role-based permissions
|
||||
/// and project-specific overrides with cache-first strategy.
|
||||
/// </summary>
|
||||
/// <param name="employeeId">The ID of the employee to fetch permissions for.</param>
|
||||
/// <param name="projectId">Optional project ID for project-level permission overrides.</param>
|
||||
/// <returns>List of unique permission IDs the employee has access to.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when employeeId or tenantId is empty.</exception>
|
||||
public async Task<List<Guid>> GetPermissionIdsByEmployeeId(Guid employeeId, Guid? projectId = null)
|
||||
{
|
||||
// Input validation
|
||||
if (employeeId == Guid.Empty)
|
||||
{
|
||||
_logger.LogWarning("EmployeeId cannot be empty.");
|
||||
return new List<Guid>();
|
||||
}
|
||||
|
||||
if (tenantId == Guid.Empty)
|
||||
{
|
||||
_logger.LogWarning("TenantId cannot be empty.");
|
||||
return new List<Guid>();
|
||||
}
|
||||
_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<Guid>();
|
||||
}
|
||||
|
||||
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<Guid>
|
||||
{
|
||||
// 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<Guid>();
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Database error in GetPermissionIdsByEmployeeId. EmployeeId: {EmployeeId}, TenantId: {TenantId}", employeeId, tenantId);
|
||||
|
||||
return new List<Guid>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error in GetPermissionIdsByEmployeeId. EmployeeId: {EmployeeId}, TenantId: {TenantId}", employeeId, tenantId);
|
||||
|
||||
return new List<Guid>();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user