From e4e49595e666a33dbcdf83c1b4b36e9c68a82a50 Mon Sep 17 00:00:00 2001 From: "ashutosh.nehete" Date: Thu, 25 Sep 2025 12:18:44 +0530 Subject: [PATCH] Enhanced the Create and update project API --- Marco.Pms.Helpers/CacheHelper/ProjectCache.cs | 18 ++- .../Dtos/Projects/CreateProjectDto.cs | 11 +- .../Dtos/Projects/UpdateProjectDto.cs | 12 +- .../MongoDBModels/OrganizationMongoDB.cs | 10 ++ .../MongoDBModels/Project/ProjectMongoDB.cs | 2 + .../ViewModels/Tenant/TenantListVM.cs | 1 + .../Controllers/AuthController.cs | 40 ++--- .../Helpers/CacheUpdateHelper.cs | 104 +++++++++++-- Marco.Pms.Services/Service/ProjectServices.cs | 142 ++++++++++++++---- 9 files changed, 273 insertions(+), 67 deletions(-) create mode 100644 Marco.Pms.Model/MongoDBModels/OrganizationMongoDB.cs diff --git a/Marco.Pms.Helpers/CacheHelper/ProjectCache.cs b/Marco.Pms.Helpers/CacheHelper/ProjectCache.cs index ec5598b..cc972ee 100644 --- a/Marco.Pms.Helpers/CacheHelper/ProjectCache.cs +++ b/Marco.Pms.Helpers/CacheHelper/ProjectCache.cs @@ -1,7 +1,9 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Master; +using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.MongoDBModels.Masters; using Marco.Pms.Model.MongoDBModels.Project; +using Marco.Pms.Model.OrganizationModel; using Marco.Pms.Model.Projects; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -55,7 +57,7 @@ namespace Marco.Pms.Helpers var indexModel = new CreateIndexModel(indexKeys, indexOptions); await _projectCollection.Indexes.CreateOneAsync(indexModel); } - public async Task UpdateProjectDetailsOnlyToCache(Project project, StatusMaster projectStatus) + public async Task UpdateProjectDetailsOnlyToCache(Project project, StatusMaster projectStatus, Organization promotor, Organization pmc) { // Build the update definition var updates = Builders.Update.Combine( @@ -67,6 +69,20 @@ namespace Marco.Pms.Helpers Id = projectStatus.Id.ToString(), Status = projectStatus.Status }), + Builders.Update.Set(r => r.Promoter, new OrganizationMongoDB + { + Id = promotor.Id.ToString(), + Name = promotor.Name, + ContactPerson = promotor.ContactPerson, + Email = promotor.Email + }), + Builders.Update.Set(r => r.PMC, new OrganizationMongoDB + { + Id = pmc.Id.ToString(), + Name = pmc.Name, + ContactPerson = pmc.ContactPerson, + Email = pmc.Email + }), Builders.Update.Set(r => r.StartDate, project.StartDate), Builders.Update.Set(r => r.EndDate, project.EndDate), Builders.Update.Set(r => r.ContactPerson, project.ContactPerson) diff --git a/Marco.Pms.Model/Dtos/Projects/CreateProjectDto.cs b/Marco.Pms.Model/Dtos/Projects/CreateProjectDto.cs index 0e1d213..32846dc 100644 --- a/Marco.Pms.Model/Dtos/Projects/CreateProjectDto.cs +++ b/Marco.Pms.Model/Dtos/Projects/CreateProjectDto.cs @@ -7,17 +7,18 @@ namespace Marco.Pms.Model.Dtos.Project { [Required(ErrorMessage = "Project Name is required!")] [DisplayName("Project Name")] - public string? Name { get; set; } + public required string Name { get; set; } + [DisplayName("Short Name")] public string? ShortName { get; set; } [DisplayName("Project Address")] [Required(ErrorMessage = "Project Address is required!")] - public string? ProjectAddress { get; set; } + public required string ProjectAddress { get; set; } [DisplayName("Contact Person")] - public string? ContactPerson { get; set; } + public required string ContactPerson { get; set; } public DateTime? StartDate { get; set; } @@ -25,6 +26,8 @@ namespace Marco.Pms.Model.Dtos.Project [DisplayName("Project Status")] [Required(ErrorMessage = "Project Status is required!")] - public Guid ProjectStatusId { get; set; } + public required Guid ProjectStatusId { get; set; } + public required Guid PromoterId { get; set; } + public required Guid PMCId { get; set; } } } diff --git a/Marco.Pms.Model/Dtos/Projects/UpdateProjectDto.cs b/Marco.Pms.Model/Dtos/Projects/UpdateProjectDto.cs index d7430c3..56c6e9f 100644 --- a/Marco.Pms.Model/Dtos/Projects/UpdateProjectDto.cs +++ b/Marco.Pms.Model/Dtos/Projects/UpdateProjectDto.cs @@ -5,20 +5,20 @@ namespace Marco.Pms.Model.Dtos.Project { public class UpdateProjectDto { - public Guid Id { get; set; } + public required Guid Id { get; set; } [Required(ErrorMessage = "Project Name is required!")] [DisplayName("Project Name")] - public string? Name { get; set; } + public required string Name { get; set; } [DisplayName("Short Name")] public string? ShortName { get; set; } [DisplayName("Project Address")] [Required(ErrorMessage = "Project Address is required!")] - public string? ProjectAddress { get; set; } + public required string ProjectAddress { get; set; } [DisplayName("Contact Person")] - public string? ContactPerson { get; set; } + public required string ContactPerson { get; set; } public DateTime? StartDate { get; set; } @@ -26,6 +26,8 @@ namespace Marco.Pms.Model.Dtos.Project [DisplayName("Project Status")] [Required(ErrorMessage = "Project Status is required!")] - public Guid ProjectStatusId { get; set; } + public required Guid ProjectStatusId { get; set; } + public required Guid PromoterId { get; set; } + public required Guid PMCId { get; set; } } } diff --git a/Marco.Pms.Model/MongoDBModels/OrganizationMongoDB.cs b/Marco.Pms.Model/MongoDBModels/OrganizationMongoDB.cs new file mode 100644 index 0000000..d18f0e9 --- /dev/null +++ b/Marco.Pms.Model/MongoDBModels/OrganizationMongoDB.cs @@ -0,0 +1,10 @@ +namespace Marco.Pms.Model.MongoDBModels +{ + public class OrganizationMongoDB + { + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string ContactPerson { get; set; } = string.Empty; + } +} diff --git a/Marco.Pms.Model/MongoDBModels/Project/ProjectMongoDB.cs b/Marco.Pms.Model/MongoDBModels/Project/ProjectMongoDB.cs index c2600bd..d64d358 100644 --- a/Marco.Pms.Model/MongoDBModels/Project/ProjectMongoDB.cs +++ b/Marco.Pms.Model/MongoDBModels/Project/ProjectMongoDB.cs @@ -13,6 +13,8 @@ namespace Marco.Pms.Model.MongoDBModels.Project public DateTime? StartDate { get; set; } public DateTime? EndDate { get; set; } public StatusMasterMongoDB? ProjectStatus { get; set; } + public OrganizationMongoDB? Promoter { get; set; } + public OrganizationMongoDB? PMC { get; set; } public int TeamSize { get; set; } public double CompletedWork { get; set; } public double PlannedWork { get; set; } diff --git a/Marco.Pms.Model/ViewModels/Tenant/TenantListVM.cs b/Marco.Pms.Model/ViewModels/Tenant/TenantListVM.cs index e3348b1..3c7f46e 100644 --- a/Marco.Pms.Model/ViewModels/Tenant/TenantListVM.cs +++ b/Marco.Pms.Model/ViewModels/Tenant/TenantListVM.cs @@ -12,6 +12,7 @@ namespace Marco.Pms.Model.ViewModels.Tenant public string ContactNumber { get; set; } = string.Empty; public string? logoImage { get; set; } // Base64 public string? OrganizationSize { get; set; } + public Guid OrganizationId { get; set; } public Industry? Industry { get; set; } public TenantStatus? TenantStatus { get; set; } } diff --git a/Marco.Pms.Services/Controllers/AuthController.cs b/Marco.Pms.Services/Controllers/AuthController.cs index efe81ac..abc09fb 100644 --- a/Marco.Pms.Services/Controllers/AuthController.cs +++ b/Marco.Pms.Services/Controllers/AuthController.cs @@ -1294,33 +1294,37 @@ namespace MarcoBMS.Services.Controllers _logger.LogInfo("Fetching TenantOrgMappings for OrganizationId: {OrganizationId}", organizationId); // Retrieve all TenantOrgMappings that match the organizationId and have a related Tenant - var tenantOrganizationMapping = await _context.TenantOrgMappings - .Include(to => to.Tenant) - .ThenInclude(t => t!.TenantStatus) - .Include(to => to.Tenant) - .ThenInclude(t => t!.Industry) - .Where(to => to.OrganizationId == organizationId && to.Tenant != null) - .ToListAsync(); + var tenantOrgMappingTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.TenantOrgMappings.Where(to => to.OrganizationId == organizationId && to.Tenant != null).Select(to => to.TenantId).ToListAsync(); + }); + var projectTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.Projects.Where(to => to.PromoterId == organizationId || to.PMCId == organizationId).Select(to => to.TenantId).ToListAsync(); + }); - var tenantList = tenantOrganizationMapping.Select(to => to.Tenant!).ToList(); + await Task.WhenAll(tenantOrgMappingTask, projectTask); + + var tenantIds = tenantOrgMappingTask.Result; + + tenantIds.AddRange(projectTask.Result); + + tenantIds = tenantIds.Distinct().ToList(); // Additionally fetch the Tenant record associated directly with this OrganizationId if any - var tenant = await _context.Tenants + var tenants = await _context.Tenants .Include(t => t.Industry) .Include(t => t.TenantStatus) - .FirstOrDefaultAsync(t => t.OrganizationId == organizationId); - if (tenant != null) - { - tenantList.Add(tenant); - } + .Where(t => t.OrganizationId == organizationId || tenantIds.Contains(t.Id)).ToListAsync(); - - tenantList = tenantList.Distinct().ToList(); + tenants = tenants.Distinct().ToList(); // Map the tenant entities to TenantListVM view models - var response = _mapper.Map>(tenantList); + var response = _mapper.Map>(tenants); - _logger.LogInfo("Fetched {Count} tenants for OrganizationId: {OrganizationId}", tenantList.Count, organizationId); + _logger.LogInfo("Fetched {Count} tenants for OrganizationId: {OrganizationId}", tenants.Count, organizationId); _logger.LogDebug("GetTenantAsync method completed successfully."); return Ok(ApiResponse.SuccessResponse(response, "Successfully fetched the list of tenant", 200)); diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index 8048742..f95f06e 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -10,6 +10,7 @@ using Marco.Pms.Model.MongoDBModels.Expenses; using Marco.Pms.Model.MongoDBModels.Masters; using Marco.Pms.Model.MongoDBModels.Project; using Marco.Pms.Model.MongoDBModels.Utility; +using Marco.Pms.Model.OrganizationModel; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using MarcoBMS.Services.Service; @@ -69,6 +70,39 @@ namespace Marco.Pms.Services.Helpers .Where(s => s.Id == project.ProjectStatusId) .Select(s => new { s.Id, s.Status }) // Projection .FirstOrDefaultAsync(); + + }); + + var promotorTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Organizations + .AsNoTracking() + .Where(o => o.Id == project.PromoterId) + .Select(o => new OrganizationMongoDB + { + Id = o.Id.ToString(), + Name = o.Name, + ContactPerson = o.ContactPerson, + Email = o.Email + }) // Projection + .FirstOrDefaultAsync(); + }); + + var pmcTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Organizations + .AsNoTracking() + .Where(o => o.Id == project.PMCId) + .Select(o => new OrganizationMongoDB + { + Id = o.Id.ToString(), + Name = o.Name, + ContactPerson = o.ContactPerson, + Email = o.Email + }) // Projection + .FirstOrDefaultAsync(); }); var teamSizeTask = Task.Run(async () => @@ -120,12 +154,15 @@ namespace Marco.Pms.Services.Helpers }); // Wait for all parallel database operations to complete. - await Task.WhenAll(statusTask, teamSizeTask, infrastructureTask); + await Task.WhenAll(statusTask, teamSizeTask, infrastructureTask, promotorTask, pmcTask); // Get the results from the completed tasks. - var status = await statusTask; - var teamSize = await teamSizeTask; - var (allBuildings, allFloors, allWorkAreas, workSummariesByWorkAreaId) = await infrastructureTask; + + var status = statusTask.Result; + var teamSize = teamSizeTask.Result; + var (allBuildings, allFloors, allWorkAreas, workSummariesByWorkAreaId) = infrastructureTask.Result; + var promotor = promotorTask.Result; + var pmc = pmcTask.Result; // --- Step 2: Process the fetched data and build the MongoDB model --- @@ -216,6 +253,8 @@ namespace Marco.Pms.Services.Helpers projectDetails.Buildings = buildingMongoList; projectDetails.PlannedWork = totalPlannedWork; projectDetails.CompletedWork = totalCompletedWork; + projectDetails.Promoter = promotor; + projectDetails.PMC = pmc; try { @@ -234,6 +273,8 @@ namespace Marco.Pms.Services.Helpers return; // Nothing to do } var projectStatusIds = projects.Select(p => p.ProjectStatusId).Distinct().ToList(); + var promotorIds = projects.Select(p => p.PromoterId).Distinct().ToList(); + var pmcsIds = projects.Select(p => p.PMCId).Distinct().ToList(); // --- Step 1: Fetch all required data in maximum parallel --- // Each task uses its own DbContext and selects only the required columns (projection). @@ -248,6 +289,22 @@ namespace Marco.Pms.Services.Helpers .ToDictionaryAsync(s => s.Id); }); + var organizationTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Organizations + .AsNoTracking() + .Where(o => promotorIds.Contains(o.Id) || pmcsIds.Contains(o.Id)) + .Select(o => new OrganizationMongoDB + { + Id = o.Id.ToString(), + Name = o.Name, + ContactPerson = o.ContactPerson, + Email = o.Email + }) // Projection + .ToListAsync(); + }); + var teamSizeTask = Task.Run(async () => { using var context = _dbContextFactory.CreateDbContext(); @@ -322,20 +379,22 @@ namespace Marco.Pms.Services.Helpers }); // Await the remaining parallel tasks. - await Task.WhenAll(statusTask, teamSizeTask, workAreasTask, workSummaryTask); + await Task.WhenAll(statusTask, teamSizeTask, workAreasTask, workSummaryTask, organizationTask); // --- Step 2: Process the fetched data and build the MongoDB models --- - var allStatuses = await statusTask; - var teamSizesByProjectId = await teamSizeTask; - var allWorkAreas = await workAreasTask; - var workSummariesByWorkAreaId = await workSummaryTask; + var allStatuses = statusTask.Result; + var teamSizesByProjectId = teamSizeTask.Result; + var allWorkAreas = workAreasTask.Result; + var workSummariesByWorkAreaId = workSummaryTask.Result; + var organizations = organizationTask.Result; // Create fast in-memory lookups for hierarchical data var buildingsByProjectId = allBuildings.ToLookup(b => b.ProjectId); var floorsByBuildingId = allFloors.ToLookup(f => f.BuildingId); var workAreasByFloorId = allWorkAreas.ToLookup(wa => wa.FloorId); + var projectDetailsList = new List(projects.Count); foreach (var project in projects) { @@ -427,6 +486,8 @@ namespace Marco.Pms.Services.Helpers projectDetails.Buildings = buildingMongoList; projectDetails.PlannedWork = totalPlannedWork; projectDetails.CompletedWork = totalCompletedWork; + projectDetails.Promoter = organizations.FirstOrDefault(o => o.Id == project.PromoterId.ToString()); + projectDetails.PMC = organizations.FirstOrDefault(o => o.Id == project.PMCId.ToString()); projectDetailsList.Add(projectDetails); } @@ -443,11 +504,32 @@ namespace Marco.Pms.Services.Helpers } public async Task UpdateProjectDetailsOnly(Project project) { - StatusMaster projectStatus = await _context.StatusMasters + var projectStatusTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.StatusMasters .FirstOrDefaultAsync(s => s.Id == project.ProjectStatusId) ?? new StatusMaster(); + }); + var promotorTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.Organizations.FirstOrDefaultAsync(o => o.Id == project.PromoterId) ?? new Organization(); + }); + var pmcTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.Organizations.FirstOrDefaultAsync(o => o.Id == project.PMCId) ?? new Organization(); + }); + + await Task.WhenAll(projectStatusTask, promotorTask, pmcTask); + + var projectStatus = projectStatusTask.Result; + var promotor = promotorTask.Result; + var pmc = pmcTask.Result; + try { - bool response = await _projectCache.UpdateProjectDetailsOnlyToCache(project, projectStatus); + bool response = await _projectCache.UpdateProjectDetailsOnlyToCache(project, projectStatus, promotor, pmc); return response; } catch (Exception ex) diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs index 69d4b05..692ef5e 100644 --- a/Marco.Pms.Services/Service/ProjectServices.cs +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -351,63 +351,110 @@ namespace Marco.Pms.Services.Service #region =================================================================== Project Manage APIs =================================================================== - public async Task> CreateProjectAsync(CreateProjectDto projectDto, Guid tenantId, Employee loggedInEmployee) + public async Task> CreateProjectAsync(CreateProjectDto model, Guid tenantId, Employee loggedInEmployee) { + // Begin a new scope for service resolution. using var scope = _serviceScopeFactory.CreateScope(); var _firebase = scope.ServiceProvider.GetRequiredService(); - // 1. Prepare data without I/O + // Step 1: Validate tenant access for the current employee. + var tenant = await _context.Tenants + .FirstOrDefaultAsync(t => t.Id == tenantId && t.OrganizationId == loggedInEmployee.OrganizationId); + + if (tenant == null) + { + _logger.LogWarning("Access DENIED (OrgId:{OrgId}) by Employee {EmployeeId} for tenantId={TenantId}.", + loggedInEmployee.OrganizationId, loggedInEmployee.Id, tenantId); + return ApiResponse.ErrorResponse("Access Denied", "You do not have permission to create a project for this tenant.", 403); + } + + // Step 2: Concurrent validation for Promoter and PMC organization existence. + // Run database queries in parallel for better performance. + var promoterExistsTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.Organizations.AnyAsync(o => o.Id == model.PromoterId); + }); + var pmcExistsTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.Organizations.AnyAsync(o => o.Id == model.PMCId); + }); + + await Task.WhenAll(promoterExistsTask, pmcExistsTask); + + bool promoterExists = promoterExistsTask.Result; + bool pmcExists = pmcExistsTask.Result; + + if (!promoterExists) + { + _logger.LogWarning("Promoter check failed. PromoterId={PromoterId} not found.", model.PromoterId); + return ApiResponse.ErrorResponse("Promoter not found", "Promoter not found in database.", 404); + } + if (!pmcExists) + { + _logger.LogWarning("PMC check failed. PMCId={PMCId} not found.", model.PMCId); + return ApiResponse.ErrorResponse("PMC not found", "PMC not found in database.", 404); + } + + // Step 3: Prepare the project entity. var loggedInUserId = loggedInEmployee.Id; - var project = _mapper.Map(projectDto); + var project = _mapper.Map(model); project.TenantId = tenantId; - // 2. Store it to database + // Step 4: Save the new project to the database. try { _context.Projects.Add(project); await _context.SaveChangesAsync(); + _logger.LogInfo("Project {ProjectId} created successfully for TenantId={TenantId}, by Employee {EmployeeId}.", + project.Id, tenantId, loggedInUserId); } catch (Exception ex) { - // Log the detailed exception - _logger.LogError(ex, "Failed to create project in database. Rolling back transaction."); - // Return a server error as the primary operation failed + _logger.LogError(ex, "DB Failure: Project creation failed for TenantId={TenantId}. Rolling back.", tenantId); return ApiResponse.ErrorResponse("An error occurred while saving the project.", ex.Message, 500); } - // 3. Perform non-critical side-effects (caching, notifications) concurrently + // Step 5: Perform non-critical post-save side effects (e.g., caching) in parallel. 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, tenantId); + var cacheAddDetailsTask = _cache.AddProjectDetails(project); + var cacheClearListTask = _cache.ClearAllProjectIdsByPermissionId(PermissionsMaster.ManageProject, tenantId); - // Await all side-effect tasks to complete in parallel await Task.WhenAll(cacheAddDetailsTask, cacheClearListTask); + + _logger.LogInfo("Cache updated for ProjectId={ProjectId} after creation.", project.Id); } 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.LogError(ex, "Project {ProjectId} was created, but a post-creation side-effect (caching/notification) failed. ", project.Id); + // Log the issue but do not interrupt the user flow. + _logger.LogError(ex, "Post-save cache operation failed for ProjectId={ProjectId}.", project.Id); } + // Step 6: Fire-and-forget push notification for post-creation event. Designed to be non-blocking. _ = Task.Run(async () => { - // --- Push Notification Section --- - // This section attempts to send a test push notification to the user's device. - // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. - - var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; - - await _firebase.SendModifyProjectMessageAsync(project, name, false, tenantId); - + try + { + var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + await _firebase.SendModifyProjectMessageAsync(project, name, false, tenantId); + _logger.LogInfo("Push notification sent for ProjectId={ProjectId} ({Name}).", project.Id, name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Push notification sending failed for ProjectId={ProjectId}.", project.Id); + } }); - // 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); + // Step 7: Return success response as soon as critical operation is complete. + var resultDto = _mapper.Map(project); + _logger.LogInfo("Returning success response for ProjectId={ProjectId}.", project.Id); + + return ApiResponse.SuccessResponse(resultDto, "Project created successfully.", 200); } + /// /// Updates an existing project's details. /// This endpoint is secure, handles concurrency, and performs non-essential tasks in the background. @@ -415,7 +462,7 @@ namespace Marco.Pms.Services.Service /// 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) + public async Task> UpdateProjectAsync(Guid id, UpdateProjectDto model, Guid tenantId, Employee loggedInEmployee) { using var scope = _serviceScopeFactory.CreateScope(); var _firebase = scope.ServiceProvider.GetRequiredService(); @@ -436,7 +483,46 @@ namespace Marco.Pms.Services.Service return ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404); } - // 1b. Security Check + var tenant = await _context.Tenants + .FirstOrDefaultAsync(t => t.Id == tenantId && t.OrganizationId == loggedInEmployee.OrganizationId); + + if (tenant == null && existingProject.PMCId == loggedInEmployee.OrganizationId) + { + _logger.LogWarning("Access DENIED (OrgId:{OrgId}) by Employee {EmployeeId} for tenantId={TenantId}.", + loggedInEmployee.OrganizationId, loggedInEmployee.Id, tenantId); + return ApiResponse.ErrorResponse("Access Denied", "You do not have permission to update a project for this tenant.", 403); + } + + // 1bb. Concurrent validation for Promoter and PMC organization existence. + // Run database queries in parallel for better performance. + var promoterExistsTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.Organizations.AnyAsync(o => o.Id == model.PromoterId); + }); + var pmcExistsTask = Task.Run(async () => + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + return await context.Organizations.AnyAsync(o => o.Id == model.PMCId); + }); + + await Task.WhenAll(promoterExistsTask, pmcExistsTask); + + bool promoterExists = promoterExistsTask.Result; + bool pmcExists = pmcExistsTask.Result; + + if (!promoterExists) + { + _logger.LogWarning("Promoter check failed. PromoterId={PromoterId} not found.", model.PromoterId); + return ApiResponse.ErrorResponse("Promoter not found", "Promoter not found in database.", 404); + } + if (!pmcExists) + { + _logger.LogWarning("PMC check failed. PMCId={PMCId} not found.", model.PMCId); + return ApiResponse.ErrorResponse("PMC not found", "PMC not found in database.", 404); + } + + // 1c. Security Check var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, id); if (!hasPermission) { @@ -447,7 +533,7 @@ namespace Marco.Pms.Services.Service // --- 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); + _mapper.Map(model, existingProject); // Mark the entity as modified (if your mapping doesn't do it automatically). _context.Entry(existingProject).State = EntityState.Modified;