Organization_Management #142

Merged
ashutosh.nehete merged 92 commits from Organization_Management into main 2025-09-30 09:05:14 +00:00
9 changed files with 273 additions and 67 deletions
Showing only changes of commit 2258771229 - Show all commits

View File

@ -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<ProjectMongoDB>(indexKeys, indexOptions);
await _projectCollection.Indexes.CreateOneAsync(indexModel);
}
public async Task<bool> UpdateProjectDetailsOnlyToCache(Project project, StatusMaster projectStatus)
public async Task<bool> UpdateProjectDetailsOnlyToCache(Project project, StatusMaster projectStatus, Organization promotor, Organization pmc)
{
// Build the update definition
var updates = Builders<ProjectMongoDB>.Update.Combine(
@ -67,6 +69,20 @@ namespace Marco.Pms.Helpers
Id = projectStatus.Id.ToString(),
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.EndDate, project.EndDate),
Builders<ProjectMongoDB>.Update.Set(r => r.ContactPerson, project.ContactPerson)

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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;
}
}

View File

@ -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; }

View File

@ -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; }
}

View File

@ -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<List<TenantListVM>>(tenantList);
var response = _mapper.Map<List<TenantListVM>>(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<object>.SuccessResponse(response, "Successfully fetched the list of tenant", 200));

View File

@ -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<ProjectMongoDB>(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<bool> 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)

View File

@ -351,63 +351,110 @@ namespace Marco.Pms.Services.Service
#region =================================================================== Project Manage APIs ===================================================================
public async Task<ApiResponse<object>> CreateProjectAsync(CreateProjectDto projectDto, Guid tenantId, Employee loggedInEmployee)
public async Task<ApiResponse<object>> CreateProjectAsync(CreateProjectDto model, Guid tenantId, Employee loggedInEmployee)
{
// Begin a new scope for service resolution.
using var scope = _serviceScopeFactory.CreateScope();
var _firebase = scope.ServiceProvider.GetRequiredService<IFirebaseService>();
// 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<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 project = _mapper.Map<Project>(projectDto);
var project = _mapper.Map<Project>(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<object>.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<object>.SuccessResponse(_mapper.Map<ProjectDto>(project), "Project created successfully.", 200);
// Step 7: Return success response as soon as critical operation is complete.
var resultDto = _mapper.Map<ProjectDto>(project);
_logger.LogInfo("Returning success response for ProjectId={ProjectId}.", project.Id);
return ApiResponse<object>.SuccessResponse(resultDto, "Project created successfully.", 200);
}
/// <summary>
/// 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
/// <param name="id">The ID of the project to update.</param>
/// <param name="updateProjectDto">The data to update the project with.</param>
/// <returns>An ApiResponse confirming the update or an appropriate error.</returns>
public async Task<ApiResponse<object>> UpdateProjectAsync(Guid id, UpdateProjectDto updateProjectDto, Guid tenantId, Employee loggedInEmployee)
public async Task<ApiResponse<object>> UpdateProjectAsync(Guid id, UpdateProjectDto model, Guid tenantId, Employee loggedInEmployee)
{
using var scope = _serviceScopeFactory.CreateScope();
var _firebase = scope.ServiceProvider.GetRequiredService<IFirebaseService>();
@ -436,7 +483,46 @@ namespace Marco.Pms.Services.Service
return ApiResponse<object>.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<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);
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;