1786 lines
92 KiB
C#
1786 lines
92 KiB
C#
using AutoMapper;
|
|
using Marco.Pms.DataAccess.Data;
|
|
using Marco.Pms.Helpers.Utility;
|
|
using Marco.Pms.Model.Dtos.Tenant;
|
|
using Marco.Pms.Model.Employees;
|
|
using Marco.Pms.Model.Entitlements;
|
|
using Marco.Pms.Model.MongoDBModels.Utility;
|
|
using Marco.Pms.Model.Projects;
|
|
using Marco.Pms.Model.Roles;
|
|
using Marco.Pms.Model.TenantModels;
|
|
using Marco.Pms.Model.TenantModels.MongoDBModel;
|
|
using Marco.Pms.Model.Utilities;
|
|
using Marco.Pms.Model.ViewModels.Activities;
|
|
using Marco.Pms.Model.ViewModels.Tenant;
|
|
using Marco.Pms.Services.Helpers;
|
|
using Marco.Pms.Services.Service;
|
|
using MarcoBMS.Services.Helpers;
|
|
using MarcoBMS.Services.Service;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Net;
|
|
using System.Text.Json;
|
|
|
|
// For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860
|
|
|
|
namespace Marco.Pms.Services.Controllers
|
|
{
|
|
[Route("api/[controller]")]
|
|
[ApiController]
|
|
[Authorize]
|
|
public class TenantController : ControllerBase
|
|
{
|
|
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
|
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
|
private readonly ILoggingService _logger;
|
|
private readonly UserManager<ApplicationUser> _userManager;
|
|
private readonly IMapper _mapper;
|
|
private readonly UserHelper _userHelper;
|
|
private readonly FeatureDetailsHelper _featureDetailsHelper;
|
|
|
|
private readonly Guid tenantId;
|
|
|
|
private readonly static Guid projectActiveStatus = Guid.Parse("b74da4c2-d07e-46f2-9919-e75e49b12731");
|
|
private readonly static Guid projectInProgressStatus = Guid.Parse("cdad86aa-8a56-4ff4-b633-9c629057dfef");
|
|
private readonly static Guid projectOnHoldStatus = Guid.Parse("603e994b-a27f-4e5d-a251-f3d69b0498ba");
|
|
private readonly static Guid projectInActiveStatus = Guid.Parse("ef1c356e-0fe0-42df-a5d3-8daee355492d");
|
|
private readonly static Guid projectCompletedStatus = Guid.Parse("33deaef9-9af1-4f2a-b443-681ea0d04f81");
|
|
|
|
private readonly static Guid tenantActiveStatus = 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<ApplicationDbContext> dbContextFactory,
|
|
IServiceScopeFactory serviceScopeFactory,
|
|
ILoggingService logger,
|
|
UserManager<ApplicationUser> userManager,
|
|
IMapper mapper,
|
|
UserHelper userHelper,
|
|
FeatureDetailsHelper featureDetailsHelper)
|
|
{
|
|
_dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory));
|
|
_serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
_userManager = userManager ?? throw new ArgumentNullException(nameof(userManager));
|
|
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
|
|
_userHelper = userHelper ?? throw new ArgumentNullException(nameof(userHelper));
|
|
_featureDetailsHelper = featureDetailsHelper ?? throw new ArgumentNullException(nameof(featureDetailsHelper));
|
|
tenantId = userHelper.GetTenantId();
|
|
}
|
|
#region =================================================================== Tenant APIs ===================================================================
|
|
|
|
/// <summary>
|
|
/// Retrieves a paginated list of active tenants with optional filtering and searching.
|
|
/// </summary>
|
|
/// <param name="searchString">A string to search for across various tenant fields.</param>
|
|
/// <param name="filter">A JSON serialized string containing advanced filter criteria.</param>
|
|
/// <param name="pageSize">The number of records to return per page.</param>
|
|
/// <param name="pageNumber">The page number to retrieve.</param>
|
|
/// <returns>A paginated list of tenants matching the criteria.</returns>
|
|
|
|
[HttpGet("list")]
|
|
public async Task<IActionResult> GetTenantList([FromQuery] string? searchString, string? filter, int pageSize = 20, int pageNumber = 1)
|
|
{
|
|
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
|
|
var _permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
_logger.LogInfo("Attempting to fetch tenant list with pageNumber: {PageNumber} and pageSize: {PageSize}", pageNumber, pageSize);
|
|
|
|
try
|
|
{
|
|
// --- 1. PERMISSION CHECK ---
|
|
var currentTenant = await _userHelper.GetCurrentTenant();
|
|
if (currentTenant == null)
|
|
{
|
|
_logger.LogWarning("Authentication failed: No logged-in tenant found.");
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Authentication required", "Tenant not found", 403));
|
|
}
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
if (loggedInEmployee == null)
|
|
{
|
|
_logger.LogWarning("Authentication failed: No logged-in employee found.");
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Authentication required", "User is not logged in.", 403));
|
|
}
|
|
|
|
// A root user should have access regardless of the specific permission.
|
|
var isSuperTenant = currentTenant.IsSuperTenant;
|
|
var hasPermission = await _permissionService.HasPermission(PermissionsMaster.ManageTenants, loggedInEmployee.Id);
|
|
|
|
if (!hasPermission && !isSuperTenant)
|
|
{
|
|
_logger.LogWarning("Permission denied: User {EmployeeId} attempted to list tenants without 'ManageTenants' permission or root access.", loggedInEmployee.Id);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Access denied", "User does not have the required permissions for this action.", 403));
|
|
}
|
|
|
|
// --- 2. QUERY CONSTRUCTION ---
|
|
// Using a DbContext from the factory ensures a fresh context for this request.
|
|
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
|
|
|
// Start with a base IQueryable. Filters will be appended to this.
|
|
var tenantQuery = _context.Tenants.Where(t => t.IsActive);
|
|
|
|
if (hasPermission && !isSuperTenant)
|
|
{
|
|
tenantQuery = tenantQuery.Where(t => t.Id == currentTenant.Id || t.CreatedById == loggedInEmployee.Id);
|
|
}
|
|
|
|
// Apply advanced filters from the JSON filter object.
|
|
var tenantFilter = TryDeserializeFilter(filter);
|
|
if (tenantFilter != null)
|
|
{
|
|
// Date range filtering
|
|
if (tenantFilter.StartDate.HasValue && tenantFilter.EndDate.HasValue)
|
|
{
|
|
// OPTIMIZATION: Avoid using .Date on the database column, as it can prevent index usage.
|
|
// This structure (`>= start` and `< end.AddDays(1)`) is index-friendly and correctly inclusive.
|
|
var endDateExclusive = tenantFilter.EndDate.Value.AddDays(1);
|
|
tenantQuery = tenantQuery.Where(t => t.OnBoardingDate >= tenantFilter.StartDate.Value && t.OnBoardingDate < endDateExclusive);
|
|
}
|
|
// List-based filtering
|
|
if (tenantFilter.IndustryIds?.Any() == true)
|
|
{
|
|
tenantQuery = tenantQuery.Where(t => t.IndustryId.HasValue && tenantFilter.IndustryIds.Contains(t.IndustryId.Value));
|
|
}
|
|
if (tenantFilter.References?.Any() == true)
|
|
{
|
|
tenantQuery = tenantQuery.Where(t => tenantFilter.References.Contains(t.Reference));
|
|
}
|
|
if (tenantFilter.TenantStatusIds?.Any() == true)
|
|
{
|
|
tenantQuery = tenantQuery.Where(t => tenantFilter.TenantStatusIds.Contains(t.TenantStatusId));
|
|
}
|
|
if (tenantFilter.CreatedByIds?.Any() == true)
|
|
{
|
|
tenantQuery = tenantQuery.Where(t => t.CreatedById.HasValue && tenantFilter.CreatedByIds.Contains(t.CreatedById.Value));
|
|
}
|
|
}
|
|
|
|
// Apply free-text search string.
|
|
if (!string.IsNullOrWhiteSpace(searchString))
|
|
{
|
|
// OPTIMIZATION: Do not use .ToLower() on the database columns (e.g., `t.Name.ToLower()`).
|
|
// This makes the query non-SARGable and kills performance by preventing index usage.
|
|
// This implementation relies on the database collation being case-insensitive (e.g., `SQL_Latin1_General_CP1_CI_AS` in SQL Server).
|
|
tenantQuery = tenantQuery.Where(t =>
|
|
t.Name.Contains(searchString) ||
|
|
t.ContactName.Contains(searchString) ||
|
|
t.Email.Contains(searchString) ||
|
|
t.ContactNumber.Contains(searchString) ||
|
|
t.BillingAddress.Contains(searchString) ||
|
|
(t.TaxId != null && t.TaxId.Contains(searchString)) ||
|
|
(t.Description != null && t.Description.Contains(searchString)) ||
|
|
(t.DomainName != null && t.DomainName.Contains(searchString))
|
|
);
|
|
}
|
|
|
|
// --- 3. PAGINATION AND EXECUTION ---
|
|
// First, get the total count of records for pagination metadata.
|
|
// This executes a separate, efficient `COUNT(*)` query.
|
|
int totalRecords = await tenantQuery.CountAsync();
|
|
int totalPages = (int)Math.Ceiling((double)totalRecords / pageSize);
|
|
|
|
_logger.LogInfo("Found {TotalRecords} total tenants matching the query.", totalRecords);
|
|
|
|
// Now, apply ordering and pagination to fetch only the data for the current page.
|
|
// This is efficient server-side pagination.
|
|
var tenantList = await tenantQuery
|
|
.Include(t => t.Industry) // Eager load related data to prevent N+1 queries.
|
|
.Include(t => t.TenantStatus)
|
|
.OrderByDescending(t => t.OnBoardingDate)
|
|
.Skip((pageNumber - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.ToListAsync();
|
|
|
|
// Map the entities to a ViewModel/DTO for the response.
|
|
var vm = _mapper.Map<List<TenantListVM>>(tenantList);
|
|
|
|
// --- 4. CONSTRUCT RESPONSE ---
|
|
var response = new
|
|
{
|
|
TotalCount = totalRecords,
|
|
TotalPages = totalPages,
|
|
CurrentPage = pageNumber,
|
|
PageSize = pageSize,
|
|
Filter = tenantFilter, // Return the applied filter for context on the client-side.
|
|
Data = vm
|
|
};
|
|
|
|
_logger.LogInfo("Successfully fetched {RecordCount} tenant records.", vm.Count);
|
|
return Ok(ApiResponse<object>.SuccessResponse(response, $"{totalRecords} records of tenants fetched successfully", 200));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// CRITICAL SECURITY FIX: Do not expose the exception details to the client.
|
|
// Log the full exception for debugging purposes.
|
|
_logger.LogError(ex, "An unhandled exception occurred while fetching the tenant list.");
|
|
|
|
// Return a generic 500 Internal Server Error response.
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse("An internal server error occurred.", "An unexpected error prevented the request from completing.", 500));
|
|
}
|
|
}
|
|
|
|
// GET api/<TenantController>/5
|
|
[HttpGet("details/{id}")]
|
|
public async Task<IActionResult> GetTenantDetailsAsync(Guid id)
|
|
{
|
|
_logger.LogInfo("GetTenantDetails started for TenantId: {TenantId}", id);
|
|
|
|
// Get currently logged-in employee info
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
if (loggedInEmployee == null)
|
|
{
|
|
_logger.LogWarning("No logged-in employee found for the request.");
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Unauthorized", "User must be logged in.", 403));
|
|
}
|
|
|
|
// Check permissions using a single service scope to avoid overhead
|
|
bool hasManagePermission, hasModifyPermission, hasViewPermission;
|
|
using (var scope = _serviceScopeFactory.CreateScope())
|
|
{
|
|
|
|
|
|
var manageTask = Task.Run(async () =>
|
|
{
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasPermission(PermissionsMaster.ManageTenants, loggedInEmployee.Id);
|
|
});
|
|
var modifyTask = Task.Run(async () =>
|
|
{
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasPermission(PermissionsMaster.ModifyTenant, loggedInEmployee.Id);
|
|
});
|
|
var viewTask = Task.Run(async () =>
|
|
{
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
return await permissionService.HasPermission(PermissionsMaster.ViewTenant, loggedInEmployee.Id);
|
|
});
|
|
|
|
await Task.WhenAll(manageTask, modifyTask, viewTask);
|
|
|
|
hasManagePermission = manageTask.Result;
|
|
hasModifyPermission = modifyTask.Result;
|
|
hasViewPermission = viewTask.Result;
|
|
}
|
|
|
|
if (!hasManagePermission && !hasModifyPermission && !hasViewPermission)
|
|
{
|
|
_logger.LogWarning("Permission denied: User {EmployeeId} attempted to access tenant details without the required permissions.", loggedInEmployee.Id);
|
|
return StatusCode(403,
|
|
ApiResponse<object>.ErrorResponse("Access denied", "User does not have the required permissions for this action.", 403));
|
|
}
|
|
if (!hasManagePermission && (hasModifyPermission || hasViewPermission) && id != loggedInEmployee.TenantId)
|
|
{
|
|
_logger.LogWarning("Permission denied: User {EmployeeId} attempted to access tenant details of other tenant.", loggedInEmployee.Id);
|
|
return StatusCode(403,
|
|
ApiResponse<object>.ErrorResponse("Access denied", "User does not have the required permissions for this action.", 403));
|
|
}
|
|
|
|
// Create a single DbContext for main tenant fetch and related data requests
|
|
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
|
|
|
// Fetch tenant details with related Industry and TenantStatus in a single query
|
|
var tenant = await _context.Tenants
|
|
.Include(t => t.Industry)
|
|
.Include(t => t.TenantStatus)
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(t => t.Id == id);
|
|
|
|
if (tenant == null)
|
|
{
|
|
_logger.LogWarning("Tenant {TenantId} not found in database", id);
|
|
return NotFound(ApiResponse<object>.ErrorResponse("Tenant not found", "Tenant not found", 404));
|
|
}
|
|
_logger.LogInfo("Tenant {TenantId} found.", tenant.Id);
|
|
|
|
// Fetch dependent data in parallel to improve performance
|
|
var employeesTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Employees
|
|
.Include(e => e.ApplicationUser)
|
|
.Where(e => e.TenantId == tenant.Id)
|
|
.AsNoTracking()
|
|
.ToListAsync();
|
|
});
|
|
|
|
var createdByTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Employees
|
|
.AsNoTracking()
|
|
.Where(e => e.Id == tenant.CreatedById)
|
|
.Select(e => _mapper.Map<BasicEmployeeVM>(e))
|
|
.FirstOrDefaultAsync();
|
|
});
|
|
|
|
var plansTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.TenantSubscriptions
|
|
.Include(sp => sp.CreatedBy)
|
|
.ThenInclude(e => e!.JobRole)
|
|
.Include(sp => sp.UpdatedBy)
|
|
.ThenInclude(e => e!.JobRole)
|
|
.Include(sp => sp.Currency)
|
|
.Include(ts => ts.Plan).ThenInclude(sp => sp!.Plan)
|
|
.Where(ts => ts.TenantId == tenant.Id && ts.Plan != null)
|
|
.AsNoTracking()
|
|
.OrderBy(ts => ts.CreatedBy)
|
|
.ToListAsync();
|
|
});
|
|
|
|
var projectsTask = Task.Run(async () =>
|
|
{
|
|
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
return await dbContext.Projects
|
|
.Include(p => p.ProjectStatus)
|
|
.Where(p => p.TenantId == tenant.Id)
|
|
.AsNoTracking()
|
|
.ToListAsync();
|
|
});
|
|
|
|
await Task.WhenAll(employeesTask, createdByTask, plansTask, projectsTask);
|
|
|
|
var employees = employeesTask.Result;
|
|
var createdBy = createdByTask.Result;
|
|
var plans = plansTask.Result;
|
|
var projects = projectsTask.Result;
|
|
|
|
// Calculate active/inactive employees count
|
|
var activeEmployeesCount = employees.Count(e => e.IsActive);
|
|
var inActiveEmployeesCount = employees.Count - activeEmployeesCount;
|
|
var activeUserCount = employees.Count(e => e.IsActive && e.ApplicationUserId != null && e.Email != null);
|
|
|
|
|
|
// Filter current active (non-cancelled) subscription plan
|
|
var currentPlan = plans.FirstOrDefault(ts => !ts.IsCancelled);
|
|
var expiryDate = currentPlan?.EndDate;
|
|
var nextBillingDate = currentPlan?.NextBillingDate;
|
|
|
|
// Map Tenant entity to TenantDetailsVM response model
|
|
var response = _mapper.Map<TenantDetailsVM>(tenant);
|
|
response.ActiveEmployees = activeEmployeesCount;
|
|
response.InActiveEmployees = inActiveEmployeesCount;
|
|
|
|
// Count projects by status
|
|
response.ActiveProjects = projects.Count(p => p.ProjectStatusId == projectActiveStatus);
|
|
response.InProgressProjects = projects.Count(p => p.ProjectStatusId == projectInProgressStatus);
|
|
response.OnHoldProjects = projects.Count(p => p.ProjectStatusId == projectOnHoldStatus);
|
|
response.InActiveProjects = projects.Count(p => p.ProjectStatusId == projectInActiveStatus);
|
|
response.CompletedProjects = projects.Count(p => p.ProjectStatusId == projectCompletedStatus);
|
|
response.SeatsAvailable = (int)(currentPlan?.MaxUsers ?? 1) - activeUserCount;
|
|
response.ExpiryDate = expiryDate;
|
|
response.NextBillingDate = nextBillingDate;
|
|
response.CreatedBy = createdBy;
|
|
|
|
response.CurrentPlan = _mapper.Map<SubscriptionPlanDetailsVM>(currentPlan);
|
|
response.CurrentPlanFeatures = await _featureDetailsHelper.GetFeatureDetails(currentPlan?.Plan?.FeaturesId ?? Guid.Empty);
|
|
// Map subscription history plans to DTO
|
|
response.SubscriptionHistery = _mapper.Map<List<SubscriptionPlanDetailsVM>>(plans);
|
|
|
|
_logger.LogInfo("Tenant details fetched successfully for TenantId: {TenantId}", tenant.Id);
|
|
|
|
return Ok(ApiResponse<object>.SuccessResponse(response, "Tenant profile fetched successfully", 200));
|
|
}
|
|
|
|
// POST api/<TenantController>
|
|
[HttpPost("create")]
|
|
public async Task<IActionResult> CreateTenant([FromBody] CreateTenantDto model)
|
|
{
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
|
|
|
var _configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
|
var _emailSender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
|
|
var _permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
|
|
_logger.LogInfo("Attempting to create a new tenant with organization name: {OrganizationName}", model.OrganizationName);
|
|
|
|
// 1. --- PERMISSION CHECK ---
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
if (loggedInEmployee == null)
|
|
{
|
|
// This case should ideally be handled by an [Authorize] attribute, but it's good practice to double-check.
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Authentication required", "User is not logged in.", 403));
|
|
}
|
|
|
|
var hasPermission = await _permissionService.HasPermission(PermissionsMaster.ManageTenants, loggedInEmployee.Id);
|
|
if (!hasPermission || !(loggedInEmployee.ApplicationUser?.IsRootUser ?? false))
|
|
{
|
|
_logger.LogWarning("Permission denied: User {EmployeeId} attempted to create a tenant without sufficient rights.", loggedInEmployee.Id);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Access denied", "User does not have the required permissions for this action.", 403));
|
|
}
|
|
|
|
// 2. --- VALIDATION ---
|
|
// Check if a user with the same email already exists.
|
|
var existingUser = await _userManager.FindByEmailAsync(model.Email);
|
|
if (existingUser != null)
|
|
{
|
|
_logger.LogWarning("Tenant creation failed for email {Email}: an application user with this email already exists.", model.Email);
|
|
return StatusCode(409, ApiResponse<object>.ErrorResponse("Tenant cannot be created", "A user with the specified email already exists.", 409));
|
|
}
|
|
|
|
// Check if a tenant with the same Tax ID and Domain Name already exists.
|
|
var taxTask = Task.Run(async () =>
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(model.TaxId))
|
|
{
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
return await context.Tenants.AnyAsync(t => t.TaxId == model.TaxId);
|
|
|
|
}
|
|
return false;
|
|
});
|
|
var domainTask = Task.Run(async () =>
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(model.DomainName))
|
|
{
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
return await context.Tenants.AnyAsync(t => t.DomainName == model.DomainName);
|
|
|
|
}
|
|
return false;
|
|
});
|
|
|
|
await Task.WhenAll(taxTask, domainTask);
|
|
|
|
if (taxTask.Result || domainTask.Result)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(model.TaxId))
|
|
{
|
|
_logger.LogWarning("Tenant creation failed for Tax ID {TaxId}: a tenant with this Tax ID already exists.", model.TaxId);
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(model.DomainName))
|
|
{
|
|
_logger.LogWarning("Tenant creation failed for Domain Name {DomainName}: a tenant with this Domain Name already exists.", model.DomainName);
|
|
}
|
|
return StatusCode(409, ApiResponse<object>.ErrorResponse("Tenant cannot be created", "A tenant already exists.", 409));
|
|
}
|
|
|
|
// Check if the provided logo is a valid Base64 string.
|
|
if (!string.IsNullOrWhiteSpace(model.logoImage) && !IsBase64String(model.logoImage))
|
|
{
|
|
_logger.LogWarning("Tenant creation failed for user {EmployeeId}: The provided logo image was not a valid Base64 string.", loggedInEmployee.Id);
|
|
return StatusCode(400, ApiResponse<object>.ErrorResponse("Tenant cannot be created", "The provided logo image is invalid.", 400));
|
|
}
|
|
|
|
// 3. --- DATABASE TRANSACTION ---
|
|
// Use a transaction to ensure all related entities are created successfully or none at all.
|
|
await using var transaction = await _context.Database.BeginTransactionAsync();
|
|
try
|
|
{
|
|
// Create the primary Tenant entity
|
|
|
|
var tenant = _mapper.Map<Tenant>(model);
|
|
|
|
tenant.TenantStatusId = tenantActiveStatus;
|
|
tenant.CreatedById = loggedInEmployee.Id;
|
|
tenant.IsSuperTenant = false;
|
|
|
|
_context.Tenants.Add(tenant);
|
|
|
|
// Create the root ApplicationUser for the new tenant
|
|
var applicationUser = new ApplicationUser
|
|
{
|
|
Email = model.Email,
|
|
UserName = model.Email, // Best practice to use email as username for simplicity
|
|
IsRootUser = true,
|
|
EmailConfirmed = true, // Auto-confirming email as it's part of a trusted setup process
|
|
TenantId = tenant.Id
|
|
};
|
|
|
|
// SECURITY WARNING: Hardcoded passwords are a major vulnerability.
|
|
// Replace "User@123" with a securely generated random password.
|
|
var initialPassword = "User@123"; // TODO: Replace with password generation service.
|
|
var result = await _userManager.CreateAsync(applicationUser, initialPassword);
|
|
|
|
if (!result.Succeeded)
|
|
{
|
|
// If user creation fails, roll back the transaction immediately and return the errors.
|
|
await transaction.RollbackAsync();
|
|
var errors = result.Errors.Select(e => e.Description).ToList();
|
|
_logger.LogWarning("Failed to create ApplicationUser for tenant {TenantName}. Errors: {Errors}", model.OrganizationName, string.Join(", ", errors));
|
|
return BadRequest(ApiResponse<object>.ErrorResponse("Failed to create user", errors, 400));
|
|
}
|
|
|
|
// Create the default "Admin" Job Role for the tenant
|
|
var adminJobRole = new JobRole
|
|
{
|
|
Name = AdminRoleName,
|
|
Description = "Default administrator role for the tenant.",
|
|
TenantId = tenant.Id
|
|
};
|
|
_context.JobRoles.Add(adminJobRole);
|
|
|
|
// Create the primary Employee record and link it to the ApplicationUser and JobRole
|
|
var employeeUser = new Employee
|
|
{
|
|
FirstName = model.FirstName,
|
|
LastName = model.LastName,
|
|
Email = model.Email,
|
|
PhoneNumber = model.ContactNumber,
|
|
JoiningDate = model.OnBoardingDate,
|
|
ApplicationUserId = applicationUser.Id,
|
|
JobRole = adminJobRole, // Link to the newly created role
|
|
CurrentAddress = model.BillingAddress,
|
|
TenantId = tenant.Id
|
|
};
|
|
_context.Employees.Add(employeeUser);
|
|
|
|
var applicationRole = new ApplicationRole
|
|
{
|
|
Role = "Super User",
|
|
Description = "Super User",
|
|
IsSystem = true,
|
|
TenantId = tenant.Id
|
|
};
|
|
_context.ApplicationRoles.Add(applicationRole);
|
|
|
|
var rolePermissionMappigs = new List<RolePermissionMappings> {
|
|
new RolePermissionMappings
|
|
{
|
|
ApplicationRoleId = applicationRole.Id,
|
|
FeaturePermissionId = PermissionsMaster.ModifyTenant
|
|
},
|
|
new RolePermissionMappings
|
|
{
|
|
ApplicationRoleId = applicationRole.Id,
|
|
FeaturePermissionId = PermissionsMaster.ViewTenant
|
|
},
|
|
new RolePermissionMappings
|
|
{
|
|
ApplicationRoleId = applicationRole.Id,
|
|
FeaturePermissionId = PermissionsMaster.ManageMasters
|
|
},
|
|
new RolePermissionMappings
|
|
{
|
|
ApplicationRoleId = applicationRole.Id,
|
|
FeaturePermissionId = PermissionsMaster.ViewMasters
|
|
}
|
|
};
|
|
_context.RolePermissionMappings.AddRange(rolePermissionMappigs);
|
|
|
|
_context.EmployeeRoleMappings.Add(new EmployeeRoleMapping
|
|
{
|
|
EmployeeId = employeeUser.Id,
|
|
RoleId = applicationRole.Id,
|
|
IsEnabled = true,
|
|
TenantId = tenant.Id
|
|
});
|
|
|
|
// Create a default project for the new tenant
|
|
var project = new Project
|
|
{
|
|
Name = "Default Project",
|
|
ProjectStatusId = Guid.Parse("b74da4c2-d07e-46f2-9919-e75e49b12731"), // Consider using a constant for this GUID
|
|
ProjectAddress = model.BillingAddress,
|
|
StartDate = model.OnBoardingDate,
|
|
EndDate = DateTime.MaxValue,
|
|
ContactPerson = tenant.ContactName,
|
|
TenantId = tenant.Id
|
|
};
|
|
_context.Projects.Add(project);
|
|
|
|
var projectAllocation = new ProjectAllocation
|
|
{
|
|
ProjectId = project.Id,
|
|
EmployeeId = employeeUser.Id,
|
|
AllocationDate = model.OnBoardingDate,
|
|
IsActive = true,
|
|
JobRoleId = adminJobRole.Id,
|
|
TenantId = tenant.Id
|
|
};
|
|
_context.ProjectAllocations.Add(projectAllocation);
|
|
|
|
// All entities are now added to the context. Save them all in a single database operation.
|
|
await _context.SaveChangesAsync();
|
|
|
|
// 4. --- POST-CREATION ACTIONS ---
|
|
// Generate a password reset token so the new user can set their own password.
|
|
_logger.LogInfo("User {Email} created. Sending password setup email.", applicationUser.Email);
|
|
var token = await _userManager.GeneratePasswordResetTokenAsync(applicationUser);
|
|
var resetLink = $"{_configuration["AppSettings:WebFrontendUrl"]}/reset-password?token={WebUtility.UrlEncode(token)}&email={WebUtility.UrlEncode(applicationUser.Email)}";
|
|
await _emailSender.SendResetPasswordEmailOnRegister(applicationUser.Email, employeeUser.FirstName, resetLink);
|
|
|
|
// Map the result to a ViewModel for the API response.
|
|
var tenantVM = _mapper.Map<TenantVM>(tenant);
|
|
tenantVM.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
|
|
|
|
// Commit the transaction as all operations were successful.
|
|
await transaction.CommitAsync();
|
|
|
|
_logger.LogInfo("Successfully created tenant {TenantId} for organization {OrganizationName}.", tenant.Id, tenant.Name);
|
|
return StatusCode(201, ApiResponse<TenantVM>.SuccessResponse(tenantVM, "Tenant created successfully.", 201));
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
await transaction.RollbackAsync();
|
|
// Log the detailed database exception, including the inner exception if available.
|
|
_logger.LogError(dbEx, "A database update exception occurred while creating tenant for email {Email}. Inner Exception: {InnerException}",
|
|
model.Email, dbEx.InnerException?.Message ?? string.Empty);
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse("An internal database error occurred.", ExceptionMapper(dbEx), 500));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await transaction.RollbackAsync();
|
|
// Log the general exception.
|
|
_logger.LogError(ex, "An unexpected exception occurred while creating tenant for email {Email}.", model.Email);
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse("An unexpected internal error occurred.", ExceptionMapper(ex), 500));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates tenant and root employee details for a specified tenant ID.
|
|
/// </summary>
|
|
/// <param name="id">ID of the tenant to update</param>
|
|
/// <param name="model">Details to update</param>
|
|
/// <returns>Result of the operation</returns>
|
|
|
|
[HttpPut("edit/{id}")]
|
|
public async Task<IActionResult> UpdateTenant(Guid id, [FromBody] UpdateTenantDto model)
|
|
{
|
|
_logger.LogInfo("UpdateTenant called for TenantId: {TenantId} by user.", id);
|
|
|
|
// 1. Retrieve the logged-in employee information
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
if (loggedInEmployee == null)
|
|
{
|
|
_logger.LogWarning("Unauthorized access - User not logged in.");
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Unauthorized", "User must be logged in.", 403));
|
|
}
|
|
|
|
// 2. Check permissions using a single service scope to avoid overhead
|
|
bool hasManagePermission, hasModifyPermission;
|
|
UtilityMongoDBHelper _updateLogHelper;
|
|
using (var scope = _serviceScopeFactory.CreateScope())
|
|
{
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
_updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
|
|
|
|
var manageTask = permissionService.HasPermission(PermissionsMaster.ManageTenants, loggedInEmployee.Id);
|
|
var modifyTask = permissionService.HasPermission(PermissionsMaster.ModifyTenant, loggedInEmployee.Id);
|
|
|
|
await Task.WhenAll(manageTask, modifyTask);
|
|
|
|
hasManagePermission = manageTask.Result;
|
|
hasModifyPermission = modifyTask.Result;
|
|
}
|
|
|
|
if (!hasManagePermission && !hasModifyPermission)
|
|
{
|
|
_logger.LogWarning("Access denied: User {EmployeeId} lacks required permissions for UpdateTenant on TenantId: {TenantId}.", loggedInEmployee.Id, id);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Access denied", "User does not have the required permissions for this action.", 403));
|
|
}
|
|
if (!hasManagePermission && hasModifyPermission && id != loggedInEmployee.TenantId)
|
|
{
|
|
_logger.LogWarning("Permission denied: User {EmployeeId} attempted to access tenant details of other tenant.", loggedInEmployee.Id);
|
|
return StatusCode(403,
|
|
ApiResponse<object>.ErrorResponse("Access denied", "User does not have the required permissions for this action.", 403));
|
|
}
|
|
// 3. Use a single DbContext instance for data access
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
|
|
// 4. Fetch tenant (with related Industry, TenantStatus), tracking enabled for updates
|
|
var tenant = await context.Tenants
|
|
.Include(t => t.Industry)
|
|
.Include(t => t.TenantStatus)
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(t => t.Id == id);
|
|
|
|
if (tenant == null)
|
|
{
|
|
_logger.LogWarning("Tenant not found: ID {TenantId}", id);
|
|
return NotFound(ApiResponse<object>.ErrorResponse("Tenant not found", "Tenant not found", 404));
|
|
}
|
|
|
|
_logger.LogInfo("Tenant {TenantId} fetched for update.", tenant.Id);
|
|
|
|
var tenantObject = _updateLogHelper.EntityToBsonDocument(tenant);
|
|
// 5. Map update DTO properties to the tenant entity
|
|
_mapper.Map(model, tenant);
|
|
|
|
// 6. Fetch root employee for the tenant (includes ApplicationUser)
|
|
var rootEmployee = await context.Employees
|
|
.Include(e => e.ApplicationUser)
|
|
.FirstOrDefaultAsync(e => e.TenantId == tenant.Id && e.ApplicationUser != null && (e.ApplicationUser.IsRootUser ?? false));
|
|
|
|
if (rootEmployee == null)
|
|
{
|
|
_logger.LogWarning("Root employee not found for tenant {TenantId}", id);
|
|
return NotFound(ApiResponse<object>.ErrorResponse("Root employee not found", "Root employee not found", 404));
|
|
}
|
|
var employeeobject = _updateLogHelper.EntityToBsonDocument(rootEmployee);
|
|
// 7. Update root employee details
|
|
rootEmployee.FirstName = model.FirstName;
|
|
rootEmployee.LastName = model.LastName;
|
|
rootEmployee.PhoneNumber = model.ContactNumber;
|
|
rootEmployee.CurrentAddress = model.BillingAddress;
|
|
|
|
// 8. Save changes to DB
|
|
try
|
|
{
|
|
context.Tenants.Update(tenant);
|
|
await context.SaveChangesAsync();
|
|
_logger.LogInfo("Tenant {TenantId} and root employee updated successfully.", tenant.Id);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating Tenant {TenantId} or root employee.", tenant.Id);
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse("Error updating tenant", "Unexpected error occurred while updating tenant.", 500));
|
|
}
|
|
|
|
var tenantTaks = Task.Run(async () =>
|
|
{
|
|
await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = tenant.Id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = tenantObject,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, "TenantModificationLog");
|
|
});
|
|
var employeeTaks = Task.Run(async () =>
|
|
{
|
|
await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = rootEmployee.Id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = employeeobject,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, "EmployeeModificationLog");
|
|
});
|
|
|
|
await Task.WhenAll(tenantTaks, employeeTaks);
|
|
|
|
// 9. Map updated tenant to ViewModel for response
|
|
var response = _mapper.Map<TenantVM>(tenant);
|
|
|
|
return Ok(ApiResponse<object>.SuccessResponse(response, "Tenant updated successfully", 200));
|
|
}
|
|
|
|
|
|
// DELETE api/<TenantController>/5
|
|
[HttpDelete("delete/{id}")]
|
|
public async Task<IActionResult> DeleteTenant(Guid id, [FromQuery] bool isActive = false)
|
|
{
|
|
var action = isActive ? "activation" : "deactivation";
|
|
_logger.LogInfo("Attempting tenant {Action} for ID: {TenantId}", action, id);
|
|
|
|
// --- 1. Authentication & Authorization ---
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
if (loggedInEmployee == null)
|
|
{
|
|
_logger.LogWarning("Unauthorized tenant status update attempt. User is not logged in.");
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Unauthorized", "User must be logged in to perform this action.", 403));
|
|
}
|
|
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var _permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
var _updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
|
|
|
|
var hasPermission = await _permissionService.HasPermission(PermissionsMaster.ManageTenants, loggedInEmployee.Id);
|
|
if (!hasPermission && !(loggedInEmployee.ApplicationUser?.IsRootUser ?? false))
|
|
{
|
|
_logger.LogWarning(
|
|
"Permission Denied: User {EmployeeId} attempted tenant status update for Tenant {TenantId} without 'ManageTenants' permission.",
|
|
loggedInEmployee.Id, id);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Access Denied", "User does not have the required permissions for this action.", 403));
|
|
}
|
|
|
|
// --- 2. Data Retrieval ---
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
var tenant = await context.Tenants
|
|
// Include related data only if it's required by the TenantVM mapping.
|
|
// If not, removing these improves performance.
|
|
.Include(t => t.Industry)
|
|
.Include(t => t.TenantStatus)
|
|
.FirstOrDefaultAsync(t => t.Id == id);
|
|
|
|
if (tenant == null)
|
|
{
|
|
_logger.LogWarning("Tenant status update failed: Tenant with ID {TenantId} not found.", id);
|
|
return NotFound(ApiResponse<object>.ErrorResponse("Not Found", $"Tenant with ID '{id}' was not found.", 404));
|
|
}
|
|
|
|
// --- 3. Logic & State Change ---
|
|
// Efficiency: If the state is already what is being requested, do nothing.
|
|
if (tenant.IsActive == isActive)
|
|
{
|
|
var currentStatus = isActive ? "already active" : "already inactive";
|
|
_logger.LogInfo("No action needed. Tenant {TenantId} is {Status}.", tenant.Id, currentStatus);
|
|
var noChangeMessage = $"Tenant was {currentStatus}. No changes were made.";
|
|
return Ok(ApiResponse<object>.SuccessResponse(_mapper.Map<TenantVM>(tenant), noChangeMessage, 200));
|
|
}
|
|
|
|
// Capture the state *before* modification for the audit log.
|
|
var tenantOldStateBson = _updateLogHelper.EntityToBsonDocument(tenant);
|
|
tenant.IsActive = isActive;
|
|
|
|
// --- 4. Database Persistence ---
|
|
try
|
|
{
|
|
await context.SaveChangesAsync();
|
|
_logger.LogInfo("Successfully updated Tenant {TenantId} IsActive status to {IsActive}.", tenant.Id, isActive);
|
|
}
|
|
catch (DbUpdateException ex) // Be more specific with exceptions if possible.
|
|
{
|
|
_logger.LogError(ex, "Database error occurred while updating status for Tenant {TenantId}.", tenant.Id);
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse("Database Error", "An error occurred while saving changes to the database.", 500));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "An unexpected error occurred while updating status for Tenant {TenantId}.", tenant.Id);
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse("Server Error", "An unexpected error occurred.", 500));
|
|
}
|
|
|
|
// --- 5. Audit Logging ---
|
|
// This runs after the DB save is confirmed.
|
|
// Note: If this call fails, the audit log will be missing for a successful DB change.
|
|
// For critical systems, consider a more robust outbox pattern.
|
|
await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = tenant.Id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = tenantOldStateBson,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, "TenantModificationLog");
|
|
_logger.LogInfo("Audit log created for status change of Tenant {TenantId} by User {EmployeeId}.", tenant.Id, loggedInEmployee.Id);
|
|
|
|
|
|
// --- 6. Prepare and Return Response ---
|
|
var responseData = _mapper.Map<TenantVM>(tenant);
|
|
var successMessage = $"Tenant successfully {(isActive ? "activated" : "deactivated")}.";
|
|
|
|
return Ok(ApiResponse<object>.SuccessResponse(responseData, successMessage, 200));
|
|
}
|
|
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Subscription APIs ===================================================================
|
|
|
|
[HttpPost("add-subscription")]
|
|
public async Task<IActionResult> 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 StatusCode(403, ApiResponse<object>.ErrorResponse("Unauthorized", "User must be logged in.", 403));
|
|
}
|
|
|
|
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
|
|
var _permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
|
|
var isRootUser = loggedInEmployee.ApplicationUser?.IsRootUser ?? false;
|
|
var hasPermission = await _permissionService.HasPermission(PermissionsMaster.ManageTenants, loggedInEmployee.Id);
|
|
|
|
if (!hasPermission || !isRootUser)
|
|
{
|
|
_logger.LogWarning("Permission denied: User {EmployeeId} attempted to add subscription without permission or root access.",
|
|
loggedInEmployee.Id);
|
|
|
|
return StatusCode(403,
|
|
ApiResponse<object>.ErrorResponse("Access denied",
|
|
"User does not have the required permissions for this action.", 403));
|
|
}
|
|
|
|
var subscriptionPlan = await _context.SubscriptionPlanDetails.Include(sp => sp.Plan).FirstOrDefaultAsync(sp => sp.Id == model.PlanId);
|
|
|
|
var tenant = await _context.Tenants.FirstOrDefaultAsync(t => t.Id == model.TenantId);
|
|
if (tenant == null)
|
|
{
|
|
_logger.LogWarning("Tenant {TenantId} not found in database", model.TenantId);
|
|
return NotFound(ApiResponse<object>.ErrorResponse("Tenant not found", "Tenant not found", 404));
|
|
}
|
|
if (subscriptionPlan == null)
|
|
{
|
|
_logger.LogWarning("Subscription plan {PlanId} not found in database", model.PlanId);
|
|
return NotFound(ApiResponse<object>.ErrorResponse("Subscription plan not found", "Subscription plan not found", 404));
|
|
}
|
|
var activeUsers = await _context.Employees.CountAsync(e => e.Email != null && e.ApplicationUserId != null && e.TenantId == tenant.Id && e.IsActive);
|
|
if (activeUsers > model.MaxUsers)
|
|
{
|
|
_logger.LogWarning("Employee {EmployeeId} add less max user than the active user in the tenant {TenantId}", loggedInEmployee.Id, tenant.Id);
|
|
return BadRequest(ApiResponse<object>.ErrorResponse("Invalid Max user count", "Max User count must be higher than active user count", 400));
|
|
}
|
|
await using var transaction = await _context.Database.BeginTransactionAsync();
|
|
var utcNow = DateTime.UtcNow;
|
|
|
|
// Prepare subscription dates based on frequency
|
|
var endDate = subscriptionPlan.Frequency switch
|
|
{
|
|
PLAN_FREQUENCY.MONTHLY => utcNow.AddDays(30),
|
|
PLAN_FREQUENCY.QUARTERLY => utcNow.AddDays(90),
|
|
PLAN_FREQUENCY.HALF_YEARLY => utcNow.AddDays(120),
|
|
PLAN_FREQUENCY.YEARLY => utcNow.AddDays(360),
|
|
_ => utcNow // default if unknown
|
|
};
|
|
|
|
var tenantSubscription = new TenantSubscriptions
|
|
{
|
|
TenantId = model.TenantId,
|
|
PlanId = model.PlanId,
|
|
StatusId = activePlanStatus,
|
|
CreatedAt = utcNow,
|
|
MaxUsers = model.MaxUsers,
|
|
CreatedById = loggedInEmployee.Id,
|
|
CurrencyId = model.CurrencyId,
|
|
IsTrial = model.IsTrial,
|
|
StartDate = utcNow,
|
|
EndDate = endDate,
|
|
NextBillingDate = endDate,
|
|
AutoRenew = model.AutoRenew
|
|
};
|
|
|
|
_context.TenantSubscriptions.Add(tenantSubscription);
|
|
|
|
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<object>.ErrorResponse("Internal error occured", ExceptionMapper(dbEx), 500));
|
|
}
|
|
|
|
try
|
|
{
|
|
_ = Task.Run(async () =>
|
|
{
|
|
await ClearPermissionForTenant();
|
|
});
|
|
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<object>.SuccessResponse(tenantSubscription, "Tenant subscription successfully added", 200));
|
|
}
|
|
|
|
// Helper to get permissions for a module asynchronously
|
|
async Task<List<Guid>> GetPermissionsForModuleAsync(List<Guid>? featureIds)
|
|
{
|
|
if (featureIds == null || featureIds.Count == 0) return new List<Guid>();
|
|
|
|
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<Guid> { EmployeeFeatureId });
|
|
|
|
await Task.WhenAll(projectPermissionTask, attendancePermissionTask, directoryPermissionTask, expensePermissionTask, employeePermissionTask);
|
|
|
|
var newPermissionIds = new List<Guid>();
|
|
var deletePermissionIds = new List<Guid>();
|
|
|
|
// Add or remove permissions based on modules enabled status
|
|
void ProcessPermissions(bool? enabled, List<Guid> 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<object>.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<object>.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);
|
|
}
|
|
|
|
var _masteData = scope.ServiceProvider.GetRequiredService<MasterDataService>();
|
|
|
|
if (features.Modules?.ProjectManagement?.Enabled ?? false)
|
|
{
|
|
var workCategoryMaster = _masteData.GetWorkCategoriesData(tenant.Id);
|
|
var workStatusMaster = _masteData.GetWorkStatusesData(tenant.Id);
|
|
|
|
_context.WorkCategoryMasters.AddRange(workCategoryMaster);
|
|
_context.WorkStatusMasters.AddRange(workStatusMaster);
|
|
}
|
|
if (features.Modules?.Expense?.Enabled ?? false)
|
|
{
|
|
var expensesTypeMaster = _masteData.GetExpensesTypeesData(tenant.Id);
|
|
var paymentModeMatser = _masteData.GetPaymentModesData(tenant.Id);
|
|
|
|
_context.ExpensesTypeMaster.AddRange(expensesTypeMaster);
|
|
_context.PaymentModeMatser.AddRange(paymentModeMatser);
|
|
}
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
await transaction.CommitAsync();
|
|
|
|
_logger.LogInfo("Permissions updated successfully for tenant {TenantId} subscription", model.TenantId);
|
|
|
|
return Ok(ApiResponse<object>.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<object>.ErrorResponse("Internal error occured", ExceptionMapper(ex), 500));
|
|
}
|
|
}
|
|
|
|
[HttpPut("update-subscription")]
|
|
public async Task<IActionResult> UpdateSubscriptionAsync(UpdateSubscriptionDto model)
|
|
{
|
|
// 1. Get the logged-in user's employee record.
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
|
|
// 2. Create a new DbContext instance for this request.
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
|
|
// 3. Get PermissionServices from DI inside a fresh scope (rarely needed, but retained for your design).
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
var _updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
|
|
|
|
// 4. Check user permissions: must be both Root user and have ManageTenants permission.
|
|
var isRootUser = loggedInEmployee.ApplicationUser?.IsRootUser ?? false;
|
|
var hasPermission = await permissionService.HasPermission(PermissionsMaster.ManageTenants, loggedInEmployee.Id);
|
|
|
|
if (!isRootUser || !hasPermission)
|
|
{
|
|
_logger.LogWarning("Permission denied for EmployeeId={EmployeeId}. Root: {IsRoot}, HasPermission: {HasPermission}",
|
|
loggedInEmployee.Id, isRootUser, hasPermission);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Access denied",
|
|
"User does not have the required permissions.", 403));
|
|
}
|
|
|
|
// 5. Fetch Tenant, SubscriptionPlan, and TenantSubscription in parallel (efficiently).
|
|
var tenantTask = _dbContextFactory.CreateDbContextAsync().ContinueWith(ctx =>
|
|
ctx.Result.Tenants.AsNoTracking().FirstOrDefaultAsync(t => t.Id == model.TenantId)).Unwrap();
|
|
|
|
var planDetailsTask = _dbContextFactory.CreateDbContextAsync().ContinueWith(ctx =>
|
|
ctx.Result.SubscriptionPlanDetails.Include(sp => sp.Plan).AsNoTracking().FirstOrDefaultAsync(sp => sp.Id == model.PlanId)).Unwrap();
|
|
|
|
var currentSubscriptionTask = _dbContextFactory.CreateDbContextAsync().ContinueWith(ctx =>
|
|
ctx.Result.TenantSubscriptions.Include(ts => ts.Currency).AsNoTracking().FirstOrDefaultAsync(ts => ts.TenantId == model.TenantId && !ts.IsCancelled)).Unwrap();
|
|
|
|
await Task.WhenAll(tenantTask, planDetailsTask, currentSubscriptionTask);
|
|
|
|
var tenant = tenantTask.Result;
|
|
if (tenant == null)
|
|
{
|
|
_logger.LogWarning("Tenant {TenantId} not found.", model.TenantId);
|
|
return NotFound(ApiResponse<object>.ErrorResponse("Tenant not found", "Tenant not found", 404));
|
|
}
|
|
|
|
var subscriptionPlan = planDetailsTask.Result;
|
|
if (subscriptionPlan == null)
|
|
{
|
|
_logger.LogWarning("Subscription plan {PlanId} not found.", model.PlanId);
|
|
return NotFound(ApiResponse<object>.ErrorResponse("Subscription plan not found", "Subscription plan not found", 404));
|
|
}
|
|
|
|
var currentSubscription = currentSubscriptionTask.Result;
|
|
var utcNow = DateTime.UtcNow;
|
|
|
|
var activeUsers = await context.Employees.CountAsync(e => e.Email != null && e.ApplicationUserId != null && e.TenantId == tenant.Id && e.IsActive);
|
|
if (activeUsers > model.MaxUsers)
|
|
{
|
|
_logger.LogWarning("Employee {EmployeeId} add less max user than the active user in the tenant {TenantId}", loggedInEmployee.Id, tenant.Id);
|
|
return BadRequest(ApiResponse<object>.ErrorResponse("Invalid Max user count", "Max User count must be higher than active user count", 400));
|
|
}
|
|
|
|
// 6. If the tenant already has this plan, extend/renew it.
|
|
if (currentSubscription != null && currentSubscription.PlanId == subscriptionPlan.Id)
|
|
{
|
|
var existingEntityBson = _updateLogHelper.EntityToBsonDocument(currentSubscription);
|
|
DateTime newEndDate;
|
|
// 6a. If the subscription is still active, extend from current EndDate; else start from now.
|
|
if (currentSubscription.EndDate.Date >= utcNow.Date)
|
|
{
|
|
newEndDate = subscriptionPlan.Frequency switch
|
|
{
|
|
PLAN_FREQUENCY.MONTHLY => currentSubscription.EndDate.AddDays(30),
|
|
PLAN_FREQUENCY.QUARTERLY => currentSubscription.EndDate.AddDays(90),
|
|
PLAN_FREQUENCY.HALF_YEARLY => currentSubscription.EndDate.AddDays(120),
|
|
PLAN_FREQUENCY.YEARLY => currentSubscription.EndDate.AddDays(360),
|
|
_ => currentSubscription.EndDate
|
|
};
|
|
}
|
|
else
|
|
{
|
|
newEndDate = subscriptionPlan.Frequency switch
|
|
{
|
|
PLAN_FREQUENCY.MONTHLY => utcNow.AddDays(30),
|
|
PLAN_FREQUENCY.QUARTERLY => utcNow.AddDays(90),
|
|
PLAN_FREQUENCY.HALF_YEARLY => utcNow.AddDays(120),
|
|
PLAN_FREQUENCY.YEARLY => utcNow.AddDays(360),
|
|
_ => utcNow // default if unknown
|
|
};
|
|
}
|
|
|
|
// 6b. Update subscription details
|
|
if (model.MaxUsers != null && model.MaxUsers > 0)
|
|
{
|
|
currentSubscription.MaxUsers = model.MaxUsers.Value;
|
|
}
|
|
currentSubscription.StartDate = utcNow;
|
|
currentSubscription.EndDate = newEndDate;
|
|
currentSubscription.NextBillingDate = newEndDate;
|
|
currentSubscription.UpdateAt = utcNow;
|
|
currentSubscription.UpdatedById = loggedInEmployee.Id;
|
|
|
|
context.TenantSubscriptions.Update(currentSubscription);
|
|
await context.SaveChangesAsync();
|
|
|
|
_logger.LogInfo("Subscription renewed: Tenant={TenantId}, Plan={PlanId}, NewEnd={EndDate}",
|
|
model.TenantId, model.PlanId, newEndDate);
|
|
|
|
await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = currentSubscription.Id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = existingEntityBson,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, "SubscriptionPlanModificationLog");
|
|
|
|
return Ok(ApiResponse<object>.SuccessResponse(currentSubscription, "Subscription renewed/extended", 200));
|
|
}
|
|
|
|
// 7. Else, change plan: new subscription record, close the old if exists.
|
|
await using var transaction = await context.Database.BeginTransactionAsync();
|
|
try
|
|
{
|
|
// 7a. Compute new plan dates
|
|
var endDate = subscriptionPlan.Frequency switch
|
|
{
|
|
PLAN_FREQUENCY.MONTHLY => utcNow.AddDays(30),
|
|
PLAN_FREQUENCY.QUARTERLY => utcNow.AddDays(90),
|
|
PLAN_FREQUENCY.HALF_YEARLY => utcNow.AddDays(120),
|
|
PLAN_FREQUENCY.YEARLY => utcNow.AddDays(360),
|
|
_ => utcNow // default if unknown
|
|
};
|
|
|
|
var newSubscription = new TenantSubscriptions
|
|
{
|
|
TenantId = model.TenantId,
|
|
PlanId = model.PlanId,
|
|
StatusId = activePlanStatus,
|
|
CreatedAt = utcNow,
|
|
MaxUsers = model.MaxUsers ?? (currentSubscription?.MaxUsers ?? subscriptionPlan.MaxUser),
|
|
CreatedById = loggedInEmployee.Id,
|
|
CurrencyId = model.CurrencyId,
|
|
StartDate = utcNow,
|
|
EndDate = endDate,
|
|
NextBillingDate = endDate,
|
|
IsTrial = currentSubscription?.IsTrial ?? false,
|
|
AutoRenew = currentSubscription?.AutoRenew ?? false
|
|
};
|
|
context.TenantSubscriptions.Add(newSubscription);
|
|
|
|
// 7b. If an old subscription exists, cancel it.
|
|
if (currentSubscription != null)
|
|
{
|
|
currentSubscription.IsCancelled = true;
|
|
currentSubscription.CancellationDate = utcNow;
|
|
currentSubscription.UpdateAt = utcNow;
|
|
currentSubscription.UpdatedById = loggedInEmployee.Id;
|
|
context.TenantSubscriptions.Update(currentSubscription);
|
|
}
|
|
await context.SaveChangesAsync();
|
|
_logger.LogInfo("Subscription plan changed: Tenant={TenantId}, NewPlan={PlanId}",
|
|
model.TenantId, model.PlanId);
|
|
|
|
_ = Task.Run(async () =>
|
|
{
|
|
await ClearPermissionForTenant();
|
|
});
|
|
|
|
// 8. Update tenant permissions based on subscription features.
|
|
var features = await _featureDetailsHelper.GetFeatureDetails(subscriptionPlan.FeaturesId);
|
|
if (features == null)
|
|
{
|
|
_logger.LogInfo("No features for Plan={PlanId}.", model.PlanId);
|
|
await transaction.CommitAsync();
|
|
return Ok(ApiResponse<object>.SuccessResponse(newSubscription, "Tenant subscription updated (no features)", 200));
|
|
}
|
|
|
|
// 8a. Async helper to get all permission IDs for a given module.
|
|
async Task<List<Guid>> GetPermissionsForFeaturesAsync(List<Guid>? featureIds)
|
|
{
|
|
if (featureIds == null || featureIds.Count == 0) return new List<Guid>();
|
|
|
|
await using var ctx = await _dbContextFactory.CreateDbContextAsync();
|
|
return await ctx.FeaturePermissions.AsNoTracking()
|
|
.Where(fp => featureIds.Contains(fp.FeatureId))
|
|
.Select(fp => fp.Id)
|
|
.ToListAsync();
|
|
}
|
|
|
|
// 8b. Fetch all module permissions concurrently.
|
|
var projectPermTask = GetPermissionsForFeaturesAsync(features.Modules?.ProjectManagement?.FeatureId);
|
|
var attendancePermTask = GetPermissionsForFeaturesAsync(features.Modules?.Attendance?.FeatureId);
|
|
var directoryPermTask = GetPermissionsForFeaturesAsync(features.Modules?.Directory?.FeatureId);
|
|
var expensePermTask = GetPermissionsForFeaturesAsync(features.Modules?.Expense?.FeatureId);
|
|
var employeePermTask = GetPermissionsForFeaturesAsync(new List<Guid> { EmployeeFeatureId }); // assumed defined
|
|
|
|
await Task.WhenAll(projectPermTask, attendancePermTask, directoryPermTask, expensePermTask, employeePermTask);
|
|
|
|
// 8c. Find root employee & 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("No root employee for Tenant={TenantId}.", model.TenantId);
|
|
await transaction.CommitAsync();
|
|
return Ok(ApiResponse<object>.SuccessResponse(newSubscription, "Tenant subscription updated (no root employee)", 200));
|
|
}
|
|
|
|
var rootRoleId = await context.EmployeeRoleMappings
|
|
.AsNoTracking()
|
|
.Where(er => er.EmployeeId == rootEmployee.Id && er.TenantId == model.TenantId)
|
|
.Select(er => er.RoleId)
|
|
.FirstOrDefaultAsync();
|
|
|
|
if (rootRoleId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("No root role for Employee={EmployeeId}, Tenant={TenantId}.", rootEmployee.Id, model.TenantId);
|
|
await transaction.CommitAsync();
|
|
return Ok(ApiResponse<object>.SuccessResponse(newSubscription, "Tenant subscription updated (no root role)", 200));
|
|
}
|
|
|
|
var dbOldRolePerms = await context.RolePermissionMappings.Where(x => x.ApplicationRoleId == rootRoleId).ToListAsync();
|
|
var oldPermIds = dbOldRolePerms.Select(rp => rp.FeaturePermissionId).ToList();
|
|
|
|
// 8d. Prepare add and remove permission lists.
|
|
var newPermissionIds = new List<Guid>();
|
|
var revokePermissionIds = new List<Guid>();
|
|
var employeePerms = employeePermTask.Result;
|
|
var isOldEmployeePermissionIdExist = oldPermIds.Any(fp => employeePerms.Contains(fp));
|
|
|
|
void ProcessPerms(bool? enabled, List<Guid> ids)
|
|
{
|
|
var isOldPermissionIdExist = oldPermIds.Any(fp => ids.Contains(fp) && !employeePerms.Contains(fp));
|
|
|
|
if (enabled == true && !isOldPermissionIdExist) newPermissionIds.AddRange(ids);
|
|
if (enabled == true && !isOldEmployeePermissionIdExist) newPermissionIds.AddRange(ids);
|
|
if (enabled == false && isOldPermissionIdExist)
|
|
revokePermissionIds.AddRange(ids);
|
|
}
|
|
ProcessPerms(features.Modules?.ProjectManagement?.Enabled, projectPermTask.Result);
|
|
ProcessPerms(features.Modules?.Attendance?.Enabled, attendancePermTask.Result);
|
|
ProcessPerms(features.Modules?.Directory?.Enabled, directoryPermTask.Result);
|
|
ProcessPerms(features.Modules?.Expense?.Enabled, expensePermTask.Result);
|
|
|
|
newPermissionIds = newPermissionIds.Distinct().ToList();
|
|
revokePermissionIds = revokePermissionIds.Distinct().ToList();
|
|
|
|
|
|
// 8e. Prevent accidental loss of basic employee permissions.
|
|
if ((features.Modules?.ProjectManagement?.Enabled == true ||
|
|
features.Modules?.Attendance?.Enabled == true ||
|
|
features.Modules?.Directory?.Enabled == true ||
|
|
features.Modules?.Expense?.Enabled == true) && isOldEmployeePermissionIdExist)
|
|
{
|
|
revokePermissionIds = revokePermissionIds.Where(pid => !employeePerms.Contains(pid)).ToList();
|
|
}
|
|
|
|
// 8f. Prepare permission-mapping records to add/remove.
|
|
var mappingsToRemove = dbOldRolePerms.Where(rp => revokePermissionIds.Contains(rp.FeaturePermissionId)).ToList();
|
|
var mappingsToAdd = newPermissionIds
|
|
.Where(pid => !oldPermIds.Contains(pid))
|
|
.Select(pid => new RolePermissionMappings { ApplicationRoleId = rootRoleId, FeaturePermissionId = pid })
|
|
.ToList();
|
|
|
|
if (mappingsToAdd.Any())
|
|
{
|
|
context.RolePermissionMappings.AddRange(mappingsToAdd);
|
|
_logger.LogInfo("Permissions granted: {Count} for Role={RoleId}", mappingsToAdd.Count, rootRoleId);
|
|
}
|
|
if (mappingsToRemove.Any())
|
|
{
|
|
context.RolePermissionMappings.RemoveRange(mappingsToRemove);
|
|
_logger.LogInfo("Permissions revoked: {Count} for Role={RoleId}", mappingsToRemove.Count, rootRoleId);
|
|
}
|
|
|
|
var _masteData = scope.ServiceProvider.GetRequiredService<MasterDataService>();
|
|
|
|
if (features.Modules?.ProjectManagement?.Enabled ?? false)
|
|
{
|
|
var workCategoryMaster = _masteData.GetWorkCategoriesData(tenant.Id);
|
|
var workStatusMaster = _masteData.GetWorkStatusesData(tenant.Id);
|
|
|
|
var workCategoryTask = Task.Run(async () =>
|
|
{
|
|
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
|
return await _context.WorkCategoryMasters.AnyAsync(wc => wc.IsSystem && wc.TenantId == tenant.Id);
|
|
});
|
|
var workStatusTask = Task.Run(async () =>
|
|
{
|
|
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
|
return await _context.WorkStatusMasters.AnyAsync(ws => ws.IsSystem && ws.TenantId == tenant.Id);
|
|
});
|
|
|
|
await Task.WhenAll(workCategoryTask, workStatusTask);
|
|
|
|
var workCategoryExist = workCategoryTask.Result;
|
|
var workStatusExist = workStatusTask.Result;
|
|
if (!workCategoryExist)
|
|
{
|
|
context.WorkCategoryMasters.AddRange(workCategoryMaster);
|
|
}
|
|
if (!workStatusExist)
|
|
{
|
|
context.WorkStatusMasters.AddRange(workStatusMaster);
|
|
}
|
|
}
|
|
if (features.Modules?.Expense?.Enabled ?? false)
|
|
{
|
|
var expensesTypeMaster = _masteData.GetExpensesTypeesData(tenant.Id);
|
|
var paymentModeMatser = _masteData.GetPaymentModesData(tenant.Id);
|
|
|
|
var expensesTypeTask = Task.Run(async () =>
|
|
{
|
|
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
|
var expensesTypeNames = expensesTypeMaster.Select(et => et.Name).ToList();
|
|
return await _context.ExpensesTypeMaster.AnyAsync(et => expensesTypeNames.Contains(et.Name) && et.TenantId == tenant.Id);
|
|
});
|
|
var paymentModeTask = Task.Run(async () =>
|
|
{
|
|
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
|
var paymentModeNames = paymentModeMatser.Select(py => py.Name).ToList();
|
|
return await _context.PaymentModeMatser.AnyAsync(py => paymentModeNames.Contains(py.Name) && py.TenantId == tenant.Id);
|
|
});
|
|
|
|
await Task.WhenAll(expensesTypeTask, paymentModeTask);
|
|
|
|
var expensesTypeExist = expensesTypeTask.Result;
|
|
var paymentModeExist = paymentModeTask.Result;
|
|
if (!expensesTypeExist)
|
|
{
|
|
context.ExpensesTypeMaster.AddRange(expensesTypeMaster);
|
|
}
|
|
if (!paymentModeExist)
|
|
{
|
|
context.PaymentModeMatser.AddRange(paymentModeMatser);
|
|
}
|
|
}
|
|
|
|
await context.SaveChangesAsync();
|
|
await transaction.CommitAsync();
|
|
|
|
_logger.LogInfo("Tenant subscription and permissions updated: Tenant={TenantId}", model.TenantId);
|
|
|
|
return Ok(ApiResponse<object>.SuccessResponse(newSubscription, "Tenant subscription successfully updated", 200));
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
await transaction.RollbackAsync();
|
|
_logger.LogError(dbEx, "Database exception updating subscription for TenantId={TenantId}", model.TenantId);
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse("Internal database error", ExceptionMapper(dbEx), 500));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await transaction.RollbackAsync();
|
|
_logger.LogError(ex, "General exception for TenantId={TenantId}", model.TenantId);
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse("Internal error occurred", ExceptionMapper(ex), 500));
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Subscription Plan APIs ===================================================================
|
|
|
|
[HttpGet("list/subscription-plan")]
|
|
public async Task<IActionResult> GetSubscriptionPlanList([FromQuery] PLAN_FREQUENCY? frequency)
|
|
{
|
|
_logger.LogInfo("GetSubscriptionPlanList called with frequency: {Frequency}", frequency ?? PLAN_FREQUENCY.MONTHLY);
|
|
|
|
// Initialize the list to store subscription plan view models
|
|
List<SubscriptionPlanVM> detailsVM = new List<SubscriptionPlanVM>();
|
|
|
|
try
|
|
{
|
|
// Create DbContext
|
|
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
|
|
|
// Load subscription plans with optional frequency filtering
|
|
IQueryable<SubscriptionPlanDetails> query = _context.SubscriptionPlanDetails.Include(sp => sp.Plan).Include(sp => sp.Currency);
|
|
|
|
if (frequency.HasValue)
|
|
{
|
|
query = query.Where(sp => sp.Frequency == frequency.Value);
|
|
_logger.LogInfo("Filtering subscription plans by frequency: {Frequency}", frequency);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInfo("Fetching all subscription plans without frequency filter");
|
|
}
|
|
|
|
var subscriptionPlans = await query.ToListAsync();
|
|
|
|
// Map and fetch feature details for each subscription plan
|
|
foreach (var subscriptionPlan in subscriptionPlans)
|
|
{
|
|
var response = _mapper.Map<SubscriptionPlanVM>(subscriptionPlan);
|
|
|
|
try
|
|
{
|
|
response.Features = await _featureDetailsHelper.GetFeatureDetails(subscriptionPlan.FeaturesId);
|
|
}
|
|
catch (Exception exFeature)
|
|
{
|
|
_logger.LogError(exFeature, "Failed to fetch features for FeaturesId: {FeaturesId}", subscriptionPlan.FeaturesId);
|
|
response.Features = null; // or set to a default/fallback value
|
|
}
|
|
|
|
detailsVM.Add(response);
|
|
}
|
|
|
|
_logger.LogInfo("Successfully fetched {Count} subscription plans", detailsVM.Count);
|
|
|
|
return Ok(ApiResponse<object>.SuccessResponse(detailsVM, "List of plans fetched successfully", 200));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error occurred while fetching subscription plans");
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse("An error occurred while fetching subscription plans."));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new subscription plan with details for each frequency (monthly, quarterly, half-yearly, yearly).
|
|
/// Only employees with root status and 'ManageTenants' permission can create plans.
|
|
/// </summary>
|
|
[HttpPost("create/subscription-plan")]
|
|
public async Task<IActionResult> CreateSubscriptionPlan([FromBody] SubscriptionPlanDto model)
|
|
{
|
|
// Step 1: Authenticate and check permissions
|
|
var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
|
|
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
var _permissionService = scope.ServiceProvider.GetRequiredService<PermissionServices>();
|
|
|
|
// Permission check: root user or explicit ManageTenants permission
|
|
var isRootUser = loggedInEmployee.ApplicationUser?.IsRootUser ?? false;
|
|
var hasPermission = await _permissionService.HasPermission(PermissionsMaster.ManageTenants, loggedInEmployee.Id);
|
|
if (!(hasPermission && isRootUser))
|
|
{
|
|
_logger.LogWarning("Permission denied: User {EmployeeId} attempted to create a subscription plan.", loggedInEmployee.Id);
|
|
return StatusCode(403, ApiResponse<object>.ErrorResponse("Access denied", "User does not have the required permissions for this action.", 403));
|
|
}
|
|
|
|
_logger.LogInfo("User {EmployeeId} authorized to create subscription plan.", loggedInEmployee.Id);
|
|
|
|
// Step 2: Map DTO to entity and persist the base SubscriptionPlan
|
|
var plan = _mapper.Map<SubscriptionPlan>(model);
|
|
_context.SubscriptionPlans.Add(plan);
|
|
|
|
try
|
|
{
|
|
await _context.SaveChangesAsync();
|
|
_logger.LogInfo("Base subscription plan {PlanId} saved by user {EmployeeId}.", plan.Id, loggedInEmployee.Id);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Database exception occurred while saving the base subscription plan.");
|
|
return StatusCode(500, ApiResponse<object>.ErrorResponse("Internal error occurred", ExceptionMapper(dbEx), 500));
|
|
}
|
|
|
|
// Step 3: Prepare tasks for each plan frequency
|
|
var frequencies = new[]
|
|
{
|
|
(model.MonthlyPlan, PLAN_FREQUENCY.MONTHLY),
|
|
(model.QuarterlyPlan, PLAN_FREQUENCY.QUARTERLY),
|
|
(model.HalfYearlyPlan, PLAN_FREQUENCY.HALF_YEARLY),
|
|
(model.YearlyPlan, PLAN_FREQUENCY.YEARLY)
|
|
};
|
|
|
|
var tasks = frequencies
|
|
.Select(f => CreateSubscriptionPlanDetails(f.Item1, plan, loggedInEmployee, f.Item2))
|
|
.ToArray();
|
|
|
|
// Await all frequency tasks
|
|
await Task.WhenAll(tasks);
|
|
|
|
// Step 4: Collect successful plan details or return errors if any
|
|
var responseList = new List<SubscriptionPlanVM>();
|
|
for (int i = 0; i < tasks.Length; i++)
|
|
{
|
|
var result = tasks[i].Result;
|
|
if (result.StatusCode == 200 && result.Data != null)
|
|
{
|
|
responseList.Add(result.Data);
|
|
}
|
|
// status code 400 - skip, e.g. missing data for this frequency
|
|
else if (result.StatusCode != 400)
|
|
{
|
|
_logger.LogWarning("Failed to create plan details for {Frequency}: {Error}", frequencies[i].Item2, result.Message);
|
|
return StatusCode(result.StatusCode, result);
|
|
}
|
|
}
|
|
|
|
_logger.LogInfo("Subscription plan {PlanId} created successfully with {DetailCount} details.", plan.Id, responseList.Count);
|
|
return StatusCode(201, ApiResponse<object>.SuccessResponse(responseList, "Plan Created Successfully", 201));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Helper Functions ===================================================================
|
|
|
|
private static object ExceptionMapper(Exception ex)
|
|
{
|
|
return new
|
|
{
|
|
Message = ex.Message,
|
|
StackTrace = ex.StackTrace,
|
|
Source = ex.Source,
|
|
InnerException = new
|
|
{
|
|
Message = ex.InnerException?.Message,
|
|
StackTrace = ex.InnerException?.StackTrace,
|
|
Source = ex.InnerException?.Source,
|
|
}
|
|
};
|
|
}
|
|
private bool IsBase64String(string? input)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(input))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string base64Data = input;
|
|
const string dataUriMarker = "base64,";
|
|
int markerIndex = input.IndexOf(dataUriMarker, StringComparison.Ordinal);
|
|
|
|
// If the marker is found, extract the actual Base64 data
|
|
if (markerIndex >= 0)
|
|
{
|
|
base64Data = input.Substring(markerIndex + dataUriMarker.Length);
|
|
}
|
|
|
|
// Now, validate the extracted payload
|
|
base64Data = base64Data.Trim();
|
|
|
|
// Check for valid length (must be a multiple of 4) and non-empty
|
|
if (base64Data.Length == 0 || base64Data.Length % 4 != 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// The most reliable test is to simply try to convert it.
|
|
// The .NET converter is strict and will throw a FormatException
|
|
// for invalid characters or incorrect padding.
|
|
try
|
|
{
|
|
Convert.FromBase64String(base64Data);
|
|
return true;
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
// The string is not a valid Base64 payload.
|
|
return false;
|
|
}
|
|
}
|
|
private TenantFilter? TryDeserializeFilter(string? filter)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filter))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
TenantFilter? expenseFilter = null;
|
|
|
|
try
|
|
{
|
|
// First, try to deserialize directly. This is the expected case (e.g., from a web client).
|
|
expenseFilter = JsonSerializer.Deserialize<TenantFilter>(filter, options);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
_logger.LogError(ex, "[{MethodName}] Failed to directly deserialize filter. Attempting to unescape and re-parse. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
|
|
|
|
// If direct deserialization fails, it might be an escaped string (common with tools like Postman or some mobile clients).
|
|
try
|
|
{
|
|
// Unescape the string first, then deserialize the result.
|
|
string unescapedJsonString = JsonSerializer.Deserialize<string>(filter, options) ?? "";
|
|
if (!string.IsNullOrWhiteSpace(unescapedJsonString))
|
|
{
|
|
expenseFilter = JsonSerializer.Deserialize<TenantFilter>(unescapedJsonString, options);
|
|
}
|
|
}
|
|
catch (JsonException ex1)
|
|
{
|
|
// If both attempts fail, log the final error and return null.
|
|
_logger.LogError(ex1, "[{MethodName}] All attempts to deserialize the filter failed. Filter will be ignored. Filter: {Filter}", nameof(TryDeserializeFilter), filter);
|
|
return null;
|
|
}
|
|
}
|
|
return expenseFilter;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles the creation and persistence of SubscriptionPlanDetails for a particular frequency.
|
|
/// </summary>
|
|
private async Task<ApiResponse<SubscriptionPlanVM>> CreateSubscriptionPlanDetails(SubscriptionPlanDetailsDto? model, SubscriptionPlan plan, Employee loggedInEmployee, PLAN_FREQUENCY frequency)
|
|
{
|
|
if (model == null)
|
|
{
|
|
_logger.LogInfo("No plan detail provided for {Frequency} - skipping.", frequency);
|
|
return ApiResponse<SubscriptionPlanVM>.ErrorResponse("Invalid", "No data provided for this frequency", 400);
|
|
}
|
|
|
|
await using var _dbContext = await _dbContextFactory.CreateDbContextAsync();
|
|
|
|
// Fetch currency master record
|
|
var currencyMaster = await _dbContext.CurrencyMaster.AsNoTracking().FirstOrDefaultAsync(c => c.Id == model.CurrencyId);
|
|
if (currencyMaster == null)
|
|
{
|
|
_logger.LogWarning("Currency with Id {CurrencyId} not found for plan {PlanId}/{Frequency}.", model.CurrencyId, plan.Id, frequency);
|
|
return ApiResponse<SubscriptionPlanVM>.ErrorResponse("Currency not found", "Specified currency not found", 404);
|
|
}
|
|
|
|
// Map to entity and create related feature details
|
|
var planDetails = _mapper.Map<SubscriptionPlanDetails>(model);
|
|
var features = _mapper.Map<FeatureDetails>(model.Features);
|
|
|
|
try
|
|
{
|
|
await _featureDetailsHelper.AddFeatureDetails(features);
|
|
_logger.LogInfo("FeatureDetails for plan {PlanId}/{Frequency} saved in MongoDB.", plan.Id, frequency);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Exception occurred while saving features in MongoDB for {PlanId}/{Frequency}.", plan.Id, frequency);
|
|
return ApiResponse<SubscriptionPlanVM>.ErrorResponse("Internal error occurred", ExceptionMapper(ex), 500);
|
|
}
|
|
|
|
planDetails.PlanId = plan.Id;
|
|
planDetails.Frequency = frequency;
|
|
planDetails.FeaturesId = features.Id;
|
|
planDetails.CreatedById = loggedInEmployee.Id;
|
|
planDetails.CreateAt = DateTime.UtcNow;
|
|
|
|
_dbContext.SubscriptionPlanDetails.Add(planDetails);
|
|
|
|
// Prepare view model
|
|
var VM = _mapper.Map<SubscriptionPlanVM>(planDetails);
|
|
VM.PlanName = plan.PlanName;
|
|
VM.Description = plan.Description;
|
|
VM.Features = features;
|
|
VM.Currency = currencyMaster;
|
|
|
|
try
|
|
{
|
|
await _dbContext.SaveChangesAsync();
|
|
_logger.LogInfo("Subscription plan details for {PlanId}/{Frequency} saved to SQL.", plan.Id, frequency);
|
|
}
|
|
catch (DbUpdateException dbEx)
|
|
{
|
|
_logger.LogError(dbEx, "Database exception occurred while saving plan details for {PlanId}/{Frequency}.", plan.Id, frequency);
|
|
return ApiResponse<SubscriptionPlanVM>.ErrorResponse("Internal error occurred", ExceptionMapper(dbEx), 500);
|
|
}
|
|
|
|
return ApiResponse<SubscriptionPlanVM>.SuccessResponse(VM, "Success", 200);
|
|
}
|
|
|
|
private async Task ClearPermissionForTenant()
|
|
{
|
|
await using var _context = await _dbContextFactory.CreateDbContextAsync();
|
|
using var scope = _serviceScopeFactory.CreateScope();
|
|
|
|
var _cache = scope.ServiceProvider.GetRequiredService<CacheUpdateHelper>();
|
|
var _cacheLogger = scope.ServiceProvider.GetRequiredService<ILoggingService>();
|
|
|
|
var employeeIds = await _context.Employees.Where(e => e.TenantId == tenantId).Select(e => e.Id).ToListAsync();
|
|
await _cache.ClearAllEmployeesFromCacheByEmployeeIds(employeeIds);
|
|
_cacheLogger.LogInfo("{EmployeeCount} number of employee deleted", employeeIds.Count);
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|