diff --git a/Marco.Pms.Model/Dtos/Tenant/AttendanceDetailsDto.cs b/Marco.Pms.Model/Dtos/Tenant/AttendanceDetailsDto.cs index b6fb41c..9259a5c 100644 --- a/Marco.Pms.Model/Dtos/Tenant/AttendanceDetailsDto.cs +++ b/Marco.Pms.Model/Dtos/Tenant/AttendanceDetailsDto.cs @@ -2,6 +2,7 @@ { public class AttendanceDetailsDto { + public List? FeatureId { get; set; } public bool Enabled { get; set; } = false; public bool ManualEntry { get; set; } = true; public bool LocationTracking { get; set; } = true; diff --git a/Marco.Pms.Model/Dtos/Tenant/DirectoryDetailsDto.cs b/Marco.Pms.Model/Dtos/Tenant/DirectoryDetailsDto.cs index 168cc4f..7006545 100644 --- a/Marco.Pms.Model/Dtos/Tenant/DirectoryDetailsDto.cs +++ b/Marco.Pms.Model/Dtos/Tenant/DirectoryDetailsDto.cs @@ -2,6 +2,7 @@ { public class DirectoryDetailsDto { + public List? FeatureId { get; set; } public bool Enabled { get; set; } = false; public int BucketLimit { get; set; } = 25; public bool OrganizationChart { get; set; } = false; diff --git a/Marco.Pms.Model/Dtos/Tenant/ExpenseModuleDetailsDto.cs b/Marco.Pms.Model/Dtos/Tenant/ExpenseModuleDetailsDto.cs index 1966b78..b8142c9 100644 --- a/Marco.Pms.Model/Dtos/Tenant/ExpenseModuleDetailsDto.cs +++ b/Marco.Pms.Model/Dtos/Tenant/ExpenseModuleDetailsDto.cs @@ -2,6 +2,7 @@ { public class ExpenseModuleDetailsDto { + public List? FeatureId { get; set; } public bool Enabled { get; set; } = false; } } diff --git a/Marco.Pms.Model/Dtos/Tenant/ProjectManagementDetailsDto.cs b/Marco.Pms.Model/Dtos/Tenant/ProjectManagementDetailsDto.cs index cda923a..0ba8c5e 100644 --- a/Marco.Pms.Model/Dtos/Tenant/ProjectManagementDetailsDto.cs +++ b/Marco.Pms.Model/Dtos/Tenant/ProjectManagementDetailsDto.cs @@ -2,6 +2,7 @@ { public class ProjectManagementDetailsDto { + public List? FeatureId { get; set; } public bool Enabled { get; set; } = false; public int MaxProject { get; set; } = 10; public double MaxTaskPerProject { get; set; } = 100000000; diff --git a/Marco.Pms.Model/Dtos/Tenant/SubscriptionPlanDto.cs b/Marco.Pms.Model/Dtos/Tenant/SubscriptionPlanDto.cs index 1346d8c..b414d3f 100644 --- a/Marco.Pms.Model/Dtos/Tenant/SubscriptionPlanDto.cs +++ b/Marco.Pms.Model/Dtos/Tenant/SubscriptionPlanDto.cs @@ -7,7 +7,7 @@ public required string Description { get; set; } public double PriceQuarterly { get; set; } public double PriceMonthly { get; set; } - public double PriceHalfMonthly { get; set; } + public double PriceHalfYearly { get; set; } public double PriceYearly { get; set; } public required int TrialDays { get; set; } public required double MaxUser { get; set; } diff --git a/Marco.Pms.Model/TenantModels/MongoDBModel/AttendanceDetails.cs b/Marco.Pms.Model/TenantModels/MongoDBModel/AttendanceDetails.cs index 0c800c2..a0728ac 100644 --- a/Marco.Pms.Model/TenantModels/MongoDBModel/AttendanceDetails.cs +++ b/Marco.Pms.Model/TenantModels/MongoDBModel/AttendanceDetails.cs @@ -8,6 +8,9 @@ namespace Marco.Pms.Model.TenantModels.MongoDBModel [BsonId] [BsonRepresentation(BsonType.String)] public Guid Id { get; set; } = Guid.NewGuid(); + + [BsonRepresentation(BsonType.String)] + public List FeatureId { get; set; } = new List(); public bool Enabled { get; set; } = false; public bool ManualEntry { get; set; } = true; public bool LocationTracking { get; set; } = true; diff --git a/Marco.Pms.Model/TenantModels/MongoDBModel/DirectoryDetails.cs b/Marco.Pms.Model/TenantModels/MongoDBModel/DirectoryDetails.cs index f0bf9d3..9708cc9 100644 --- a/Marco.Pms.Model/TenantModels/MongoDBModel/DirectoryDetails.cs +++ b/Marco.Pms.Model/TenantModels/MongoDBModel/DirectoryDetails.cs @@ -8,6 +8,9 @@ namespace Marco.Pms.Model.TenantModels.MongoDBModel [BsonId] [BsonRepresentation(BsonType.String)] public Guid Id { get; set; } = Guid.NewGuid(); + + [BsonRepresentation(BsonType.String)] + public List FeatureId { get; set; } = new List(); public bool Enabled { get; set; } = false; public int BucketLimit { get; set; } = 25; public bool OrganizationChart { get; set; } = false; diff --git a/Marco.Pms.Model/TenantModels/MongoDBModel/ExpenseModuleDetails.cs b/Marco.Pms.Model/TenantModels/MongoDBModel/ExpenseModuleDetails.cs index 210ae26..3ea1a78 100644 --- a/Marco.Pms.Model/TenantModels/MongoDBModel/ExpenseModuleDetails.cs +++ b/Marco.Pms.Model/TenantModels/MongoDBModel/ExpenseModuleDetails.cs @@ -8,6 +8,9 @@ namespace Marco.Pms.Model.TenantModels.MongoDBModel [BsonId] [BsonRepresentation(BsonType.String)] public Guid Id { get; set; } = Guid.NewGuid(); + + [BsonRepresentation(BsonType.String)] + public List FeatureId { get; set; } = new List(); public bool Enabled { get; set; } = false; } } diff --git a/Marco.Pms.Model/TenantModels/MongoDBModel/ProjectManagementDetails.cs b/Marco.Pms.Model/TenantModels/MongoDBModel/ProjectManagementDetails.cs index 7f843f8..9852570 100644 --- a/Marco.Pms.Model/TenantModels/MongoDBModel/ProjectManagementDetails.cs +++ b/Marco.Pms.Model/TenantModels/MongoDBModel/ProjectManagementDetails.cs @@ -8,6 +8,9 @@ namespace Marco.Pms.Model.TenantModels.MongoDBModel [BsonId] [BsonRepresentation(BsonType.String)] public Guid Id { get; set; } = Guid.NewGuid(); + + [BsonRepresentation(BsonType.String)] + public List FeatureId { get; set; } = new List(); public bool Enabled { get; set; } = false; public int MaxProject { get; set; } = 10; public double MaxTaskPerProject { get; set; } = 100000000; diff --git a/Marco.Pms.Services/Controllers/TenantController.cs b/Marco.Pms.Services/Controllers/TenantController.cs index c3793e9..781aeda 100644 --- a/Marco.Pms.Services/Controllers/TenantController.cs +++ b/Marco.Pms.Services/Controllers/TenantController.cs @@ -41,6 +41,7 @@ namespace Marco.Pms.Services.Controllers private readonly static Guid activeStatus = Guid.Parse("62b05792-5115-4f99-8ff5-e8374859b191"); private readonly static Guid activePlanStatus = Guid.Parse("cd3a68ea-41fd-42f0-bd0c-c871c7337727"); + private readonly static Guid EmployeeFeatureId = Guid.Parse("81ab8a87-8ccd-4015-a917-0627cee6a100"); private readonly static string AdminRoleName = "Admin"; public TenantController(IDbContextFactory dbContextFactory, IServiceScopeFactory serviceScopeFactory, @@ -461,60 +462,214 @@ namespace Marco.Pms.Services.Controllers #region =================================================================== Subscription APIs =================================================================== [HttpPost("add-subscription")] - public async Task AddSubscription(AddSubscriptionDto model) + public async Task AddSubscriptionAsync(AddSubscriptionDto model) { var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + _logger.LogInfo("AddSubscription called by employee {EmployeeId} for Tenant {TenantId} and Plan {PlanId}", + loggedInEmployee.Id, model.TenantId, model.PlanId); + if (loggedInEmployee == null) + { + _logger.LogWarning("No logged-in employee found."); + return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "User must be logged in.", 401)); + } + await using var _context = await _dbContextFactory.CreateDbContextAsync(); using var scope = _serviceScopeFactory.CreateScope(); var _permissionService = scope.ServiceProvider.GetRequiredService(); - // A root user should have access regardless of the specific permission. var isRootUser = loggedInEmployee.ApplicationUser?.IsRootUser ?? false; var hasPermission = await _permissionService.HasPermission(PermissionsMaster.ManageTenants, loggedInEmployee.Id); - if (!hasPermission || !isRootUser) + if (!hasPermission && !isRootUser) // fixed logic here { - _logger.LogWarning("Permission denied: User {EmployeeId} attempted to list tenants without 'ManageTenants' permission or root access.", loggedInEmployee.Id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "User does not have the required permissions for this action.", 403)); + _logger.LogWarning("Permission denied: User {EmployeeId} attempted to add subscription without permission or root access.", + loggedInEmployee.Id); + + return StatusCode(403, + ApiResponse.ErrorResponse("Access denied", + "User does not have the required permissions for this action.", 403)); } + var subscriptionPlan = await _context.SubscriptionPlans.FirstOrDefaultAsync(sp => sp.Id == model.PlanId); + if (subscriptionPlan == null) + { + _logger.LogWarning("Subscription plan {PlanId} not found in database", model.PlanId); + return NotFound(ApiResponse.ErrorResponse("Subscription plan not found", "Subscription plan not found", 400)); + } + + await using var transaction = await _context.Database.BeginTransactionAsync(); + var utcNow = DateTime.UtcNow; + + // Prepare subscription dates based on frequency + var endDate = model.Frequency switch + { + PLAN_FREQUENCY.MONTHLY => utcNow.AddMonths(1), + PLAN_FREQUENCY.QUARTERLY => utcNow.AddMonths(3), + PLAN_FREQUENCY.HALF_MONTHLY => utcNow.AddMonths(6), + PLAN_FREQUENCY.YEARLY => utcNow.AddMonths(12), + _ => utcNow.AddMonths(1) // default to monthly if unknown + }; + var tenantSubscription = new TenantSubscriptions { TenantId = model.TenantId, PlanId = model.PlanId, StatusId = activePlanStatus, - CreatedAt = DateTime.UtcNow, + CreatedAt = utcNow, CreatedById = loggedInEmployee.Id, CurrencyId = model.CurrencyId, IsTrial = model.IsTrial, - StartDate = DateTime.UtcNow, + StartDate = utcNow, + EndDate = endDate, + NextBillingDate = endDate, AutoRenew = model.AutoRenew }; - switch (model.Frequency) - { - case PLAN_FREQUENCY.MONTHLY: - tenantSubscription.EndDate = DateTime.UtcNow.AddMonths(1); - tenantSubscription.NextBillingDate = DateTime.UtcNow.AddMonths(1); - break; - case PLAN_FREQUENCY.QUARTERLY: - tenantSubscription.EndDate = DateTime.UtcNow.AddMonths(3); - tenantSubscription.NextBillingDate = DateTime.UtcNow.AddMonths(3); - break; - case PLAN_FREQUENCY.HALF_MONTHLY: - tenantSubscription.EndDate = DateTime.UtcNow.AddMonths(6); - tenantSubscription.NextBillingDate = DateTime.UtcNow.AddMonths(6); - break; - case PLAN_FREQUENCY.YEARLY: - tenantSubscription.EndDate = DateTime.UtcNow.AddMonths(12); - tenantSubscription.NextBillingDate = DateTime.UtcNow.AddMonths(12); - break; - } + _context.TenantSubscriptions.Add(tenantSubscription); - await _context.SaveChangesAsync(); - return Ok(ApiResponse.SuccessResponse(tenantSubscription, "Tenant Subscription Successfully", 200)); + + try + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Tenant subscription added successfully for Tenant {TenantId}, Plan {PlanId}", + model.TenantId, model.PlanId); + } + catch (DbUpdateException dbEx) + { + _logger.LogError(dbEx, "Database exception while adding subscription plan to tenant {TenantId}", model.TenantId); + return StatusCode(500, ApiResponse.ErrorResponse("Internal error occured", ExceptionMapper(dbEx), 500)); + } + + try + { + var features = await _featureDetailsHelper.GetFeatureDetails(subscriptionPlan.FeaturesId); + if (features == null) + { + _logger.LogInfo("No features found for subscription plan {PlanId}", model.PlanId); + await transaction.CommitAsync(); + return Ok(ApiResponse.SuccessResponse(tenantSubscription, "Tenant subscription successfully added", 200)); + } + + // Helper to get permissions for a module asynchronously + async Task> GetPermissionsForModuleAsync(List? featureIds) + { + if (featureIds == null || featureIds.Count == 0) return new List(); + + await using var ctx = await _dbContextFactory.CreateDbContextAsync(); + return await ctx.FeaturePermissions.AsNoTracking() + .Where(fp => featureIds.Contains(fp.FeatureId)) + .Select(fp => fp.Id) + .ToListAsync(); + } + + // Fetch permission tasks for all modules in parallel + var projectPermissionTask = GetPermissionsForModuleAsync(features.Modules?.ProjectManagement?.FeatureId); + var attendancePermissionTask = GetPermissionsForModuleAsync(features.Modules?.Attendance?.FeatureId); + var directoryPermissionTask = GetPermissionsForModuleAsync(features.Modules?.Directory?.FeatureId); + var expensePermissionTask = GetPermissionsForModuleAsync(features.Modules?.Expense?.FeatureId); + var employeePermissionTask = GetPermissionsForModuleAsync(new List { EmployeeFeatureId }); + + await Task.WhenAll(projectPermissionTask, attendancePermissionTask, directoryPermissionTask, expensePermissionTask, employeePermissionTask); + + var newPermissionIds = new List(); + var deletePermissionIds = new List(); + + // Add or remove permissions based on modules enabled status + void ProcessPermissions(bool? enabled, List permissions) + { + if (enabled == true) + newPermissionIds.AddRange(permissions); + else + deletePermissionIds.AddRange(permissions); + } + + ProcessPermissions(features.Modules?.ProjectManagement?.Enabled, projectPermissionTask.Result); + ProcessPermissions(features.Modules?.Attendance?.Enabled, attendancePermissionTask.Result); + ProcessPermissions(features.Modules?.Directory?.Enabled, directoryPermissionTask.Result); + ProcessPermissions(features.Modules?.Expense?.Enabled, expensePermissionTask.Result); + + newPermissionIds = newPermissionIds.Distinct().ToList(); + deletePermissionIds = deletePermissionIds.Distinct().ToList(); + + // Get root employee and role for this tenant + var rootEmployee = await _context.Employees + .Include(e => e.ApplicationUser) + .FirstOrDefaultAsync(e => e.ApplicationUser != null && (e.ApplicationUser.IsRootUser ?? false) && e.TenantId == model.TenantId); + + if (rootEmployee == null) + { + _logger.LogWarning("Root employee not found for tenant {TenantId}", model.TenantId); + await transaction.CommitAsync(); + return Ok(ApiResponse.SuccessResponse(tenantSubscription, "Tenant subscription successfully added", 200)); + } + + var roleId = await _context.EmployeeRoleMappings + .AsNoTracking() + .Where(er => er.EmployeeId == rootEmployee.Id && er.TenantId == model.TenantId) + .Select(er => er.RoleId) + .FirstOrDefaultAsync(); + + if (roleId == Guid.Empty) + { + _logger.LogWarning("RoleId for root employee {EmployeeId} in tenant {TenantId} not found", rootEmployee.Id, model.TenantId); + await transaction.CommitAsync(); + return Ok(ApiResponse.SuccessResponse(tenantSubscription, "Tenant subscription successfully added", 200)); + } + + var oldRolePermissionMappings = await _context.RolePermissionMappings + .Where(rp => rp.ApplicationRoleId == roleId) + .ToListAsync(); + + var oldPermissionIds = oldRolePermissionMappings.Select(rp => rp.FeaturePermissionId).ToList(); + + // Prevent accidentally deleting essential employee permissions + var permissionIdCount = oldPermissionIds.Count - deletePermissionIds.Count; + if (permissionIdCount <= 4 && deletePermissionIds.Any()) + { + var employeePermissionIds = employeePermissionTask.Result; + deletePermissionIds = deletePermissionIds.Where(p => !employeePermissionIds.Contains(p)).ToList(); + } + + // Prepare mappings to delete and add + var deleteMappings = oldRolePermissionMappings.Where(rp => deletePermissionIds.Contains(rp.FeaturePermissionId)).ToList(); + var addRolePermissionMappings = newPermissionIds + .Where(p => !oldPermissionIds.Contains(p)) + .Select(p => new RolePermissionMappings + { + ApplicationRoleId = roleId, + FeaturePermissionId = p + }) + .ToList(); + + if (addRolePermissionMappings.Any()) + { + _context.RolePermissionMappings.AddRange(addRolePermissionMappings); + _logger.LogInfo("Added {Count} new role permission mappings for role {RoleId}", addRolePermissionMappings.Count, roleId); + } + if (deleteMappings.Any()) + { + _context.RolePermissionMappings.RemoveRange(deleteMappings); + _logger.LogInfo("Removed {Count} role permission mappings for role {RoleId}", deleteMappings.Count, roleId); + } + + await _context.SaveChangesAsync(); + + await transaction.CommitAsync(); + + _logger.LogInfo("Permissions updated successfully for tenant {TenantId} subscription", model.TenantId); + + return Ok(ApiResponse.SuccessResponse(tenantSubscription, "Tenant Subscription Successfully", 200)); + } + catch (Exception ex) + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "Exception occurred while updating permissions for tenant {TenantId}", model.TenantId); + return StatusCode(500, ApiResponse.ErrorResponse("Internal error occured", ExceptionMapper(ex), 500)); + } } + #endregion #region =================================================================== Subscription Plan APIs =================================================================== @@ -523,7 +678,7 @@ namespace Marco.Pms.Services.Controllers public async Task GetSubscriptionPlanList([FromQuery] int? frequency) { await using var _context = await _dbContextFactory.CreateDbContextAsync(); - var plans = await _context.SubscriptionPlans.Include(s => s.Currency).ToListAsync(); + var plans = await _context.SubscriptionPlans.Include(s => s.Currency).OrderBy(s => s.PriceHalfYearly).ToListAsync(); if (frequency == null) { @@ -545,7 +700,7 @@ namespace Marco.Pms.Services.Controllers response.Price = p.PriceMonthly; break; case 1: - response.Price = p.PriceMonthly; + response.Price = p.PriceQuarterly; break; case 2: response.Price = p.PriceHalfYearly; diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 4b99cb8..9e19e21 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -81,10 +81,12 @@ string? connString = builder.Configuration.GetConnectionString("DefaultConnectio // This single call correctly registers BOTH the DbContext (scoped) AND the IDbContextFactory (singleton). builder.Services.AddDbContextFactory(options => - options.UseMySql(connString, ServerVersion.AutoDetect(connString))); + options.UseMySql(connString, ServerVersion.AutoDetect(connString)) + .EnableSensitiveDataLogging()); builder.Services.AddDbContext(options => - options.UseMySql(connString, ServerVersion.AutoDetect(connString))); + options.UseMySql(connString, ServerVersion.AutoDetect(connString)) + .EnableSensitiveDataLogging()); builder.Services.AddIdentity() .AddEntityFrameworkStores()