Compare commits

..

No commits in common. "2258771229c977eae454ef1a8f64d5bb35c98e70" and "44d2827dccbca812bd14e3bead6fef4bb84bb773" have entirely different histories.

9 changed files with 67 additions and 273 deletions

View File

@ -1,9 +1,7 @@
using Marco.Pms.DataAccess.Data; using Marco.Pms.DataAccess.Data;
using Marco.Pms.Model.Master; using Marco.Pms.Model.Master;
using Marco.Pms.Model.MongoDBModels;
using Marco.Pms.Model.MongoDBModels.Masters; using Marco.Pms.Model.MongoDBModels.Masters;
using Marco.Pms.Model.MongoDBModels.Project; using Marco.Pms.Model.MongoDBModels.Project;
using Marco.Pms.Model.OrganizationModel;
using Marco.Pms.Model.Projects; using Marco.Pms.Model.Projects;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@ -57,7 +55,7 @@ namespace Marco.Pms.Helpers
var indexModel = new CreateIndexModel<ProjectMongoDB>(indexKeys, indexOptions); var indexModel = new CreateIndexModel<ProjectMongoDB>(indexKeys, indexOptions);
await _projectCollection.Indexes.CreateOneAsync(indexModel); await _projectCollection.Indexes.CreateOneAsync(indexModel);
} }
public async Task<bool> UpdateProjectDetailsOnlyToCache(Project project, StatusMaster projectStatus, Organization promotor, Organization pmc) public async Task<bool> UpdateProjectDetailsOnlyToCache(Project project, StatusMaster projectStatus)
{ {
// Build the update definition // Build the update definition
var updates = Builders<ProjectMongoDB>.Update.Combine( var updates = Builders<ProjectMongoDB>.Update.Combine(
@ -69,20 +67,6 @@ namespace Marco.Pms.Helpers
Id = projectStatus.Id.ToString(), Id = projectStatus.Id.ToString(),
Status = projectStatus.Status Status = projectStatus.Status
}), }),
Builders<ProjectMongoDB>.Update.Set(r => r.Promoter, new OrganizationMongoDB
{
Id = promotor.Id.ToString(),
Name = promotor.Name,
ContactPerson = promotor.ContactPerson,
Email = promotor.Email
}),
Builders<ProjectMongoDB>.Update.Set(r => r.PMC, new OrganizationMongoDB
{
Id = pmc.Id.ToString(),
Name = pmc.Name,
ContactPerson = pmc.ContactPerson,
Email = pmc.Email
}),
Builders<ProjectMongoDB>.Update.Set(r => r.StartDate, project.StartDate), Builders<ProjectMongoDB>.Update.Set(r => r.StartDate, project.StartDate),
Builders<ProjectMongoDB>.Update.Set(r => r.EndDate, project.EndDate), Builders<ProjectMongoDB>.Update.Set(r => r.EndDate, project.EndDate),
Builders<ProjectMongoDB>.Update.Set(r => r.ContactPerson, project.ContactPerson) Builders<ProjectMongoDB>.Update.Set(r => r.ContactPerson, project.ContactPerson)

View File

@ -7,18 +7,17 @@ namespace Marco.Pms.Model.Dtos.Project
{ {
[Required(ErrorMessage = "Project Name is required!")] [Required(ErrorMessage = "Project Name is required!")]
[DisplayName("Project Name")] [DisplayName("Project Name")]
public required string Name { get; set; } public string? Name { get; set; }
[DisplayName("Short Name")] [DisplayName("Short Name")]
public string? ShortName { get; set; } public string? ShortName { get; set; }
[DisplayName("Project Address")] [DisplayName("Project Address")]
[Required(ErrorMessage = "Project Address is required!")] [Required(ErrorMessage = "Project Address is required!")]
public required string ProjectAddress { get; set; } public string? ProjectAddress { get; set; }
[DisplayName("Contact Person")] [DisplayName("Contact Person")]
public required string ContactPerson { get; set; } public string? ContactPerson { get; set; }
public DateTime? StartDate { get; set; } public DateTime? StartDate { get; set; }
@ -26,8 +25,6 @@ namespace Marco.Pms.Model.Dtos.Project
[DisplayName("Project Status")] [DisplayName("Project Status")]
[Required(ErrorMessage = "Project Status is required!")] [Required(ErrorMessage = "Project Status is required!")]
public required Guid ProjectStatusId { get; set; } public Guid ProjectStatusId { get; set; }
public required Guid PromoterId { get; set; }
public required Guid PMCId { get; set; }
} }
} }

