From f4ca7670e3b12a0309f106e3fc7c56b2af4eec3b Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Mon, 14 Jul 2025 18:45:23 +0530 Subject: [PATCH] Refactored: Moved business logic from ProjectController to ProjectService --- .../Controllers/ProjectController.cs | 693 +----------------- .../MappingProfiles/ProjectMappingProfile.cs | 1 + Marco.Pms.Services/Program.cs | 10 + .../Service/PermissionServices.cs | 10 +- Marco.Pms.Services/Service/ProjectServices.cs | 691 +++++++++++++++++ .../ServiceInterfaces/IProjectServices.cs | 17 + 6 files changed, 760 insertions(+), 662 deletions(-) create mode 100644 Marco.Pms.Services/Service/ProjectServices.cs create mode 100644 Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 3d5558f..e7d257f 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,6 +1,4 @@ -using AutoMapper; -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.Activities; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; @@ -13,6 +11,7 @@ using Marco.Pms.Model.ViewModels.Projects; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Hubs; using Marco.Pms.Services.Service; +using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; @@ -28,30 +27,26 @@ namespace MarcoBMS.Services.Controllers [Authorize] public class ProjectController : ControllerBase { - private readonly IDbContextFactory _dbContextFactory; + private readonly IProjectServices _projectServices; private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; private readonly ILoggingService _logger; - private readonly ProjectsHelper _projectsHelper; private readonly IHubContext _signalR; private readonly PermissionServices _permission; private readonly CacheUpdateHelper _cache; - private readonly IMapper _mapper; private readonly Guid tenantId; - public ProjectController(IDbContextFactory dbContextFactory, ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, - ProjectsHelper projectHelper, IHubContext signalR, CacheUpdateHelper cache, PermissionServices permission, IMapper mapper) + public ProjectController(ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, + IHubContext signalR, CacheUpdateHelper cache, PermissionServices permission, IProjectServices projectServices) { - _dbContextFactory = dbContextFactory; _context = context; _userHelper = userHelper; _logger = logger; - _projectsHelper = projectHelper; _signalR = signalR; _cache = cache; _permission = permission; - _mapper = mapper; + _projectServices = projectServices; tenantId = userHelper.GetTenantId(); } @@ -60,30 +55,10 @@ namespace MarcoBMS.Services.Controllers [HttpGet("list/basic")] public async Task GetAllProjectsBasic() { - // Step 1: Get the current user + // Get the current user var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - if (loggedInEmployee == null) - { - return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "User could not be identified.", 401)); - } - - _logger.LogInfo("Basic project list requested by EmployeeId {EmployeeId}", loggedInEmployee.Id); - - // Step 2: Get the list of project IDs the user has access to - List accessibleProjectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); - - if (accessibleProjectIds == null || !accessibleProjectIds.Any()) - { - _logger.LogInfo("No accessible projects found for EmployeeId {EmployeeId}", loggedInEmployee.Id); - return Ok(ApiResponse>.SuccessResponse(new List(), "Success.", 200)); - } - - // Step 3: Fetch project ViewModels using the optimized, cache-aware helper - var projectVMs = await GetProjectInfosByIdsAsync(accessibleProjectIds); - - // Step 4: Return the final list - _logger.LogInfo("Successfully returned {ProjectCount} projects for EmployeeId {EmployeeId}", projectVMs.Count, loggedInEmployee.Id); - return Ok(ApiResponse>.SuccessResponse(projectVMs, $"{projectVMs.Count} records of project fetchd successfully", 200)); + var response = await _projectServices.GetAllProjectsBasicAsync(tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } /// @@ -96,7 +71,7 @@ namespace MarcoBMS.Services.Controllers [HttpGet("list")] public async Task GetAllProjects() { - // --- Step 1: Input Validation and Initial Setup --- + // --- Input Validation and Initial Setup --- if (!ModelState.IsValid) { var errors = ModelState.Values @@ -106,63 +81,9 @@ namespace MarcoBMS.Services.Controllers _logger.LogWarning("GetAllProjects called with invalid model state. Errors: {Errors}", string.Join(", ", errors)); return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - - try - { - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - _logger.LogInfo("Starting GetAllProjects for TenantId: {TenantId}, User: {UserId}", tenantId, loggedInEmployee.Id); - - // --- Step 2: Get a list of project IDs the user can access --- - List projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); - if (!projectIds.Any()) - { - _logger.LogInfo("User has no assigned projects. Returning empty list."); - return Ok(ApiResponse>.SuccessResponse(new List(), "No projects found for the current user.", 200)); - } - - // --- Step 3: Efficiently handle partial cache hits --- - _logger.LogInfo("Attempting to fetch details for {ProjectCount} projects from cache.", projectIds.Count); - - // Fetch what we can from the cache. - var cachedDetails = await _cache.GetProjectDetailsList(projectIds) ?? new List(); - var cachedDictionary = cachedDetails.ToDictionary(p => Guid.Parse(p.Id)); - - // Identify which projects are missing from the cache. - var missingIds = projectIds.Where(id => !cachedDictionary.ContainsKey(id)).ToList(); - - // Start building the response with the items we found in the cache. - var responseVms = _mapper.Map>(cachedDictionary.Values); - - if (missingIds.Any()) - { - // --- Step 4: Fetch ONLY the missing items from the database --- - _logger.LogInfo("Cache partial MISS. Found {CachedCount}, fetching {MissingCount} projects from DB.", - cachedDictionary.Count, missingIds.Count); - - // Call our dedicated data-fetching method for the missing IDs. - var newMongoDetails = await FetchAndBuildProjectDetails(missingIds, tenantId); - - if (newMongoDetails.Any()) - { - // Map the newly fetched items and add them to our response list. - responseVms.AddRange(newMongoDetails); - } - } - else - { - _logger.LogInfo("Cache HIT. All {ProjectCount} projects found in cache.", projectIds.Count); - } - - // --- Step 5: Return the combined result --- - _logger.LogInfo("Successfully retrieved a total of {ProjectCount} projects.", responseVms.Count); - return Ok(ApiResponse>.SuccessResponse(responseVms, "Projects retrieved successfully.", 200)); - } - catch (Exception ex) - { - // --- Step 6: Graceful Error Handling --- - _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. \n {Error}", tenantId, ex.Message); - return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500)); - } + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetAllProjectsAsync(tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } /// @@ -173,7 +94,7 @@ namespace MarcoBMS.Services.Controllers /// An ApiResponse containing the project details or an appropriate error. [HttpGet("get/{id}")] - public async Task Get([FromRoute] Guid id) + public async Task GetProject([FromRoute] Guid id) { // --- Step 1: Input Validation --- if (!ModelState.IsValid) @@ -183,53 +104,14 @@ namespace MarcoBMS.Services.Controllers return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - try - { - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // --- Step 2: Run independent operations in PARALLEL --- - // We can check permissions and fetch data at the same time to reduce latency. - var permissionTask = _permission.HasProjectPermission(loggedInEmployee, id); - - // This helper method encapsulates the "cache-first, then database" logic. - var projectDataTask = GetProjectDataAsync(id); - - // Await both tasks to complete. - await Task.WhenAll(permissionTask, projectDataTask); - - var hasPermission = await permissionTask; - var projectVm = await projectDataTask; - - // --- Step 3: Process results sequentially --- - - // 3a. Check for permission first. Forbid() is the idiomatic way to return 403. - if (!hasPermission) - { - _logger.LogWarning("Access denied for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, id); - return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to access this project.", 403))); - } - - // 3b. Check if the project was found (either in cache or DB). - if (projectVm == null) - { - _logger.LogInfo("Project with ID {ProjectId} not found.", id); - return NotFound(ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404)); - } - - // 3c. Success. Return the consistent ViewModel. - _logger.LogInfo("Successfully retrieved project {ProjectId}.", id); - return Ok(ApiResponse.SuccessResponse(projectVm, "Project retrieved successfully.", 200)); - } - catch (Exception ex) - { - _logger.LogError("An unexpected error occurred while getting project {ProjectId} : \n {Error}", id, ex.Message); - return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); - } + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetProjectAsync(id, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } [HttpGet("details/{id}")] - public async Task Details([FromRoute] Guid id) + public async Task GetProjectDetails([FromRoute] Guid id) { // Step 1: Validate model state if (!ModelState.IsValid) @@ -245,63 +127,13 @@ namespace MarcoBMS.Services.Controllers // Step 2: Get logged-in employee var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - _logger.LogInfo("Details requested by EmployeeId: {EmployeeId} for ProjectId: {ProjectId}", loggedInEmployee.Id, id); - // Step 3: Check global view project permission - var hasViewProjectPermission = await _permission.HasPermission(PermissionsMaster.ViewProject, loggedInEmployee.Id); - if (!hasViewProjectPermission) - { - _logger.LogWarning("ViewProjects permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have permission to view projects", 403)); - } - - // Step 4: Check permission for this specific project - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, id); - if (!hasProjectPermission) - { - _logger.LogWarning("Project-specific access denied. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}", loggedInEmployee.Id, id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403)); - } - - // Step 5: Fetch project with status - var projectDetails = await _cache.GetProjectDetails(id); - ProjectVM? projectVM = null; - if (projectDetails == null) - { - var project = await _context.Projects - .Include(c => c.ProjectStatus) - .FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Id == id); - - projectVM = _mapper.Map(project); - - if (project != null) - { - await _cache.AddProjectDetails(project); - } - } - else - { - projectVM = _mapper.Map(projectDetails); - if (projectVM.ProjectStatus != null) - { - projectVM.ProjectStatus.TenantId = tenantId; - } - } - - if (projectVM == null) - { - _logger.LogWarning("Project not found. ProjectId: {ProjectId}", id); - return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); - } - - // Step 6: Return result - - _logger.LogInfo("Project details fetched successfully. ProjectId: {ProjectId}", id); - return Ok(ApiResponse.SuccessResponse(projectVM, "Project details fetched successfully", 200)); + var response = await _projectServices.GetProjectDetailsAsync(id, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } [HttpGet("details-old/{id}")] - public async Task DetailsOld([FromRoute] Guid id) + public async Task GetProjectDetailsOld([FromRoute] Guid id) { // ProjectDetailsVM vm = new ProjectDetailsVM(); @@ -315,92 +147,10 @@ namespace MarcoBMS.Services.Controllers } - var project = await _context.Projects.Where(c => c.TenantId == tenantId && c.Id == id).Include(c => c.ProjectStatus).SingleOrDefaultAsync(); // includeProperties: "ProjectStatus,Tenant"); //_context.Stock.FindAsync(id); - - if (project == null) - { - return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); - - } - else - { - //var project = projects.Where(c => c.Id == id).SingleOrDefault(); - ProjectDetailsVM vm = await GetProjectViewModel(id, project); - - OldProjectVM projectVM = new OldProjectVM(); - if (vm.project != null) - { - projectVM.Id = vm.project.Id; - projectVM.Name = vm.project.Name; - projectVM.ShortName = vm.project.ShortName; - projectVM.ProjectAddress = vm.project.ProjectAddress; - projectVM.ContactPerson = vm.project.ContactPerson; - projectVM.StartDate = vm.project.StartDate; - projectVM.EndDate = vm.project.EndDate; - projectVM.ProjectStatusId = vm.project.ProjectStatusId; - } - projectVM.Buildings = new List(); - if (vm.buildings != null) - { - foreach (Building build in vm.buildings) - { - BuildingVM buildVM = new BuildingVM() { Id = build.Id, Description = build.Description, Name = build.Name }; - buildVM.Floors = new List(); - if (vm.floors != null) - { - foreach (Floor floorDto in vm.floors.Where(c => c.BuildingId == build.Id).ToList()) - { - FloorsVM floorVM = new FloorsVM() { FloorName = floorDto.FloorName, Id = floorDto.Id }; - floorVM.WorkAreas = new List(); - - if (vm.workAreas != null) - { - foreach (WorkArea workAreaDto in vm.workAreas.Where(c => c.FloorId == floorVM.Id).ToList()) - { - WorkAreaVM workAreaVM = new WorkAreaVM() { Id = workAreaDto.Id, AreaName = workAreaDto.AreaName, WorkItems = new List() }; - - if (vm.workItems != null) - { - foreach (WorkItem workItemDto in vm.workItems.Where(c => c.WorkAreaId == workAreaDto.Id).ToList()) - { - WorkItemVM workItemVM = new WorkItemVM() { WorkItemId = workItemDto.Id, WorkItem = workItemDto }; - - workItemVM.WorkItem.WorkArea = new WorkArea(); - - if (workItemVM.WorkItem.ActivityMaster != null) - { - workItemVM.WorkItem.ActivityMaster.Tenant = new Tenant(); - } - workItemVM.WorkItem.Tenant = new Tenant(); - - double todaysAssigned = 0; - if (vm.Tasks != null) - { - var tasks = vm.Tasks.Where(t => t.WorkItemId == workItemDto.Id).ToList(); - foreach (TaskAllocation task in tasks) - { - todaysAssigned += task.PlannedTask; - } - } - workItemVM.TodaysAssigned = todaysAssigned; - - workAreaVM.WorkItems.Add(workItemVM); - } - } - - floorVM.WorkAreas.Add(workAreaVM); - } - } - - buildVM.Floors.Add(floorVM); - } - } - projectVM.Buildings.Add(buildVM); - } - } - return Ok(ApiResponse.SuccessResponse(projectVM, "Success.", 200)); - } + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetProjectDetailsAsync(id, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } @@ -409,7 +159,7 @@ namespace MarcoBMS.Services.Controllers #region =================================================================== Project Manage APIs =================================================================== [HttpPost] - public async Task Create([FromBody] CreateProjectDto projectDto) + public async Task CreateProject([FromBody] CreateProjectDto projectDto) { // 1. Validate input first (early exit) if (!ModelState.IsValid) @@ -420,87 +170,13 @@ namespace MarcoBMS.Services.Controllers // 2. Prepare data without I/O Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var loggedInUserId = loggedInEmployee.Id; - var project = projectDto.ToProjectFromCreateProjectDto(tenantId); - - // 3. Store it to database - try + var response = await _projectServices.CreateProjectAsync(projectDto, tenantId, loggedInEmployee); + if (response.Success) { - _context.Projects.Add(project); - await _context.SaveChangesAsync(); - } - catch (Exception ex) - { - // Log the detailed exception - _logger.LogError("Failed to create project in database. Rolling back transaction. : {Error}", ex.Message); - // Return a server error as the primary operation failed - return StatusCode(500, ApiResponse.ErrorResponse("An error occurred while saving the project.", ex.Message, 500)); - } - - // 4. Perform non-critical side-effects (caching, notifications) concurrently - try - { - // These operations do not depend on each other, so they can run in parallel. - Task cacheAddDetailsTask = _cache.AddProjectDetails(project); - Task cacheClearListTask = _cache.ClearAllProjectIdsByPermissionId(PermissionsMaster.ManageProject); - - var notification = new { LoggedInUserId = loggedInUserId, Keyword = "Create_Project", Response = project.ToProjectDto() }; - // Send notification only to the relevant group (e.g., users in the same tenant) - Task notificationTask = _signalR.Clients.Group(tenantId.ToString()).SendAsync("NotificationEventHandler", notification); - - // Await all side-effect tasks to complete in parallel - await Task.WhenAll(cacheAddDetailsTask, cacheClearListTask, notificationTask); - } - catch (Exception ex) - { - // The project was created successfully, but a side-effect failed. - // Log this as a warning, as the primary operation succeeded. Don't return an error to the user. - _logger.LogWarning("Project {ProjectId} was created, but a post-creation side-effect (caching/notification) failed. : {Error}", project.Id, ex.Message); - } - - // 5. Return a success response to the user as soon as the critical data is saved. - return Ok(ApiResponse.SuccessResponse(project.ToProjectDto(), "Project created successfully.", 200)); - } - - [HttpPut] - [Route("update1/{id}")] - public async Task Update([FromRoute] Guid id, [FromBody] UpdateProjectDto updateProjectDto) - { - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - if (!ModelState.IsValid) - { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - - } - try - { - Project project = updateProjectDto.ToProjectFromUpdateProjectDto(tenantId, id); - _context.Projects.Update(project); - - await _context.SaveChangesAsync(); - - // Cache functions - bool isUpdated = await _cache.UpdateProjectDetailsOnly(project); - if (!isUpdated) - { - await _cache.AddProjectDetails(project); - } - - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Update_Project", Response = project.ToProjectDto() }; - + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Create_Project", Response = response.Data }; await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - - return Ok(ApiResponse.SuccessResponse(project.ToProjectDto(), "Success.", 200)); - - } - catch (Exception ex) - { - return BadRequest(ApiResponse.ErrorResponse(ex.Message, ex, 400)); } + return StatusCode(response.StatusCode, response); } /// @@ -522,76 +198,15 @@ namespace MarcoBMS.Services.Controllers return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - try + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.UpdateProjectAsync(id, updateProjectDto, tenantId, loggedInEmployee); + if (response.Success) { - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - // --- Step 2: Fetch the Existing Entity from the Database --- - // This is crucial to avoid the data loss bug. We only want to modify an existing record. - var existingProject = await _context.Projects - .Where(p => p.Id == id && p.TenantId == tenantId) - .SingleOrDefaultAsync(); - - // 2a. Existence Check - if (existingProject == null) - { - _logger.LogWarning("Attempt to update non-existent project with ID {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); - return NotFound(ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404)); - } - - // 2b. Security Check - var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, id); - if (!hasPermission) - { - _logger.LogWarning("Access DENIED for user {UserId} attempting to update project {ProjectId}.", loggedInEmployee.Id, id); - return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to modify this project.", 403))); - } - - // --- Step 3: Apply Changes and Save --- - // Map the changes from the DTO onto the entity we just fetched from the database. - // This only modifies the properties defined in the mapping, preventing data loss. - _mapper.Map(updateProjectDto, existingProject); - - // Mark the entity as modified (if your mapping doesn't do it automatically). - _context.Entry(existingProject).State = EntityState.Modified; - - try - { - await _context.SaveChangesAsync(); - _logger.LogInfo("Successfully updated project {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); - } - catch (DbUpdateConcurrencyException ex) - { - // --- Step 4: Handle Concurrency Conflicts --- - // This happens if another user modified the project after we fetched it. - _logger.LogWarning("Concurrency conflict while updating project {ProjectId} \n {Error}", id, ex.Message); - return Conflict(ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409)); - } - - // --- Step 5: Perform Side-Effects in the Background (Fire and Forget) --- - // The core database operation is done. Now, we perform non-blocking cache and notification updates. - _ = Task.Run(async () => - { - // Create a DTO of the updated project to pass to background tasks. - var projectDto = _mapper.Map(existingProject); - - // 5a. Update Cache - await UpdateCacheInBackground(existingProject); - - // 5b. Send Targeted SignalR Notification - var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Update_Project", Response = projectDto }; - await SendNotificationInBackground(notification, projectDto.Id); - }); - - // --- Step 6: Return Success Response Immediately --- - // The client gets a fast response without waiting for caching or SignalR. - return Ok(ApiResponse.SuccessResponse(_mapper.Map(existingProject), "Project updated successfully.", 200)); - } - catch (Exception ex) - { - // --- Step 7: Graceful Error Handling for Unexpected Errors --- - _logger.LogError("An unexpected error occurred while updating project {ProjectId} \n {Error}", id, ex.Message); - return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Update_Project", Response = response.Data }; + await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); } + return StatusCode(response.StatusCode, response); } #endregion @@ -1367,241 +982,5 @@ namespace MarcoBMS.Services.Controllers #endregion - #region =================================================================== Helper Functions =================================================================== - - /// - /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. - /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the - /// database (as Project), updates the cache, and returns a unified list of ViewModels. - /// - /// The list of project IDs to retrieve. - /// A list of ProjectInfoVMs. - private async Task> GetProjectInfosByIdsAsync(List projectIds) - { - // --- Step 1: Fetch from Cache --- - // The cache returns a list of MongoDB documents for the projects it found. - var cachedMongoDocs = await _cache.GetProjectDetailsList(projectIds) ?? new List(); - var finalViewModels = _mapper.Map>(cachedMongoDocs); - - _logger.LogDebug("Cache hit for {CacheCount} of {TotalCount} projects.", finalViewModels.Count, projectIds.Count); - - // --- Step 2: Identify Missing Projects --- - // If we found everything in the cache, we can return early. - if (finalViewModels.Count == projectIds.Count) - { - return finalViewModels; - } - - var cachedIds = cachedMongoDocs.Select(p => p.Id).ToHashSet(); // Assuming ProjectMongoDB has an Id - var missingIds = projectIds.Where(id => !cachedIds.Contains(id.ToString())).ToList(); - - // --- Step 3: Fetch Missing from Database --- - if (missingIds.Any()) - { - _logger.LogDebug("Cache miss for {MissingCount} projects. Querying database.", missingIds.Count); - - var projectsFromDb = await _context.Projects - .Where(p => missingIds.Contains(p.Id)) - .AsNoTracking() // Use AsNoTracking for read-only query performance - .ToListAsync(); - - if (projectsFromDb.Any()) - { - // Map the newly fetched projects (from SQL) to their ViewModel - var vmsFromDb = _mapper.Map>(projectsFromDb); - finalViewModels.AddRange(vmsFromDb); - - // --- Step 4: Update Cache with Missing Items in a new scope --- - _logger.LogDebug("Updating cache with {DbCount} newly fetched projects.", projectsFromDb.Count); - await _cache.AddProjectDetailsList(projectsFromDb); - } - } - - return finalViewModels; - } - - private async Task GetProjectViewModel(Guid? id, Project project) - { - ProjectDetailsVM vm = new ProjectDetailsVM(); - - // List buildings = _unitOfWork.Building.GetAll(c => c.ProjectId == id).ToList(); - List buildings = await _context.Buildings.Where(c => c.ProjectId == id).ToListAsync(); - List idList = buildings.Select(o => o.Id).ToList(); - // List floors = _unitOfWork.Floor.GetAll(c => idList.Contains(c.Id)).ToList(); - List floors = await _context.Floor.Where(c => idList.Contains(c.BuildingId)).ToListAsync(); - idList = floors.Select(o => o.Id).ToList(); - //List workAreas = _unitOfWork.WorkArea.GetAll(c => idList.Contains(c.Id), includeProperties: "WorkItems,WorkItems.ActivityMaster").ToList(); - - List workAreas = await _context.WorkAreas.Where(c => idList.Contains(c.FloorId)).ToListAsync(); - - idList = workAreas.Select(o => o.Id).ToList(); - List workItems = await _context.WorkItems.Include(c => c.WorkCategoryMaster).Where(c => idList.Contains(c.WorkAreaId)).Include(c => c.ActivityMaster).ToListAsync(); - // List workItems = _unitOfWork.WorkItem.GetAll(c => idList.Contains(c.WorkAreaId), includeProperties: "ActivityMaster").ToList(); - idList = workItems.Select(t => t.Id).ToList(); - List tasks = await _context.TaskAllocations.Where(t => idList.Contains(t.WorkItemId) && t.AssignmentDate.Date == DateTime.UtcNow.Date).ToListAsync(); - vm.project = project; - vm.buildings = buildings; - vm.floors = floors; - vm.workAreas = workAreas; - vm.workItems = workItems; - vm.Tasks = tasks; - return vm; - } - - /// - /// Fetches project details from the database for a given list of project IDs and assembles them into MongoDB models. - /// This method encapsulates the optimized, parallel database queries. - /// - /// The list of project IDs to fetch. - /// The current tenant ID for filtering. - /// A list of fully populated ProjectMongoDB objects. - private async Task> FetchAndBuildProjectDetails(List projectIdsToFetch, Guid tenantId) - { - // Task to get base project details for the MISSING projects - var projectsTask = Task.Run(async () => - { - using var context = _dbContextFactory.CreateDbContext(); - return await context.Projects.AsNoTracking() - .Where(p => projectIdsToFetch.Contains(p.Id) && p.TenantId == tenantId) - .ToListAsync(); - }); - - // Task to get team sizes for the MISSING projects - var teamSizesTask = Task.Run(async () => - { - using var context = _dbContextFactory.CreateDbContext(); - return await context.ProjectAllocations.AsNoTracking() - .Where(pa => pa.TenantId == tenantId && projectIdsToFetch.Contains(pa.ProjectId) && pa.IsActive) - .GroupBy(pa => pa.ProjectId) - .Select(g => new { ProjectId = g.Key, Count = g.Count() }) - .ToDictionaryAsync(x => x.ProjectId, x => x.Count); - }); - - // Task to get work summaries for the MISSING projects - var workSummariesTask = Task.Run(async () => - { - using var context = _dbContextFactory.CreateDbContext(); - return await context.WorkItems.AsNoTracking() - .Where(wi => wi.TenantId == tenantId && - wi.WorkArea != null && - wi.WorkArea.Floor != null && - wi.WorkArea.Floor.Building != null && - projectIdsToFetch.Contains(wi.WorkArea.Floor.Building.ProjectId)) - .GroupBy(wi => wi.WorkArea!.Floor!.Building!.ProjectId) - .Select(g => new { ProjectId = g.Key, PlannedWork = g.Sum(i => i.PlannedWork), CompletedWork = g.Sum(i => i.CompletedWork) }) - .ToDictionaryAsync(x => x.ProjectId); - }); - - // Await all parallel tasks to complete - await Task.WhenAll(projectsTask, teamSizesTask, workSummariesTask); - - var projects = await projectsTask; - var teamSizes = await teamSizesTask; - var workSummaries = await workSummariesTask; - - // Proactively update the cache with the items we just fetched. - _logger.LogInfo("Updating cache with {NewItemCount} newly fetched projects.", projects.Count); - await _cache.AddProjectDetailsList(projects); - - // This section would build the full ProjectMongoDB objects, similar to your AddProjectDetailsList method. - // For brevity, assuming you have a mapper or a builder for this. Here's a simplified representation: - var mongoDetailsList = new List(); - foreach (var project in projects) - { - // This is a placeholder for the full build logic from your other methods. - // In a real scenario, you would fetch all hierarchy levels (buildings, floors, etc.) - // for the `projectIdsToFetch` and build the complete MongoDB object. - var mongoDetail = _mapper.Map(project); - mongoDetail.Id = project.Id; - mongoDetail.TeamSize = teamSizes.GetValueOrDefault(project.Id, 0); - if (workSummaries.TryGetValue(project.Id, out var summary)) - { - mongoDetail.PlannedWork = summary.PlannedWork; - mongoDetail.CompletedWork = summary.CompletedWork; - } - mongoDetailsList.Add(mongoDetail); - } - - return mongoDetailsList; - } - - /// - /// Private helper to encapsulate the cache-first data retrieval logic. - /// - /// A ProjectDetailVM if found, otherwise null. - private async Task GetProjectDataAsync(Guid projectId) - { - // --- Cache First --- - _logger.LogDebug("Attempting to fetch project {ProjectId} from cache.", projectId); - var cachedProject = await _cache.GetProjectDetails(projectId); - if (cachedProject != null) - { - _logger.LogInfo("Cache HIT for project {ProjectId}.", projectId); - // Map from the cache model (e.g., ProjectMongoDB) to the response ViewModel. - return _mapper.Map(cachedProject); - } - - // --- Database Second (on Cache Miss) --- - _logger.LogInfo("Cache MISS for project {ProjectId}. Fetching from database.", projectId); - var dbProject = await _context.Projects - .AsNoTracking() // Use AsNoTracking for read-only queries. - .Where(p => p.Id == projectId && p.TenantId == tenantId) - .SingleOrDefaultAsync(); - - if (dbProject == null) - { - return null; // The project doesn't exist. - } - - // --- Proactively Update Cache --- - // The next request for this project will now be a cache hit. - try - { - // Map the DB entity to the cache model (e.g., ProjectMongoDB) before caching. - await _cache.AddProjectDetails(dbProject); - _logger.LogInfo("Updated cache with project {ProjectId}.", projectId); - } - catch (Exception ex) - { - _logger.LogWarning("Failed to update cache for project {ProjectId} : \n {Error}", projectId, ex.Message); - } - - // Map from the database entity to the response ViewModel. - return dbProject; - } - - // Helper method for background cache update - private async Task UpdateCacheInBackground(Project project) - { - try - { - // This logic can be more complex, but the idea is to update or add. - if (!await _cache.UpdateProjectDetailsOnly(project)) - { - await _cache.AddProjectDetails(project); - } - _logger.LogInfo("Background cache update succeeded for project {ProjectId}.", project.Id); - } - catch (Exception ex) - { - _logger.LogError("Background cache update failed for project {ProjectId} \n {Error}", project.Id, ex.Message); - } - } - - // Helper method for background notification - private async Task SendNotificationInBackground(object notification, Guid projectId) - { - try - { - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - _logger.LogInfo("Background SignalR notification sent for project {ProjectId}.", projectId); - } - catch (Exception ex) - { - _logger.LogError("Background SignalR notification failed for project {ProjectId} \n {Error}", projectId, ex.Message); - } - } - - #endregion } } \ No newline at end of file diff --git a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs index 18db7ff..b811056 100644 --- a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs @@ -39,6 +39,7 @@ namespace Marco.Pms.Services.MappingProfiles CreateMap(); CreateMap(); + CreateMap(); } } } diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 7fa2647..6553745 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -6,6 +6,7 @@ using Marco.Pms.Model.Utilities; using Marco.Pms.Services.Helpers; using Marco.Pms.Services.Hubs; using Marco.Pms.Services.Service; +using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Middleware; using MarcoBMS.Services.Service; @@ -154,8 +155,13 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); // Scoped services (one instance per HTTP request) +#region Customs Services builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +#endregion + +#region Helpers builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -164,9 +170,13 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +#endregion + +#region Cache Services builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +#endregion // Singleton services (one instance for the app's lifetime) builder.Services.AddSingleton(); diff --git a/Marco.Pms.Services/Service/PermissionServices.cs b/Marco.Pms.Services/Service/PermissionServices.cs index f20a768..9758a5f 100644 --- a/Marco.Pms.Services/Service/PermissionServices.cs +++ b/Marco.Pms.Services/Service/PermissionServices.cs @@ -37,7 +37,7 @@ namespace Marco.Pms.Services.Service if (projectIds == null) { - var hasPermission = await HasPermission(employeeId, PermissionsMaster.ManageProject); + var hasPermission = await HasPermission(PermissionsMaster.ManageProject, employeeId); if (hasPermission) { var projects = await _context.Projects.Where(c => c.TenantId == LoggedInEmployee.TenantId).ToListAsync(); @@ -45,12 +45,12 @@ namespace Marco.Pms.Services.Service } else { - var allocation = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.IsActive == true).ToListAsync(); - if (allocation.Any()) + var allocation = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.IsActive).ToListAsync(); + if (!allocation.Any()) { - projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); + return false; } - return false; + projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); } await _cache.AddProjects(LoggedInEmployee.Id, projectIds); } diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs new file mode 100644 index 0000000..3280558 --- /dev/null +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -0,0 +1,691 @@ +using AutoMapper; +using Marco.Pms.DataAccess.Data; +using Marco.Pms.Model.Activities; +using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Entitlements; +using Marco.Pms.Model.MongoDBModels; +using Marco.Pms.Model.Projects; +using Marco.Pms.Model.Utilities; +using Marco.Pms.Model.ViewModels.Projects; +using Marco.Pms.Services.Helpers; +using Marco.Pms.Services.Service.ServiceInterfaces; +using MarcoBMS.Services.Helpers; +using MarcoBMS.Services.Service; +using Microsoft.EntityFrameworkCore; + +namespace Marco.Pms.Services.Service +{ + public class ProjectServices : IProjectServices + { + private readonly IDbContextFactory _dbContextFactory; + private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate + private readonly ILoggingService _logger; + private readonly ProjectsHelper _projectsHelper; + private readonly PermissionServices _permission; + private readonly CacheUpdateHelper _cache; + private readonly IMapper _mapper; + public ProjectServices( + IDbContextFactory dbContextFactory, + ApplicationDbContext context, + ILoggingService logger, + ProjectsHelper projectsHelper, + PermissionServices permission, + CacheUpdateHelper cache, + IMapper mapper) + { + _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _projectsHelper = projectsHelper ?? throw new ArgumentNullException(nameof(projectsHelper)); + _permission = permission ?? throw new ArgumentNullException(nameof(permission)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + } + #region =================================================================== Project Get APIs =================================================================== + + public async Task> GetAllProjectsBasicAsync(Guid tenantId, Employee loggedInEmployee) + { + try + { + // Step 1: Verify the current user + if (loggedInEmployee == null) + { + return ApiResponse.ErrorResponse("Unauthorized", "User could not be identified.", 401); + } + + _logger.LogInfo("Basic project list requested by EmployeeId {EmployeeId}", loggedInEmployee.Id); + + // Step 2: Get the list of project IDs the user has access to + List accessibleProjectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + + if (accessibleProjectIds == null || !accessibleProjectIds.Any()) + { + _logger.LogInfo("No accessible projects found for EmployeeId {EmployeeId}", loggedInEmployee.Id); + return ApiResponse.SuccessResponse(new List(), "0 records of project fetchd successfully", 200); + } + + // Step 3: Fetch project ViewModels using the optimized, cache-aware helper + var projectVMs = await GetProjectInfosByIdsAsync(accessibleProjectIds); + + // Step 4: Return the final list + _logger.LogInfo("Successfully returned {ProjectCount} projects for EmployeeId {EmployeeId}", projectVMs.Count, loggedInEmployee.Id); + return ApiResponse.SuccessResponse(projectVMs, $"{projectVMs.Count} records of project fetchd successfully", 200); + } + catch (Exception ex) + { + // --- Step 5: Graceful Error Handling --- + _logger.LogError("An unexpected error occurred in GetAllProjectsBasic for tenant {TenantId}. \n {Error}", tenantId, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); + } + } + + public async Task> GetAllProjectsAsync(Guid tenantId, Employee loggedInEmployee) + { + try + { + _logger.LogInfo("Starting GetAllProjects for TenantId: {TenantId}, User: {UserId}", tenantId, loggedInEmployee.Id); + + // --- Step 1: Get a list of project IDs the user can access --- + List projectIds = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); + if (!projectIds.Any()) + { + _logger.LogInfo("User has no assigned projects. Returning empty list."); + return ApiResponse.SuccessResponse(new List(), "No projects found for the current user.", 200); + } + + // --- Step 2: Efficiently handle partial cache hits --- + _logger.LogInfo("Attempting to fetch details for {ProjectCount} projects from cache.", projectIds.Count); + + // Fetch what we can from the cache. + var cachedDetails = await _cache.GetProjectDetailsList(projectIds) ?? new List(); + var cachedDictionary = cachedDetails.ToDictionary(p => Guid.Parse(p.Id)); + + // Identify which projects are missing from the cache. + var missingIds = projectIds.Where(id => !cachedDictionary.ContainsKey(id)).ToList(); + + // Start building the response with the items we found in the cache. + var responseVms = _mapper.Map>(cachedDictionary.Values); + + if (missingIds.Any()) + { + // --- Step 3: Fetch ONLY the missing items from the database --- + _logger.LogInfo("Cache partial MISS. Found {CachedCount}, fetching {MissingCount} projects from DB.", + cachedDictionary.Count, missingIds.Count); + + // Call our dedicated data-fetching method for the missing IDs. + var newMongoDetails = await FetchAndBuildProjectDetails(missingIds, tenantId); + + if (newMongoDetails.Any()) + { + // Map the newly fetched items and add them to our response list. + responseVms.AddRange(newMongoDetails); + } + } + else + { + _logger.LogInfo("Cache HIT. All {ProjectCount} projects found in cache.", projectIds.Count); + } + + // --- Step 4: Return the combined result --- + _logger.LogInfo("Successfully retrieved a total of {ProjectCount} projects.", responseVms.Count); + return ApiResponse.SuccessResponse(responseVms, "Projects retrieved successfully.", 200); + } + catch (Exception ex) + { + // --- Step 5: Graceful Error Handling --- + _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. \n {Error}", tenantId, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); + } + } + + public async Task> GetProjectAsync(Guid id, Guid tenantId, Employee loggedInEmployee) + { + try + { + // --- Step 1: Run independent operations in PARALLEL --- + // We can check permissions and fetch data at the same time to reduce latency. + var permissionTask = _permission.HasProjectPermission(loggedInEmployee, id); + + // This helper method encapsulates the "cache-first, then database" logic. + var projectDataTask = GetProjectDataAsync(id, tenantId); + + // Await both tasks to complete. + await Task.WhenAll(permissionTask, projectDataTask); + + var hasPermission = await permissionTask; + var projectVm = await projectDataTask; + + // --- Step 2: Process results sequentially --- + + // 2a. Check for permission first. Forbid() is the idiomatic way to return 403. + if (!hasPermission) + { + _logger.LogWarning("Access denied for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to access this project.", 403); + } + + // 2b. Check if the project was found (either in cache or DB). + if (projectVm == null) + { + _logger.LogInfo("Project with ID {ProjectId} not found.", id); + return ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404); + } + + // 2c. Success. Return the consistent ViewModel. + _logger.LogInfo("Successfully retrieved project {ProjectId}.", id); + return ApiResponse.SuccessResponse(projectVm, "Project retrieved successfully.", 200); + } + catch (Exception ex) + { + _logger.LogError("An unexpected error occurred while getting project {ProjectId} : \n {Error}", id, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); + } + } + + public async Task> GetProjectDetailsAsync(Guid id, Guid tenantId, Employee loggedInEmployee) + { + try + { + _logger.LogInfo("Details requested by EmployeeId: {EmployeeId} for ProjectId: {ProjectId}", loggedInEmployee.Id, id); + + // Step 1: Check global view project permission + var hasViewProjectPermission = await _permission.HasPermission(PermissionsMaster.ViewProject, loggedInEmployee.Id); + if (!hasViewProjectPermission) + { + _logger.LogWarning("ViewProjects permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Access denied", "You don't have permission to view projects", 403); + } + + // Step 2: Check permission for this specific project + var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, id); + if (!hasProjectPermission) + { + _logger.LogWarning("Project-specific access denied. EmployeeId: {EmployeeId}, ProjectId: {ProjectId}", loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403); + } + + // Step 3: Fetch project with status + var projectDetails = await _cache.GetProjectDetails(id); + ProjectVM? projectVM = null; + if (projectDetails == null) + { + var project = await _context.Projects + .Include(c => c.ProjectStatus) + .FirstOrDefaultAsync(c => c.TenantId == tenantId && c.Id == id); + + projectVM = _mapper.Map(project); + + if (project != null) + { + await _cache.AddProjectDetails(project); + } + } + else + { + projectVM = _mapper.Map(projectDetails); + if (projectVM.ProjectStatus != null) + { + projectVM.ProjectStatus.TenantId = tenantId; + } + } + + if (projectVM == null) + { + _logger.LogWarning("Project not found. ProjectId: {ProjectId}", id); + return ApiResponse.ErrorResponse("Project not found", "Project not found", 404); + } + + // Step 4: Return result + + _logger.LogInfo("Project details fetched successfully. ProjectId: {ProjectId}", id); + return ApiResponse.SuccessResponse(projectVM, "Project details fetched successfully", 200); + } + catch (Exception ex) + { + // --- Step 5: Graceful Error Handling --- + _logger.LogError("An unexpected error occurred in Get Project Details for project {ProjectId} for tenant {TenantId}. \n {Error}", id, tenantId, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); + } + } + + public async Task> GetProjectDetailsOldAsync(Guid id, Guid tenantId, Employee loggedInEmployee) + { + var project = await _context.Projects + .Where(c => c.TenantId == tenantId && c.Id == id) + .Include(c => c.ProjectStatus) + .SingleOrDefaultAsync(); + + if (project == null) + { + return ApiResponse.ErrorResponse("Project not found", "Project not found", 404); + + } + else + { + ProjectDetailsVM vm = await GetProjectViewModel(id, project); + + OldProjectVM projectVM = new OldProjectVM(); + if (vm.project != null) + { + projectVM.Id = vm.project.Id; + projectVM.Name = vm.project.Name; + projectVM.ShortName = vm.project.ShortName; + projectVM.ProjectAddress = vm.project.ProjectAddress; + projectVM.ContactPerson = vm.project.ContactPerson; + projectVM.StartDate = vm.project.StartDate; + projectVM.EndDate = vm.project.EndDate; + projectVM.ProjectStatusId = vm.project.ProjectStatusId; + } + projectVM.Buildings = new List(); + if (vm.buildings != null) + { + foreach (Building build in vm.buildings) + { + BuildingVM buildVM = new BuildingVM() { Id = build.Id, Description = build.Description, Name = build.Name }; + buildVM.Floors = new List(); + if (vm.floors != null) + { + foreach (Floor floorDto in vm.floors.Where(c => c.BuildingId == build.Id).ToList()) + { + FloorsVM floorVM = new FloorsVM() { FloorName = floorDto.FloorName, Id = floorDto.Id }; + floorVM.WorkAreas = new List(); + + if (vm.workAreas != null) + { + foreach (WorkArea workAreaDto in vm.workAreas.Where(c => c.FloorId == floorVM.Id).ToList()) + { + WorkAreaVM workAreaVM = new WorkAreaVM() { Id = workAreaDto.Id, AreaName = workAreaDto.AreaName, WorkItems = new List() }; + + if (vm.workItems != null) + { + foreach (WorkItem workItemDto in vm.workItems.Where(c => c.WorkAreaId == workAreaDto.Id).ToList()) + { + WorkItemVM workItemVM = new WorkItemVM() { WorkItemId = workItemDto.Id, WorkItem = workItemDto }; + + workItemVM.WorkItem.WorkArea = new WorkArea(); + + if (workItemVM.WorkItem.ActivityMaster != null) + { + workItemVM.WorkItem.ActivityMaster.Tenant = new Tenant(); + } + workItemVM.WorkItem.Tenant = new Tenant(); + + double todaysAssigned = 0; + if (vm.Tasks != null) + { + var tasks = vm.Tasks.Where(t => t.WorkItemId == workItemDto.Id).ToList(); + foreach (TaskAllocation task in tasks) + { + todaysAssigned += task.PlannedTask; + } + } + workItemVM.TodaysAssigned = todaysAssigned; + + workAreaVM.WorkItems.Add(workItemVM); + } + } + + floorVM.WorkAreas.Add(workAreaVM); + } + } + + buildVM.Floors.Add(floorVM); + } + } + projectVM.Buildings.Add(buildVM); + } + } + return ApiResponse.SuccessResponse(projectVM, "Success.", 200); + } + } + + #endregion + + #region =================================================================== Project Manage APIs =================================================================== + + public async Task> CreateProjectAsync(CreateProjectDto projectDto, Guid tenantId, Employee loggedInEmployee) + { + // 1. Prepare data without I/O + var loggedInUserId = loggedInEmployee.Id; + var project = _mapper.Map(projectDto); + project.TenantId = tenantId; + + // 2. Store it to database + try + { + _context.Projects.Add(project); + await _context.SaveChangesAsync(); + } + catch (Exception ex) + { + // Log the detailed exception + _logger.LogError("Failed to create project in database. Rolling back transaction. \n {Error}", ex.Message); + // Return a server error as the primary operation failed + return ApiResponse.ErrorResponse("An error occurred while saving the project.", ex.Message, 500); + } + + // 3. Perform non-critical side-effects (caching, notifications) concurrently + try + { + // These operations do not depend on each other, so they can run in parallel. + Task cacheAddDetailsTask = _cache.AddProjectDetails(project); + Task cacheClearListTask = _cache.ClearAllProjectIdsByPermissionId(PermissionsMaster.ManageProject); + + // Await all side-effect tasks to complete in parallel + await Task.WhenAll(cacheAddDetailsTask, cacheClearListTask); + } + catch (Exception ex) + { + // The project was created successfully, but a side-effect failed. + // Log this as a warning, as the primary operation succeeded. Don't return an error to the user. + _logger.LogWarning("Project {ProjectId} was created, but a post-creation side-effect (caching/notification) failed. \n {Error}", project.Id, ex.Message); + } + + // 4. Return a success response to the user as soon as the critical data is saved. + return ApiResponse.SuccessResponse(_mapper.Map(project), "Project created successfully.", 200); + } + + /// + /// Updates an existing project's details. + /// This endpoint is secure, handles concurrency, and performs non-essential tasks in the background. + /// + /// The ID of the project to update. + /// The data to update the project with. + /// An ApiResponse confirming the update or an appropriate error. + public async Task> UpdateProjectAsync(Guid id, UpdateProjectDto updateProjectDto, Guid tenantId, Employee loggedInEmployee) + { + try + { + // --- Step 1: Fetch the Existing Entity from the Database --- + // This is crucial to avoid the data loss bug. We only want to modify an existing record. + var existingProject = await _context.Projects + .Where(p => p.Id == id && p.TenantId == tenantId) + .SingleOrDefaultAsync(); + + // 1a. Existence Check + if (existingProject == null) + { + _logger.LogWarning("Attempt to update non-existent project with ID {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404); + } + + // 1b. Security Check + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} attempting to update project {ProjectId}.", loggedInEmployee.Id, id); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to modify this project.", 403); + } + + // --- Step 2: Apply Changes and Save --- + // Map the changes from the DTO onto the entity we just fetched from the database. + // This only modifies the properties defined in the mapping, preventing data loss. + _mapper.Map(updateProjectDto, existingProject); + + // Mark the entity as modified (if your mapping doesn't do it automatically). + _context.Entry(existingProject).State = EntityState.Modified; + + try + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully updated project {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); + } + catch (DbUpdateConcurrencyException ex) + { + // --- Step 3: Handle Concurrency Conflicts --- + // This happens if another user modified the project after we fetched it. + _logger.LogWarning("Concurrency conflict while updating project {ProjectId} \n {Error}", id, ex.Message); + return ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409); + } + + // --- Step 4: Perform Side-Effects in the Background (Fire and Forget) --- + // The core database operation is done. Now, we perform non-blocking cache and notification updates. + _ = Task.Run(async () => + { + // Create a DTO of the updated project to pass to background tasks. + var projectDto = _mapper.Map(existingProject); + + // 4a. Update Cache + await UpdateCacheInBackground(existingProject); + + }); + + // --- Step 5: Return Success Response Immediately --- + // The client gets a fast response without waiting for caching or SignalR. + return ApiResponse.SuccessResponse(_mapper.Map(existingProject), "Project updated successfully.", 200); + } + catch (Exception ex) + { + // --- Step 6: Graceful Error Handling for Unexpected Errors --- + _logger.LogError("An unexpected error occurred while updating project {ProjectId} \n {Error}", id, ex.Message); + return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); + } + } + + #endregion + + #region =================================================================== Helper Functions =================================================================== + + /// + /// Retrieves a list of ProjectInfoVMs by their IDs, using an efficient partial cache-hit strategy. + /// It fetches what it can from the cache (as ProjectMongoDB), gets the rest from the + /// database (as Project), updates the cache, and returns a unified list of ViewModels. + /// + /// The list of project IDs to retrieve. + /// A list of ProjectInfoVMs. + private async Task> GetProjectInfosByIdsAsync(List projectIds) + { + // --- Step 1: Fetch from Cache --- + // The cache returns a list of MongoDB documents for the projects it found. + var cachedMongoDocs = await _cache.GetProjectDetailsList(projectIds) ?? new List(); + var finalViewModels = _mapper.Map>(cachedMongoDocs); + + _logger.LogDebug("Cache hit for {CacheCount} of {TotalCount} projects.", finalViewModels.Count, projectIds.Count); + + // --- Step 2: Identify Missing Projects --- + // If we found everything in the cache, we can return early. + if (finalViewModels.Count == projectIds.Count) + { + return finalViewModels; + } + + var cachedIds = cachedMongoDocs.Select(p => p.Id).ToHashSet(); // Assuming ProjectMongoDB has an Id + var missingIds = projectIds.Where(id => !cachedIds.Contains(id.ToString())).ToList(); + + // --- Step 3: Fetch Missing from Database --- + if (missingIds.Any()) + { + _logger.LogDebug("Cache miss for {MissingCount} projects. Querying database.", missingIds.Count); + + var projectsFromDb = await _context.Projects + .Where(p => missingIds.Contains(p.Id)) + .AsNoTracking() // Use AsNoTracking for read-only query performance + .ToListAsync(); + + if (projectsFromDb.Any()) + { + // Map the newly fetched projects (from SQL) to their ViewModel + var vmsFromDb = _mapper.Map>(projectsFromDb); + finalViewModels.AddRange(vmsFromDb); + + // --- Step 4: Update Cache with Missing Items in a new scope --- + _logger.LogDebug("Updating cache with {DbCount} newly fetched projects.", projectsFromDb.Count); + await _cache.AddProjectDetailsList(projectsFromDb); + } + } + + return finalViewModels; + } + + private async Task GetProjectViewModel(Guid? id, Project project) + { + ProjectDetailsVM vm = new ProjectDetailsVM(); + + // List buildings = _unitOfWork.Building.GetAll(c => c.ProjectId == id).ToList(); + List buildings = await _context.Buildings.Where(c => c.ProjectId == id).ToListAsync(); + List idList = buildings.Select(o => o.Id).ToList(); + // List floors = _unitOfWork.Floor.GetAll(c => idList.Contains(c.Id)).ToList(); + List floors = await _context.Floor.Where(c => idList.Contains(c.BuildingId)).ToListAsync(); + idList = floors.Select(o => o.Id).ToList(); + //List workAreas = _unitOfWork.WorkArea.GetAll(c => idList.Contains(c.Id), includeProperties: "WorkItems,WorkItems.ActivityMaster").ToList(); + + List workAreas = await _context.WorkAreas.Where(c => idList.Contains(c.FloorId)).ToListAsync(); + + idList = workAreas.Select(o => o.Id).ToList(); + List workItems = await _context.WorkItems.Include(c => c.WorkCategoryMaster).Where(c => idList.Contains(c.WorkAreaId)).Include(c => c.ActivityMaster).ToListAsync(); + // List workItems = _unitOfWork.WorkItem.GetAll(c => idList.Contains(c.WorkAreaId), includeProperties: "ActivityMaster").ToList(); + idList = workItems.Select(t => t.Id).ToList(); + List tasks = await _context.TaskAllocations.Where(t => idList.Contains(t.WorkItemId) && t.AssignmentDate.Date == DateTime.UtcNow.Date).ToListAsync(); + vm.project = project; + vm.buildings = buildings; + vm.floors = floors; + vm.workAreas = workAreas; + vm.workItems = workItems; + vm.Tasks = tasks; + return vm; + } + + /// + /// Fetches project details from the database for a given list of project IDs and assembles them into MongoDB models. + /// This method encapsulates the optimized, parallel database queries. + /// + /// The list of project IDs to fetch. + /// The current tenant ID for filtering. + /// A list of fully populated ProjectMongoDB objects. + private async Task> FetchAndBuildProjectDetails(List projectIdsToFetch, Guid tenantId) + { + // Task to get base project details for the MISSING projects + var projectsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Projects.AsNoTracking() + .Where(p => projectIdsToFetch.Contains(p.Id) && p.TenantId == tenantId) + .ToListAsync(); + }); + + // Task to get team sizes for the MISSING projects + var teamSizesTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.ProjectAllocations.AsNoTracking() + .Where(pa => pa.TenantId == tenantId && projectIdsToFetch.Contains(pa.ProjectId) && pa.IsActive) + .GroupBy(pa => pa.ProjectId) + .Select(g => new { ProjectId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.ProjectId, x => x.Count); + }); + + // Task to get work summaries for the MISSING projects + var workSummariesTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.WorkItems.AsNoTracking() + .Where(wi => wi.TenantId == tenantId && + wi.WorkArea != null && + wi.WorkArea.Floor != null && + wi.WorkArea.Floor.Building != null && + projectIdsToFetch.Contains(wi.WorkArea.Floor.Building.ProjectId)) + .GroupBy(wi => wi.WorkArea!.Floor!.Building!.ProjectId) + .Select(g => new { ProjectId = g.Key, PlannedWork = g.Sum(i => i.PlannedWork), CompletedWork = g.Sum(i => i.CompletedWork) }) + .ToDictionaryAsync(x => x.ProjectId); + }); + + // Await all parallel tasks to complete + await Task.WhenAll(projectsTask, teamSizesTask, workSummariesTask); + + var projects = await projectsTask; + var teamSizes = await teamSizesTask; + var workSummaries = await workSummariesTask; + + // Proactively update the cache with the items we just fetched. + _logger.LogInfo("Updating cache with {NewItemCount} newly fetched projects.", projects.Count); + await _cache.AddProjectDetailsList(projects); + + // This section would build the full ProjectMongoDB objects, similar to your AddProjectDetailsList method. + // For brevity, assuming you have a mapper or a builder for this. Here's a simplified representation: + var mongoDetailsList = new List(); + foreach (var project in projects) + { + // This is a placeholder for the full build logic from your other methods. + // In a real scenario, you would fetch all hierarchy levels (buildings, floors, etc.) + // for the `projectIdsToFetch` and build the complete MongoDB object. + var mongoDetail = _mapper.Map(project); + mongoDetail.Id = project.Id; + mongoDetail.TeamSize = teamSizes.GetValueOrDefault(project.Id, 0); + if (workSummaries.TryGetValue(project.Id, out var summary)) + { + mongoDetail.PlannedWork = summary.PlannedWork; + mongoDetail.CompletedWork = summary.CompletedWork; + } + mongoDetailsList.Add(mongoDetail); + } + + return mongoDetailsList; + } + + /// + /// Private helper to encapsulate the cache-first data retrieval logic. + /// + /// A ProjectDetailVM if found, otherwise null. + private async Task GetProjectDataAsync(Guid projectId, Guid tenantId) + { + // --- Cache First --- + _logger.LogDebug("Attempting to fetch project {ProjectId} from cache.", projectId); + var cachedProject = await _cache.GetProjectDetails(projectId); + if (cachedProject != null) + { + _logger.LogInfo("Cache HIT for project {ProjectId}.", projectId); + // Map from the cache model (e.g., ProjectMongoDB) to the response ViewModel. + return _mapper.Map(cachedProject); + } + + // --- Database Second (on Cache Miss) --- + _logger.LogInfo("Cache MISS for project {ProjectId}. Fetching from database.", projectId); + var dbProject = await _context.Projects + .AsNoTracking() // Use AsNoTracking for read-only queries. + .Where(p => p.Id == projectId && p.TenantId == tenantId) + .SingleOrDefaultAsync(); + + if (dbProject == null) + { + return null; // The project doesn't exist. + } + + // --- Proactively Update Cache --- + // The next request for this project will now be a cache hit. + try + { + // Map the DB entity to the cache model (e.g., ProjectMongoDB) before caching. + await _cache.AddProjectDetails(dbProject); + _logger.LogInfo("Updated cache with project {ProjectId}.", projectId); + } + catch (Exception ex) + { + _logger.LogWarning("Failed to update cache for project {ProjectId} : \n {Error}", projectId, ex.Message); + } + + // Map from the database entity to the response ViewModel. + return dbProject; + } + + // Helper method for background cache update + private async Task UpdateCacheInBackground(Project project) + { + try + { + // This logic can be more complex, but the idea is to update or add. + if (!await _cache.UpdateProjectDetailsOnly(project)) + { + await _cache.AddProjectDetails(project); + } + _logger.LogInfo("Background cache update succeeded for project {ProjectId}.", project.Id); + } + catch (Exception ex) + { + _logger.LogError("Background cache update failed for project {ProjectId} \n {Error}", project.Id, ex.Message); + } + } + + #endregion + } +} diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs new file mode 100644 index 0000000..a23eba0 --- /dev/null +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -0,0 +1,17 @@ +using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Utilities; + +namespace Marco.Pms.Services.Service.ServiceInterfaces +{ + public interface IProjectServices + { + Task> GetAllProjectsBasicAsync(Guid tenantId, Employee loggedInEmployee); + Task> GetAllProjectsAsync(Guid tenantId, Employee loggedInEmployee); + Task> GetProjectAsync(Guid id, Guid tenantId, Employee loggedInEmployee); + Task> GetProjectDetailsAsync(Guid id, Guid tenantId, Employee loggedInEmployee); + Task> GetProjectDetailsOldAsync(Guid id, Guid tenantId, Employee loggedInEmployee); + Task> CreateProjectAsync(CreateProjectDto projectDto, Guid tenantId, Employee loggedInEmployee); + Task> UpdateProjectAsync(Guid id, UpdateProjectDto updateProjectDto, Guid tenantId, Employee loggedInEmployee); + } +}