From fdac2e06e171626bf436b6e13474727462ebda36 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Fri, 1 Aug 2025 13:17:49 +0530 Subject: [PATCH] Added get tenant list API --- Marco.Pms.Model/Utilities/TenantFilter.cs | 12 + .../ViewModels/Tenant/TenantListVM.cs | 18 ++ .../Controllers/TenantController.cs | 229 ++++++++++++++++-- .../MappingProfiles/MappingProfile.cs | 11 + 4 files changed, 244 insertions(+), 26 deletions(-) create mode 100644 Marco.Pms.Model/Utilities/TenantFilter.cs create mode 100644 Marco.Pms.Model/ViewModels/Tenant/TenantListVM.cs diff --git a/Marco.Pms.Model/Utilities/TenantFilter.cs b/Marco.Pms.Model/Utilities/TenantFilter.cs new file mode 100644 index 0000000..2c0a477 --- /dev/null +++ b/Marco.Pms.Model/Utilities/TenantFilter.cs @@ -0,0 +1,12 @@ +namespace Marco.Pms.Model.Utilities +{ + public class TenantFilter + { + public List? IndustryIds { get; set; } + public List? CreatedByIds { get; set; } + public List? TenantStatusIds { get; set; } + public List? References { get; set; } + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + } +} diff --git a/Marco.Pms.Model/ViewModels/Tenant/TenantListVM.cs b/Marco.Pms.Model/ViewModels/Tenant/TenantListVM.cs new file mode 100644 index 0000000..10c6a89 --- /dev/null +++ b/Marco.Pms.Model/ViewModels/Tenant/TenantListVM.cs @@ -0,0 +1,18 @@ +using Marco.Pms.Model.Master; + +namespace Marco.Pms.Model.ViewModels.Tenant +{ + public class TenantListVM + { + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string? DomainName { get; set; } + public string ContactName { get; set; } = string.Empty; + public string ContactNumber { get; set; } = string.Empty; + public string? logoImage { get; set; } // Base64 + public string? OragnizationSize { get; set; } + public Industry? Industry { get; set; } + public TenantStatus? TenantStatus { get; set; } + } +} diff --git a/Marco.Pms.Services/Controllers/TenantController.cs b/Marco.Pms.Services/Controllers/TenantController.cs index b1b5c6f..254a542 100644 --- a/Marco.Pms.Services/Controllers/TenantController.cs +++ b/Marco.Pms.Services/Controllers/TenantController.cs @@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System.Net; +using System.Text.Json; using System.Text.RegularExpressions; // For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860 @@ -49,18 +50,154 @@ namespace Marco.Pms.Services.Controllers _mapper = mapper; _userHelper = userHelper; } - // GET: api/ - [HttpGet] - public IEnumerable Get() + #region =================================================================== Tenant APIs =================================================================== + + /// + /// Retrieves a paginated list of active tenants with optional filtering and searching. + /// + /// A string to search for across various tenant fields. + /// A JSON serialized string containing advanced filter criteria. + /// The number of records to return per page. + /// The page number to retrieve. + /// A paginated list of tenants matching the criteria. + [HttpGet("list")] + public async Task GetTenantList([FromQuery] string? searchString, string? filter, int pageSize = 20, int pageNumber = 1) { - return new string[] { "value1", "value2" }; + + using var scope = _serviceScopeFactory.CreateScope(); + + var _permissionService = scope.ServiceProvider.GetRequiredService(); + _logger.LogInfo("Attempting to fetch tenant list with pageNumber: {PageNumber} and pageSize: {PageSize}", pageNumber, pageSize); + + try + { + // --- 1. PERMISSION CHECK --- + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + if (loggedInEmployee == null) + { + // This case should be handled by the [Authorize] attribute. + // This check is a safeguard. + _logger.LogWarning("Authentication failed: No logged-in employee found."); + return StatusCode(403, ApiResponse.ErrorResponse("Authentication required", "User is not logged in.", 403)); + } + + // 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) + { + _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)); + } + + // --- 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); + + // 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>(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.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.ErrorResponse("An internal server error occurred.", "An unexpected error prevented the request from completing.", 500)); + } } // GET api//5 - [HttpGet("{id}")] - public string Get(int id) + [HttpGet("details/{id}")] + public async Task GetDetails(Guid id) { - return "value"; + return Ok(); } // POST api/ @@ -124,25 +261,13 @@ namespace Marco.Pms.Services.Controllers try { // Create the primary Tenant entity - var tenant = new Tenant - { - Name = model.OragnizationName, - ContactName = $"{model.FirstName} {model.LastName}", - ContactNumber = model.ContactNumber, - Email = model.Email, - IndustryId = model.IndustryId, - TenantStatusId = activeStatus, // Assuming 'activeStatus' is a defined variable or constant - Description = model.Description, - OnBoardingDate = model.OnBoardingDate, - OragnizationSize = model.OragnizationSize, - Reference = model.Reference, - CreatedById = loggedInEmployee.Id, - BillingAddress = model.BillingAddress, - TaxId = model.TaxId, - logoImage = model.logoImage, - DomainName = model.DomainName, - IsSuperTenant = false - }; + + var tenant = _mapper.Map(model); + + tenant.TenantStatusId = activeStatus; + tenant.CreatedById = loggedInEmployee.Id; + tenant.IsSuperTenant = false; + _context.Tenants.Add(tenant); // Create the root ApplicationUser for the new tenant @@ -253,6 +378,17 @@ namespace Marco.Pms.Services.Controllers } + #endregion + + #region =================================================================== Subscription APIs =================================================================== + + #endregion + + #region =================================================================== Subscription Plan APIs =================================================================== + + #endregion + + #region =================================================================== Helper Functions =================================================================== private static object ExceptionMapper(Exception ex) { @@ -298,5 +434,46 @@ namespace Marco.Pms.Services.Controllers 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(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(filter, options) ?? ""; + if (!string.IsNullOrWhiteSpace(unescapedJsonString)) + { + expenseFilter = JsonSerializer.Deserialize(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; + } + + #endregion } } diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs index 244c2d8..7d9e269 100644 --- a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -1,5 +1,6 @@ using AutoMapper; using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Dtos.Tenant; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Master; @@ -18,6 +19,16 @@ namespace Marco.Pms.Services.MappingProfiles { #region ======================================================= Employees ======================================================= CreateMap(); + CreateMap(); + CreateMap() + .ForMember( + dest => dest.ContactName, + opt => opt.MapFrom(src => $"{src.FirstName} {src.LastName}") + ) + .ForMember( + dest => dest.Name, + opt => opt.MapFrom(src => src.OragnizationName) + ); #endregion #region ======================================================= Projects =======================================================