View File

@ -5,20 +5,20 @@ namespace Marco.Pms.Model.Dtos.Project
{ {
public class UpdateProjectDto public class UpdateProjectDto
{ {
public required Guid Id { get; set; } public Guid Id { get; set; }
[Required(ErrorMessage = "Project Name is required!")] [Required(ErrorMessage = "Project Name is required!")]
[DisplayName("Project Name")] [DisplayName("Project Name")]
public required string Name { get; set; } public string? Name { get; set; }
[DisplayName("Short Name")] [DisplayName("Short Name")]
public string? ShortName { get; set; } public string? ShortName { get; set; }
[DisplayName("Project Address")] [DisplayName("Project Address")]
[Required(ErrorMessage = "Project Address is required!")] [Required(ErrorMessage = "Project Address is required!")]
public required string ProjectAddress { get; set; } public string? ProjectAddress { get; set; }
[DisplayName("Contact Person")] [DisplayName("Contact Person")]
public required string ContactPerson { get; set; } public string? ContactPerson { get; set; }
public DateTime? StartDate { get; set; } public DateTime? StartDate { get; set; }
@ -26,8 +26,6 @@ namespace Marco.Pms.Model.Dtos.Project
[DisplayName("Project Status")] [DisplayName("Project Status")]
[Required(ErrorMessage = "Project Status is required!")] [Required(ErrorMessage = "Project Status is required!")]
public required Guid ProjectStatusId { get; set; } public Guid ProjectStatusId { get; set; }
public required Guid PromoterId { get; set; }
public required Guid PMCId { get; set; }
} }
} }

View File

@ -1,10 +0,0 @@
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;
}
}

View File

@ -13,8 +13,6 @@ namespace Marco.Pms.Model.MongoDBModels.Project
public DateTime? StartDate { get; set; } public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; } public DateTime? EndDate { get; set; }
public StatusMasterMongoDB? ProjectStatus { get; set; } public StatusMasterMongoDB? ProjectStatus { get; set; }
public OrganizationMongoDB? Promoter { get; set; }
public OrganizationMongoDB? PMC { get; set; }
public int TeamSize { get; set; } public int TeamSize { get; set; }
public double CompletedWork { get; set; } public double CompletedWork { get; set; }
public double PlannedWork { get; set; } public double PlannedWork { get; set; }

View File

@ -12,7 +12,6 @@ namespace Marco.Pms.Model.ViewModels.Tenant
public string ContactNumber { get; set; } = string.Empty; public string ContactNumber { get; set; } = string.Empty;
public string? logoImage { get; set; } // Base64 public string? logoImage { get; set; } // Base64
public string? OrganizationSize { get; set; } public string? OrganizationSize { get; set; }
public Guid OrganizationId { get; set; }
public Industry? Industry { get; set; } public Industry? Industry { get; set; }
public TenantStatus? TenantStatus { get; set; } public TenantStatus? TenantStatus { get; set; }
} }

View File

@ -1294,37 +1294,33 @@ namespace MarcoBMS.Services.Controllers
_logger.LogInfo("Fetching TenantOrgMappings for OrganizationId: {OrganizationId}", organizationId); _logger.LogInfo("Fetching TenantOrgMappings for OrganizationId: {OrganizationId}", organizationId);
// Retrieve all TenantOrgMappings that match the organizationId and have a related Tenant // Retrieve all TenantOrgMappings that match the organizationId and have a related Tenant
var tenantOrgMappingTask = Task.Run(async () => var tenantOrganizationMapping = await _context.TenantOrgMappings
{ .Include(to => to.Tenant)
await using var context = await _dbContextFactory.CreateDbContextAsync(); .ThenInclude(t => t!.TenantStatus)
return await context.TenantOrgMappings.Where(to => to.OrganizationId == organizationId && to.Tenant != null).Select(to => to.TenantId).ToListAsync(); .Include(to => to.Tenant)
}); .ThenInclude(t => t!.Industry)
var projectTask = Task.Run(async () => .Where(to => to.OrganizationId == organizationId && to.Tenant != null)
{ .ToListAsync();
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.Projects.Where(to => to.PromoterId == organizationId || to.PMCId == organizationId).Select(to => to.TenantId).ToListAsync();
});
await Task.WhenAll(tenantOrgMappingTask, projectTask); var tenantList = tenantOrganizationMapping.Select(to => to.Tenant!).ToList();
var tenantIds = tenantOrgMappingTask.Result;
tenantIds.AddRange(projectTask.Result);
tenantIds = tenantIds.Distinct().ToList();
// Additionally fetch the Tenant record associated directly with this OrganizationId if any // Additionally fetch the Tenant record associated directly with this OrganizationId if any
var tenants = await _context.Tenants var tenant = await _context.Tenants
.Include(t => t.Industry) .Include(t => t.Industry)
.Include(t => t.TenantStatus) .Include(t => t.TenantStatus)
.Where(t => t.OrganizationId == organizationId || tenantIds.Contains(t.Id)).ToListAsync(); .FirstOrDefaultAsync(t => t.OrganizationId == organizationId);
if (tenant != null)
{
tenantList.Add(tenant);
}
tenants = tenants.Distinct().ToList();
tenantList = tenantList.Distinct().ToList();
// Map the tenant entities to TenantListVM view models // Map the tenant entities to TenantListVM view models
var response = _mapper.Map<List<TenantListVM>>(tenants); var response = _mapper.Map<List<TenantListVM>>(tenantList);
_logger.LogInfo("Fetched {Count} tenants for OrganizationId: {OrganizationId}", tenants.Count, organizationId); _logger.LogInfo("Fetched {Count} tenants for OrganizationId: {OrganizationId}", tenantList.Count, organizationId);
_logger.LogDebug("GetTenantAsync method completed successfully."); _logger.LogDebug("GetTenantAsync method completed successfully.");
return Ok(ApiResponse<object>.SuccessResponse(response, "Successfully fetched the list of tenant", 200)); return Ok(ApiResponse<object>.SuccessResponse(response, "Successfully fetched the list of tenant", 200));

View File

@ -10,7 +10,6 @@ using Marco.Pms.Model.MongoDBModels.Expenses;
using Marco.Pms.Model.MongoDBModels.Masters; using Marco.Pms.Model.MongoDBModels.Masters;
using Marco.Pms.Model.MongoDBModels.Project; using Marco.Pms.Model.MongoDBModels.Project;
using Marco.Pms.Model.MongoDBModels.Utility; using Marco.Pms.Model.MongoDBModels.Utility;
using Marco.Pms.Model.OrganizationModel;
using Marco.Pms.Model.Projects; using Marco.Pms.Model.Projects;
using Marco.Pms.Model.Utilities; using Marco.Pms.Model.Utilities;
using MarcoBMS.Services.Service; using MarcoBMS.Services.Service;
@ -70,39 +69,6 @@ namespace Marco.Pms.Services.Helpers
.Where(s => s.Id == project.ProjectStatusId) .Where(s => s.Id == project.ProjectStatusId)
.Select(s => new { s.Id, s.Status }) // Projection .Select(s => new { s.Id, s.Status }) // Projection
.FirstOrDefaultAsync(); .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 () => var teamSizeTask = Task.Run(async () =>
@ -154,15 +120,12 @@ namespace Marco.Pms.Services.Helpers
}); });
// Wait for all parallel database operations to complete. // Wait for all parallel database operations to complete.
await Task.WhenAll(statusTask, teamSizeTask, infrastructureTask, promotorTask, pmcTask); await Task.WhenAll(statusTask, teamSizeTask, infrastructureTask);
// Get the results from the completed tasks. // Get the results from the completed tasks.
var status = await statusTask;
var status = statusTask.Result; var teamSize = await teamSizeTask;
var teamSize = teamSizeTask.Result; var (allBuildings, allFloors, allWorkAreas, workSummariesByWorkAreaId) = await infrastructureTask;
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 --- // --- Step 2: Process the fetched data and build the MongoDB model ---
@ -253,8 +216,6 @@ namespace Marco.Pms.Services.Helpers
projectDetails.Buildings = buildingMongoList; projectDetails.Buildings = buildingMongoList;
projectDetails.PlannedWork = totalPlannedWork; projectDetails.PlannedWork = totalPlannedWork;
projectDetails.CompletedWork = totalCompletedWork; projectDetails.CompletedWork = totalCompletedWork;
projectDetails.Promoter = promotor;
projectDetails.PMC = pmc;
try try
{ {
@ -273,8 +234,6 @@ namespace Marco.Pms.Services.Helpers
return; // Nothing to do return; // Nothing to do
} }
var projectStatusIds = projects.Select(p => p.ProjectStatusId).Distinct().ToList(); 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 --- // --- Step 1: Fetch all required data in maximum parallel ---
// Each task uses its own DbContext and selects only the required columns (projection). // Each task uses its own DbContext and selects only the required columns (projection).
@ -289,22 +248,6 @@ namespace Marco.Pms.Services.Helpers
.ToDictionaryAsync(s => s.Id); .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 () => var teamSizeTask = Task.Run(async () =>
{ {
using var context = _dbContextFactory.CreateDbContext(); using var context = _dbContextFactory.CreateDbContext();
@ -379,22 +322,20 @@ namespace Marco.Pms.Services.Helpers
}); });
// Await the remaining parallel tasks. // Await the remaining parallel tasks.
await Task.WhenAll(statusTask, teamSizeTask, workAreasTask, workSummaryTask, organizationTask); await Task.WhenAll(statusTask, teamSizeTask, workAreasTask, workSummaryTask);
// --- Step 2: Process the fetched data and build the MongoDB models --- // --- Step 2: Process the fetched data and build the MongoDB models ---
var allStatuses = statusTask.Result; var allStatuses = await statusTask;
var teamSizesByProjectId = teamSizeTask.Result; var teamSizesByProjectId = await teamSizeTask;
var allWorkAreas = workAreasTask.Result; var allWorkAreas = await workAreasTask;
var workSummariesByWorkAreaId = workSummaryTask.Result; var workSummariesByWorkAreaId = await workSummaryTask;
var organizations = organizationTask.Result;
// Create fast in-memory lookups for hierarchical data // Create fast in-memory lookups for hierarchical data
var buildingsByProjectId = allBuildings.ToLookup(b => b.ProjectId); var buildingsByProjectId = allBuildings.ToLookup(b => b.ProjectId);
var floorsByBuildingId = allFloors.ToLookup(f => f.BuildingId); var floorsByBuildingId = allFloors.ToLookup(f => f.BuildingId);
var workAreasByFloorId = allWorkAreas.ToLookup(wa => wa.FloorId); var workAreasByFloorId = allWorkAreas.ToLookup(wa => wa.FloorId);
var projectDetailsList = new List<ProjectMongoDB>(projects.Count); var projectDetailsList = new List<ProjectMongoDB>(projects.Count);
foreach (var project in projects) foreach (var project in projects)
{ {
@ -486,8 +427,6 @@ namespace Marco.Pms.Services.Helpers
projectDetails.Buildings = buildingMongoList; projectDetails.Buildings = buildingMongoList;
projectDetails.PlannedWork = totalPlannedWork; projectDetails.PlannedWork = totalPlannedWork;
projectDetails.CompletedWork = totalCompletedWork; 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); projectDetailsList.Add(projectDetails);
} }
@ -504,32 +443,11 @@ namespace Marco.Pms.Services.Helpers
} }
public async Task<bool> UpdateProjectDetailsOnly(Project project) public async Task<bool> UpdateProjectDetailsOnly(Project project)
{ {
var projectStatusTask = Task.Run(async () => StatusMaster projectStatus = await _context.StatusMasters
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.StatusMasters
.FirstOrDefaultAsync(s => s.Id == project.ProjectStatusId) ?? new StatusMaster(); .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 try
{ {
bool response = await _projectCache.UpdateProjectDetailsOnlyToCache(project, projectStatus, promotor, pmc); bool response = await _projectCache.UpdateProjectDetailsOnlyToCache(project, projectStatus);
return response; return response;
} }
catch (Exception ex) catch (Exception ex)

View File

@ -351,110 +351,63 @@ namespace Marco.Pms.Services.Service
#region =================================================================== Project Manage APIs =================================================================== #region =================================================================== Project Manage APIs ===================================================================
public async Task<ApiResponse<object>> CreateProjectAsync(CreateProjectDto model, Guid tenantId, Employee loggedInEmployee) public async Task<ApiResponse<object>> CreateProjectAsync(CreateProjectDto projectDto, Guid tenantId, Employee loggedInEmployee)
{ {
// Begin a new scope for service resolution.
using var scope = _serviceScopeFactory.CreateScope(); using var scope = _serviceScopeFactory.CreateScope();
var _firebase = scope.ServiceProvider.GetRequiredService<IFirebaseService>(); var _firebase = scope.ServiceProvider.GetRequiredService<IFirebaseService>();
// Step 1: Validate tenant access for the current employee. // 1. Prepare data without I/O
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<object>.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<object>.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<object>.ErrorResponse("PMC not found", "PMC not found in database.", 404);
}
// Step 3: Prepare the project entity.
var loggedInUserId = loggedInEmployee.Id; var loggedInUserId = loggedInEmployee.Id;
var project = _mapper.Map<Project>(model); var project = _mapper.Map<Project>(projectDto);
project.TenantId = tenantId; project.TenantId = tenantId;
// Step 4: Save the new project to the database. // 2. Store it to database
try try
{ {
_context.Projects.Add(project); _context.Projects.Add(project);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
_logger.LogInfo("Project {ProjectId} created successfully for TenantId={TenantId}, by Employee {EmployeeId}.",
project.Id, tenantId, loggedInUserId);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "DB Failure: Project creation failed for TenantId={TenantId}. Rolling back.", tenantId); // 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
return ApiResponse<object>.ErrorResponse("An error occurred while saving the project.", ex.Message, 500); return ApiResponse<object>.ErrorResponse("An error occurred while saving the project.", ex.Message, 500);
} }
// Step 5: Perform non-critical post-save side effects (e.g., caching) in parallel. // 3. Perform non-critical side-effects (caching, notifications) concurrently
try try
{ {
var cacheAddDetailsTask = _cache.AddProjectDetails(project); // These operations do not depend on each other, so they can run in parallel.
var cacheClearListTask = _cache.ClearAllProjectIdsByPermissionId(PermissionsMaster.ManageProject, tenantId); Task cacheAddDetailsTask = _cache.AddProjectDetails(project);
Task cacheClearListTask = _cache.ClearAllProjectIdsByPermissionId(PermissionsMaster.ManageProject, tenantId);
// Await all side-effect tasks to complete in parallel
await Task.WhenAll(cacheAddDetailsTask, cacheClearListTask); await Task.WhenAll(cacheAddDetailsTask, cacheClearListTask);
_logger.LogInfo("Cache updated for ProjectId={ProjectId} after creation.", project.Id);
} }
catch (Exception ex) catch (Exception ex)
{ {
// Log the issue but do not interrupt the user flow. // The project was created successfully, but a side-effect failed.
_logger.LogError(ex, "Post-save cache operation failed for ProjectId={ProjectId}.", project.Id); // 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);
} }
// Step 6: Fire-and-forget push notification for post-creation event. Designed to be non-blocking.
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try // --- Push Notification Section ---
{ // This section attempts to send a test push notification to the user's device.
var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens.
await _firebase.SendModifyProjectMessageAsync(project, name, false, tenantId);
_logger.LogInfo("Push notification sent for ProjectId={ProjectId} ({Name}).", project.Id, name); var name = $"{loggedInEmployee.FirstName} {loggedInEmployee.LastName}";
}
catch (Exception ex) await _firebase.SendModifyProjectMessageAsync(project, name, false, tenantId);
{
_logger.LogError(ex, "Push notification sending failed for ProjectId={ProjectId}.", project.Id);
}
}); });
// Step 7: Return success response as soon as critical operation is complete. // 4. Return a success response to the user as soon as the critical data is saved.
var resultDto = _mapper.Map<ProjectDto>(project); return ApiResponse<object>.SuccessResponse(_mapper.Map<ProjectDto>(project), "Project created successfully.", 200);
_logger.LogInfo("Returning success response for ProjectId={ProjectId}.", project.Id);
return ApiResponse<object>.SuccessResponse(resultDto, "Project created successfully.", 200);
} }
/// <summary> /// <summary>
/// Updates an existing project's details. /// Updates an existing project's details.
/// This endpoint is secure, handles concurrency, and performs non-essential tasks in the background. /// This endpoint is secure, handles concurrency, and performs non-essential tasks in the background.
@ -462,7 +415,7 @@ namespace Marco.Pms.Services.Service
/// <param name="id">The ID of the project to update.</param> /// <param name="id">The ID of the project to update.</param>
/// <param name="updateProjectDto">The data to update the project with.</param> /// <param name="updateProjectDto">The data to update the project with.</param>
/// <returns>An ApiResponse confirming the update or an appropriate error.</returns> /// <returns>An ApiResponse confirming the update or an appropriate error.</returns>
public async Task<ApiResponse<object>> UpdateProjectAsync(Guid id, UpdateProjectDto model, Guid tenantId, Employee loggedInEmployee) public async Task<ApiResponse<object>> UpdateProjectAsync(Guid id, UpdateProjectDto updateProjectDto, Guid tenantId, Employee loggedInEmployee)
{ {
using var scope = _serviceScopeFactory.CreateScope(); using var scope = _serviceScopeFactory.CreateScope();
var _firebase = scope.ServiceProvider.GetRequiredService<IFirebaseService>(); var _firebase = scope.ServiceProvider.GetRequiredService<IFirebaseService>();
@ -483,46 +436,7 @@ namespace Marco.Pms.Services.Service
return ApiResponse<object>.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404); return ApiResponse<object>.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404);
} }
var tenant = await _context.Tenants // 1b. Security Check
.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<object>.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<object>.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<object>.ErrorResponse("PMC not found", "PMC not found in database.", 404);
}
// 1c. Security Check
var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, id); var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, id);
if (!hasPermission) if (!hasPermission)
{ {
@ -533,7 +447,7 @@ namespace Marco.Pms.Services.Service
// --- Step 2: Apply Changes and Save --- // --- Step 2: Apply Changes and Save ---
// Map the changes from the DTO onto the entity we just fetched from the database. // 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. // This only modifies the properties defined in the mapping, preventing data loss.
_mapper.Map(model, existingProject); _mapper.Map(updateProjectDto, existingProject);
// Mark the entity as modified (if your mapping doesn't do it automatically). // Mark the entity as modified (if your mapping doesn't do it automatically).
_context.Entry(existingProject).State = EntityState.Modified; _context.Entry(existingProject).State = EntityState.Modified;