diff --git a/.gitignore b/.gitignore index 9491a2f..a6a47c3 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,7 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +# Sonar +/.sonarqube \ No newline at end of file diff --git a/Marco.Pms.CacheHelper/EmployeeCache.cs b/Marco.Pms.CacheHelper/EmployeeCache.cs index c2a1f7b..7c7f4b4 100644 --- a/Marco.Pms.CacheHelper/EmployeeCache.cs +++ b/Marco.Pms.CacheHelper/EmployeeCache.cs @@ -1,5 +1,4 @@ -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.MongoDBModels; +using Marco.Pms.Model.MongoDBModels; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using MongoDB.Driver; @@ -8,41 +7,21 @@ namespace Marco.Pms.CacheHelper { public class EmployeeCache { - private readonly ApplicationDbContext _context; - //private readonly IMongoDatabase _mongoDB; private readonly IMongoCollection _collection; - public EmployeeCache(ApplicationDbContext context, IConfiguration configuration) + public EmployeeCache(IConfiguration configuration) { var connectionString = configuration["MongoDB:ConnectionString"]; - _context = context; var mongoUrl = new MongoUrl(connectionString); var client = new MongoClient(mongoUrl); // Your MongoDB connection string var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name _collection = mongoDB.GetCollection("EmployeeProfile"); } - public async Task AddApplicationRoleToCache(Guid employeeId, List roleIds) + public async Task AddApplicationRoleToCache(Guid employeeId, List newRoleIds, List newPermissionIds) { - // 1. Guard Clause: Avoid unnecessary database work if there are no roles to add. - if (roleIds == null || !roleIds.Any()) - { - return false; // Nothing to add, so the operation did not result in a change. - } // 2. Perform database queries concurrently for better performance. var employeeIdString = employeeId.ToString(); - Task> getPermissionIdsTask = _context.RolePermissionMappings - .Where(rp => roleIds.Contains(rp.ApplicationRoleId)) - .Select(p => p.FeaturePermissionId.ToString()) - .Distinct() - .ToListAsync(); - - // 3. Prepare role IDs in parallel with the database query. - var newRoleIds = roleIds.Select(r => r.ToString()).ToList(); - - // 4. Await the database query result. - var newPermissionIds = await getPermissionIdsTask; - // 5. Build a single, efficient update operation. var filter = Builders.Filter.Eq(e => e.Id, employeeIdString); @@ -54,6 +33,8 @@ namespace Marco.Pms.CacheHelper var result = await _collection.UpdateOneAsync(filter, update, options); + await InitializeCollectionAsync(); + // 6. Return a more accurate result indicating success for both updates and upserts. // The operation is successful if an existing document was modified OR a new one was created. return result.IsAcknowledged && (result.ModifiedCount > 0 || result.UpsertedId != null); @@ -72,6 +53,7 @@ namespace Marco.Pms.CacheHelper { return false; } + await InitializeCollectionAsync(); return true; } public async Task> GetProjectsFromCache(Guid employeeId) @@ -118,7 +100,7 @@ namespace Marco.Pms.CacheHelper var result = await _collection.UpdateOneAsync(filter, update); - if (result.MatchedCount == 0) + if (result.ModifiedCount == 0) return false; return true; @@ -140,16 +122,10 @@ namespace Marco.Pms.CacheHelper public async Task ClearAllProjectIdsByPermissionIdFromCache(Guid permissionId) { var filter = Builders.Filter.AnyEq(e => e.PermissionIds, permissionId.ToString()); + var update = Builders.Update.Set(e => e.ProjectIds, new List()); - var update = Builders.Update - .Set(e => e.ProjectIds, new List()); - - var result = await _collection.UpdateOneAsync(filter, update); - - if (result.MatchedCount == 0) - return false; - - return true; + var result = await _collection.UpdateManyAsync(filter, update).ConfigureAwait(false); + return result.IsAcknowledged && result.ModifiedCount > 0; } public async Task RemoveRoleIdFromCache(Guid employeeId, Guid roleId) { @@ -198,5 +174,31 @@ namespace Marco.Pms.CacheHelper return true; } + public async Task ClearAllEmployeesFromCache() + { + var result = await _collection.DeleteManyAsync(FilterDefinition.Empty); + + if (result.DeletedCount == 0) + return false; + + return true; + } + + // A private method to handle the one-time setup of the collection's indexes. + private async Task InitializeCollectionAsync() + { + // 1. Define the TTL (Time-To-Live) index on the 'ExpireAt' field. + var indexKeys = Builders.IndexKeys.Ascending(x => x.ExpireAt); + var indexOptions = new CreateIndexOptions + { + // This tells MongoDB to automatically delete documents when their 'ExpireAt' time is reached. + ExpireAfter = TimeSpan.FromSeconds(0) + }; + var indexModel = new CreateIndexModel(indexKeys, indexOptions); + + // 2. Create the index. This is an idempotent operation if the index already exists. + // Use CreateOneAsync since we are only creating a single index. + await _collection.Indexes.CreateOneAsync(indexModel); + } } } diff --git a/Marco.Pms.CacheHelper/ProjectCache.cs b/Marco.Pms.CacheHelper/ProjectCache.cs index 9b2036d..10eb623 100644 --- a/Marco.Pms.CacheHelper/ProjectCache.cs +++ b/Marco.Pms.CacheHelper/ProjectCache.cs @@ -11,159 +11,62 @@ namespace Marco.Pms.CacheHelper { public class ProjectCache { - private readonly ApplicationDbContext _context; - private readonly IMongoCollection _projetCollection; + private readonly IMongoCollection _projectCollection; private readonly IMongoCollection _taskCollection; public ProjectCache(ApplicationDbContext context, IConfiguration configuration) { var connectionString = configuration["MongoDB:ConnectionString"]; - _context = context; var mongoUrl = new MongoUrl(connectionString); var client = new MongoClient(mongoUrl); // Your MongoDB connection string var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name - _projetCollection = mongoDB.GetCollection("ProjectDetails"); + _projectCollection = mongoDB.GetCollection("ProjectDetails"); _taskCollection = mongoDB.GetCollection("WorkItemDetails"); } - public async Task AddProjectDetailsToCache(Project project) + + #region=================================================================== Project Cache Helper =================================================================== + + public async Task AddProjectDetailsToCache(ProjectMongoDB projectDetails) { - //_logger.LogInfo("[AddProjectDetails] Initiated for ProjectId: {ProjectId}", project.Id); + await _projectCollection.InsertOneAsync(projectDetails); - var projectDetails = new ProjectMongoDB + var indexKeys = Builders.IndexKeys.Ascending(x => x.ExpireAt); + var indexOptions = new CreateIndexOptions { - Id = project.Id.ToString(), - Name = project.Name, - ShortName = project.ShortName, - ProjectAddress = project.ProjectAddress, - StartDate = project.StartDate, - EndDate = project.EndDate, - ContactPerson = project.ContactPerson + ExpireAfter = TimeSpan.Zero // required for fixed expiration time }; + var indexModel = new CreateIndexModel(indexKeys, indexOptions); + await _projectCollection.Indexes.CreateOneAsync(indexModel); - // Get project status - var status = await _context.StatusMasters - .AsNoTracking() - .FirstOrDefaultAsync(s => s.Id == project.ProjectStatusId); - - projectDetails.ProjectStatus = new StatusMasterMongoDB - { - Id = status?.Id.ToString(), - Status = status?.Status - }; - - // Get project team size - var teamSize = await _context.ProjectAllocations - .AsNoTracking() - .CountAsync(pa => pa.ProjectId == project.Id && pa.IsActive); - - projectDetails.TeamSize = teamSize; - - // Fetch related infrastructure in parallel - var buildings = await _context.Buildings - .AsNoTracking() - .Where(b => b.ProjectId == project.Id) - .ToListAsync(); - var buildingIds = buildings.Select(b => b.Id).ToList(); - - var floors = await _context.Floor - .AsNoTracking() - .Where(f => buildingIds.Contains(f.BuildingId)) - .ToListAsync(); - - var floorIds = floors.Select(f => f.Id).ToList(); - - var workAreas = await _context.WorkAreas - .AsNoTracking() - .Where(wa => floorIds.Contains(wa.FloorId)) - .ToListAsync(); - var workAreaIds = workAreas.Select(wa => wa.Id).ToList(); - - var workItems = await _context.WorkItems - .Where(wi => workAreaIds.Contains(wi.WorkAreaId)) - .ToListAsync(); - - double totalPlannedWork = 0, totalCompletedWork = 0; - - var buildingMongoList = new List(); - - foreach (var building in buildings) - { - double buildingPlanned = 0, buildingCompleted = 0; - var buildingFloors = floors.Where(f => f.BuildingId == building.Id).ToList(); - - var floorMongoList = new List(); - foreach (var floor in buildingFloors) - { - double floorPlanned = 0, floorCompleted = 0; - var floorWorkAreas = workAreas.Where(wa => wa.FloorId == floor.Id).ToList(); - - var workAreaMongoList = new List(); - foreach (var wa in floorWorkAreas) - { - var items = workItems.Where(wi => wi.WorkAreaId == wa.Id).ToList(); - double waPlanned = items.Sum(wi => wi.PlannedWork); - double waCompleted = items.Sum(wi => wi.CompletedWork); - - workAreaMongoList.Add(new WorkAreaMongoDB - { - Id = wa.Id.ToString(), - FloorId = wa.FloorId.ToString(), - AreaName = wa.AreaName, - PlannedWork = waPlanned, - CompletedWork = waCompleted - }); - - floorPlanned += waPlanned; - floorCompleted += waCompleted; - } - - floorMongoList.Add(new FloorMongoDB - { - Id = floor.Id.ToString(), - BuildingId = floor.BuildingId.ToString(), - FloorName = floor.FloorName, - PlannedWork = floorPlanned, - CompletedWork = floorCompleted, - WorkAreas = workAreaMongoList - }); - - buildingPlanned += floorPlanned; - buildingCompleted += floorCompleted; - } - - buildingMongoList.Add(new BuildingMongoDB - { - Id = building.Id.ToString(), - ProjectId = building.ProjectId.ToString(), - BuildingName = building.Name, - Description = building.Description, - PlannedWork = buildingPlanned, - CompletedWork = buildingCompleted, - Floors = floorMongoList - }); - - totalPlannedWork += buildingPlanned; - totalCompletedWork += buildingCompleted; - } - - projectDetails.Buildings = buildingMongoList; - projectDetails.PlannedWork = totalPlannedWork; - projectDetails.CompletedWork = totalCompletedWork; - - await _projetCollection.InsertOneAsync(projectDetails); - //_logger.LogInfo("[AddProjectDetails] Project details inserted in MongoDB for ProjectId: {ProjectId}", project.Id); } - public async Task UpdateProjectDetailsOnlyToCache(Project project) + public async Task AddProjectDetailsListToCache(List projectDetailsList) { - //_logger.LogInfo("Starting update for project: {ProjectId}", project.Id); - - var projectStatus = await _context.StatusMasters - .FirstOrDefaultAsync(s => s.Id == project.ProjectStatusId); - - if (projectStatus == null) + // 1. Add a guard clause to avoid an unnecessary database call for an empty list. + if (projectDetailsList == null || !projectDetailsList.Any()) { - //_logger.LogWarning("StatusMaster not found for ProjectStatusId: {StatusId}", project.ProjectStatusId); + return; } + // 2. Perform the insert operation. This is the only responsibility of this method. + await _projectCollection.InsertManyAsync(projectDetailsList); + await InitializeCollectionAsync(); + } + private async Task InitializeCollectionAsync() + { + // 1. Define the TTL (Time-To-Live) index on the 'ExpireAt' field. + var indexKeys = Builders.IndexKeys.Ascending(x => x.ExpireAt); + var indexOptions = new CreateIndexOptions + { + // This tells MongoDB to automatically delete documents when their 'ExpireAt' time is reached. + ExpireAfter = TimeSpan.FromSeconds(0) + }; + var indexModel = new CreateIndexModel(indexKeys, indexOptions); + + // 2. Create the index. This is an idempotent operation if the index already exists. + // Use CreateOneAsync since we are only creating a single index. + await _projectCollection.Indexes.CreateOneAsync(indexModel); + } + public async Task UpdateProjectDetailsOnlyToCache(Project project, StatusMaster projectStatus) + { // Build the update definition var updates = Builders.Update.Combine( Builders.Update.Set(r => r.Name, project.Name), @@ -171,8 +74,8 @@ namespace Marco.Pms.CacheHelper Builders.Update.Set(r => r.ShortName, project.ShortName), Builders.Update.Set(r => r.ProjectStatus, new StatusMasterMongoDB { - Id = projectStatus?.Id.ToString(), - Status = projectStatus?.Status + Id = projectStatus.Id.ToString(), + Status = projectStatus.Status }), Builders.Update.Set(r => r.StartDate, project.StartDate), Builders.Update.Set(r => r.EndDate, project.EndDate), @@ -180,18 +83,16 @@ namespace Marco.Pms.CacheHelper ); // Perform the update - var result = await _projetCollection.UpdateOneAsync( + var result = await _projectCollection.UpdateOneAsync( filter: r => r.Id == project.Id.ToString(), update: updates ); if (result.MatchedCount == 0) { - //_logger.LogWarning("No project matched in MongoDB for update. ProjectId: {ProjectId}", project.Id); return false; } - //_logger.LogInfo("Project {ProjectId} successfully updated in MongoDB", project.Id); return true; } public async Task GetProjectDetailsFromCache(Guid projectId) @@ -201,34 +102,56 @@ namespace Marco.Pms.CacheHelper var filter = Builders.Filter.Eq(p => p.Id, projectId.ToString()); var projection = Builders.Projection.Exclude(p => p.Buildings); - //_logger.LogInfo("Fetching project details for ProjectId: {ProjectId} from MongoDB", projectId); - // Perform query - var project = await _projetCollection + var project = await _projectCollection .Find(filter) .Project(projection) .FirstOrDefaultAsync(); - if (project == null) - { - //_logger.LogWarning("No project found in MongoDB for ProjectId: {ProjectId}", projectId); - return null; - } - - //_logger.LogInfo("Successfully fetched project details (excluding Buildings) for ProjectId: {ProjectId}", projectId); return project; } - public async Task?> GetProjectDetailsListFromCache(List projectIds) + public async Task GetProjectDetailsWithBuildingsFromCache(Guid projectId) + { + + // Build filter and projection to exclude large 'Buildings' list + var filter = Builders.Filter.Eq(p => p.Id, projectId.ToString()); + + // Perform query + var project = await _projectCollection + .Find(filter) + .FirstOrDefaultAsync(); + + return project; + } + public async Task> GetProjectDetailsListFromCache(List projectIds) { List stringProjectIds = projectIds.Select(p => p.ToString()).ToList(); var filter = Builders.Filter.In(p => p.Id, stringProjectIds); var projection = Builders.Projection.Exclude(p => p.Buildings); - var projects = await _projetCollection + var projects = await _projectCollection .Find(filter) .Project(projection) .ToListAsync(); return projects; } + public async Task DeleteProjectByIdFromCacheAsync(Guid projectId) + { + var filter = Builders.Filter.Eq(e => e.Id, projectId.ToString()); + var result = await _projectCollection.DeleteOneAsync(filter); + return result.DeletedCount > 0; + } + public async Task RemoveProjectsFromCacheAsync(List projectIds) + { + var stringIds = projectIds.Select(id => id.ToString()).ToList(); + var filter = Builders.Filter.In(p => p.Id, stringIds); + var result = await _projectCollection.DeleteManyAsync(filter); + return result.DeletedCount > 0; + } + + #endregion + + #region=================================================================== Project infrastructure Cache Helper =================================================================== + public async Task AddBuildngInfraToCache(Guid projectId, Building? building, Floor? floor, WorkArea? workArea, Guid? buildingId) { var stringProjectId = projectId.ToString(); @@ -249,15 +172,12 @@ namespace Marco.Pms.CacheHelper var filter = Builders.Filter.Eq(p => p.Id, stringProjectId); var update = Builders.Update.Push("Buildings", buildingMongo); - var result = await _projetCollection.UpdateOneAsync(filter, update); + var result = await _projectCollection.UpdateOneAsync(filter, update); if (result.MatchedCount == 0) { - //_logger.LogWarning("Project not found while adding building. ProjectId: {ProjectId}", projectId); return; } - - //_logger.LogInfo("Building {BuildingId} added to project {ProjectId}", building.Id, projectId); return; } @@ -279,15 +199,12 @@ namespace Marco.Pms.CacheHelper ); var update = Builders.Update.Push("Buildings.$.Floors", floorMongo); - var result = await _projetCollection.UpdateOneAsync(filter, update); + var result = await _projectCollection.UpdateOneAsync(filter, update); if (result.MatchedCount == 0) { - //_logger.LogWarning("Project or building not found while adding floor. ProjectId: {ProjectId}, BuildingId: {BuildingId}", projectId, floor.BuildingId); return; } - - //_logger.LogInfo("Floor {FloorId} added to building {BuildingId} in project {ProjectId}", floor.Id, floor.BuildingId, projectId); return; } @@ -313,20 +230,14 @@ namespace Marco.Pms.CacheHelper var update = Builders.Update.Push("Buildings.$[b].Floors.$[f].WorkAreas", workAreaMongo); var updateOptions = new UpdateOptions { ArrayFilters = arrayFilters }; - var result = await _projetCollection.UpdateOneAsync(filter, update, updateOptions); + var result = await _projectCollection.UpdateOneAsync(filter, update, updateOptions); if (result.MatchedCount == 0) { - //_logger.LogWarning("Project or nested structure not found while adding work area. ProjectId: {ProjectId}, BuildingId: {BuildingId}, FloorId: {FloorId}", projectId, buildingId, workArea.FloorId); return; } - - //_logger.LogInfo("WorkArea {WorkAreaId} added to floor {FloorId} in building {BuildingId}, ProjectId: {ProjectId}", workArea.Id, workArea.FloorId, buildingId, projectId); return; } - - // Fallback case when no valid data was passed - //_logger.LogWarning("No valid infra data provided to add for ProjectId: {ProjectId}", projectId); } public async Task UpdateBuildngInfraToCache(Guid projectId, Building? building, Floor? floor, WorkArea? workArea, Guid? buildingId) { @@ -345,15 +256,13 @@ namespace Marco.Pms.CacheHelper Builders.Update.Set("Buildings.$.Description", building.Description) ); - var result = await _projetCollection.UpdateOneAsync(filter, update); + var result = await _projectCollection.UpdateOneAsync(filter, update); if (result.MatchedCount == 0) { - //_logger.LogWarning("Update failed: Project or Building not found. ProjectId: {ProjectId}, BuildingId: {BuildingId}", projectId, building.Id); return false; } - //_logger.LogInfo("Building {BuildingId} updated successfully in project {ProjectId}", building.Id, projectId); return true; } @@ -370,15 +279,12 @@ namespace Marco.Pms.CacheHelper var updateOptions = new UpdateOptions { ArrayFilters = arrayFilters }; var filter = Builders.Filter.Eq(p => p.Id, stringProjectId); - var result = await _projetCollection.UpdateOneAsync(filter, update, updateOptions); + var result = await _projectCollection.UpdateOneAsync(filter, update, updateOptions); if (result.MatchedCount == 0) { - //_logger.LogWarning("Update failed: Project or Floor not found. ProjectId: {ProjectId}, BuildingId: {BuildingId}, FloorId: {FloorId}", projectId, floor.BuildingId, floor.Id); return false; } - - //_logger.LogInfo("Floor {FloorId} updated successfully in Building {BuildingId}, ProjectId: {ProjectId}", floor.Id, floor.BuildingId, projectId); return true; } @@ -396,21 +302,14 @@ namespace Marco.Pms.CacheHelper var updateOptions = new UpdateOptions { ArrayFilters = arrayFilters }; var filter = Builders.Filter.Eq(p => p.Id, stringProjectId); - var result = await _projetCollection.UpdateOneAsync(filter, update, updateOptions); + var result = await _projectCollection.UpdateOneAsync(filter, update, updateOptions); if (result.MatchedCount == 0) { - //_logger.LogWarning("Update failed: Project or WorkArea not found. ProjectId: {ProjectId}, BuildingId: {BuildingId}, FloorId: {FloorId}, WorkAreaId: {WorkAreaId}", - //projectId, buildingId, workArea.FloorId, workArea.Id); return false; } - - //_logger.LogInfo("WorkArea {WorkAreaId} updated successfully in Floor {FloorId}, Building {BuildingId}, ProjectId: {ProjectId}", - //workArea.Id, workArea.FloorId, buildingId, projectId); return true; } - - //_logger.LogWarning("No update performed. Missing or invalid data for ProjectId: {ProjectId}", projectId); return false; } public async Task?> GetBuildingInfraFromCache(Guid projectId) @@ -420,26 +319,17 @@ namespace Marco.Pms.CacheHelper var filter = Builders.Filter.Eq(p => p.Id, projectId.ToString()); // Project only the "Buildings" field from the document - var buildings = await _projetCollection + var buildings = await _projectCollection .Find(filter) .Project(p => p.Buildings) .FirstOrDefaultAsync(); - //if (buildings == null) - //{ - // _logger.LogWarning("No building infrastructure found for ProjectId: {ProjectId}", projectId); - //} - //else - //{ - // _logger.LogInfo("Fetched {Count} buildings for ProjectId: {ProjectId}", buildings.Count, projectId); - //} - return buildings; } public async Task UpdatePlannedAndCompleteWorksInBuildingFromCache(Guid workAreaId, double plannedWork, double completedWork) { var filter = Builders.Filter.Eq("Buildings.Floors.WorkAreas._id", workAreaId.ToString()); - var project = await _projetCollection.Find(filter).FirstOrDefaultAsync(); + var project = await _projectCollection.Find(filter).FirstOrDefaultAsync(); string? selectedBuildingId = null; string? selectedFloorId = null; @@ -477,7 +367,7 @@ namespace Marco.Pms.CacheHelper .Inc("Buildings.$[b].CompletedWork", completedWork) .Inc("PlannedWork", plannedWork) .Inc("CompletedWork", completedWork); - var result = await _projetCollection.UpdateOneAsync(filter, update, updateOptions); + var result = await _projectCollection.UpdateOneAsync(filter, update, updateOptions); } public async Task GetBuildingAndFloorByWorkAreaIdFromCache(Guid workAreaId) @@ -517,11 +407,16 @@ namespace Marco.Pms.CacheHelper { "WorkArea", "$Buildings.Floors.WorkAreas" } }) }; - var result = await _projetCollection.Aggregate(pipeline).FirstOrDefaultAsync(); + var result = await _projectCollection.Aggregate(pipeline).FirstOrDefaultAsync(); if (result == null) return null; return result; } + + #endregion + + #region=================================================================== WorkItem Cache Helper =================================================================== + public async Task> GetWorkItemsByWorkAreaIdsFromCache(List workAreaIds) { var stringWorkAreaIds = workAreaIds.Select(wa => wa.ToString()).ToList(); @@ -533,48 +428,22 @@ namespace Marco.Pms.CacheHelper return workItems; } - - // ------------------------------------------------------- WorkItem ------------------------------------------------------- - - public async Task ManageWorkItemDetailsToCache(List workItems) + public async Task ManageWorkItemDetailsToCache(List workItems) { - var activityIds = workItems.Select(wi => wi.ActivityId).ToList(); - var workCategoryIds = workItems.Select(wi => wi.WorkCategoryId).ToList(); - var workItemIds = workItems.Select(wi => wi.Id).ToList(); - // fetching Activity master - var activities = await _context.ActivityMasters.Where(a => activityIds.Contains(a.Id)).ToListAsync() ?? new List(); - - // Fetching Work Category - var workCategories = await _context.WorkCategoryMasters.Where(wc => workCategoryIds.Contains(wc.Id)).ToListAsync() ?? new List(); - var task = await _context.TaskAllocations.Where(t => workItemIds.Contains(t.WorkItemId) && t.AssignmentDate == DateTime.UtcNow).ToListAsync(); - var todaysAssign = task.Sum(t => t.PlannedTask); - foreach (WorkItem workItem in workItems) + foreach (WorkItemMongoDB workItem in workItems) { - var activity = activities.FirstOrDefault(a => a.Id == workItem.ActivityId) ?? new ActivityMaster(); - var workCategory = workCategories.FirstOrDefault(a => a.Id == workItem.WorkCategoryId) ?? new WorkCategoryMaster(); - var filter = Builders.Filter.Eq(p => p.Id, workItem.Id.ToString()); var updates = Builders.Update.Combine( Builders.Update.Set(r => r.WorkAreaId, workItem.WorkAreaId.ToString()), Builders.Update.Set(r => r.ParentTaskId, (workItem.ParentTaskId != null ? workItem.ParentTaskId.ToString() : null)), Builders.Update.Set(r => r.PlannedWork, workItem.PlannedWork), - Builders.Update.Set(r => r.TodaysAssigned, todaysAssign), + Builders.Update.Set(r => r.TodaysAssigned, workItem.TodaysAssigned), Builders.Update.Set(r => r.CompletedWork, workItem.CompletedWork), Builders.Update.Set(r => r.Description, workItem.Description), Builders.Update.Set(r => r.TaskDate, workItem.TaskDate), Builders.Update.Set(r => r.ExpireAt, DateTime.UtcNow.Date.AddDays(1)), - Builders.Update.Set(r => r.ActivityMaster, new ActivityMasterMongoDB - { - Id = activity.Id.ToString(), - ActivityName = activity.ActivityName, - UnitOfMeasurement = activity.UnitOfMeasurement - }), - Builders.Update.Set(r => r.WorkCategoryMaster, new WorkCategoryMasterMongoDB - { - Id = workCategory.Id.ToString(), - Name = workCategory.Name, - Description = workCategory.Description, - }) + Builders.Update.Set(r => r.ActivityMaster, workItem.ActivityMaster), + Builders.Update.Set(r => r.WorkCategoryMaster, workItem.WorkCategoryMaster) ); var options = new UpdateOptions { IsUpsert = true }; var result = await _taskCollection.UpdateOneAsync(filter, updates, options); @@ -625,5 +494,13 @@ namespace Marco.Pms.CacheHelper } return false; } + public async Task DeleteWorkItemByIdFromCacheAsync(Guid workItemId) + { + var filter = Builders.Filter.Eq(e => e.Id, workItemId.ToString()); + var result = await _taskCollection.DeleteOneAsync(filter); + return result.DeletedCount > 0; + } + + #endregion } } diff --git a/Marco.Pms.CacheHelper/ReportCache.cs b/Marco.Pms.CacheHelper/ReportCache.cs new file mode 100644 index 0000000..66611a8 --- /dev/null +++ b/Marco.Pms.CacheHelper/ReportCache.cs @@ -0,0 +1,42 @@ +using Marco.Pms.Model.MongoDBModels; +using Microsoft.Extensions.Configuration; +using MongoDB.Driver; + +namespace Marco.Pms.CacheHelper +{ + public class ReportCache + { + private readonly IMongoCollection _projectReportCollection; + public ReportCache(IConfiguration configuration) + { + var connectionString = configuration["MongoDB:ConnectionString"]; + var mongoUrl = new MongoUrl(connectionString); + var client = new MongoClient(mongoUrl); // Your MongoDB connection string + var mongoDB = client.GetDatabase(mongoUrl.DatabaseName); // Your MongoDB Database name + _projectReportCollection = mongoDB.GetCollection("ProjectReportMail"); + } + + /// + /// Retrieves project report emails from the cache based on their sent status. + /// + /// True to get sent reports, false to get unsent reports. + /// A list of ProjectReportEmailMongoDB objects. + public async Task> GetProjectReportMailFromCache(bool isSent) + { + var filter = Builders.Filter.Eq(p => p.IsSent, isSent); + var reports = await _projectReportCollection.Find(filter).ToListAsync(); + return reports; + } + + /// + /// Adds a project report email to the cache. + /// + /// The ProjectReportEmailMongoDB object to add. + /// A Task representing the asynchronous operation. + public async Task AddProjectReportMailToCache(ProjectReportEmailMongoDB report) + { + // Consider adding validation or logging here. + await _projectReportCollection.InsertOneAsync(report); + } + } +} diff --git a/Marco.Pms.Model/Dtos/Projects/BuildingDot.cs b/Marco.Pms.Model/Dtos/Projects/BuildingDto.cs similarity index 92% rename from Marco.Pms.Model/Dtos/Projects/BuildingDot.cs rename to Marco.Pms.Model/Dtos/Projects/BuildingDto.cs index a5b160b..e6a7b89 100644 --- a/Marco.Pms.Model/Dtos/Projects/BuildingDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/BuildingDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace Marco.Pms.Model.Dtos.Project { - public class BuildingDot + public class BuildingDto { [Key] public Guid? Id { get; set; } diff --git a/Marco.Pms.Model/Dtos/Projects/FloorDot.cs b/Marco.Pms.Model/Dtos/Projects/FloorDto.cs similarity index 92% rename from Marco.Pms.Model/Dtos/Projects/FloorDot.cs rename to Marco.Pms.Model/Dtos/Projects/FloorDto.cs index a3d1c86..3dbe06f 100644 --- a/Marco.Pms.Model/Dtos/Projects/FloorDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/FloorDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace Marco.Pms.Model.Dtos.Project { - public class FloorDot + public class FloorDto { public Guid? Id { get; set; } diff --git a/Marco.Pms.Model/Dtos/Projects/InfraDot.cs b/Marco.Pms.Model/Dtos/Projects/InfraDot.cs deleted file mode 100644 index 7c16c09..0000000 --- a/Marco.Pms.Model/Dtos/Projects/InfraDot.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Marco.Pms.Model.Dtos.Project -{ - public class InfraDot - { - public BuildingDot? Building { get; set; } - public FloorDot? Floor { get; set; } - public WorkAreaDot? WorkArea { get; set; } - } -} diff --git a/Marco.Pms.Model/Dtos/Projects/InfraDto.cs b/Marco.Pms.Model/Dtos/Projects/InfraDto.cs new file mode 100644 index 0000000..09d1462 --- /dev/null +++ b/Marco.Pms.Model/Dtos/Projects/InfraDto.cs @@ -0,0 +1,9 @@ +namespace Marco.Pms.Model.Dtos.Project +{ + public class InfraDto + { + public BuildingDto? Building { get; set; } + public FloorDto? Floor { get; set; } + public WorkAreaDto? WorkArea { get; set; } + } +} diff --git a/Marco.Pms.Model/Dtos/Projects/WorkAreaDot.cs b/Marco.Pms.Model/Dtos/Projects/WorkAreaDto.cs similarity index 91% rename from Marco.Pms.Model/Dtos/Projects/WorkAreaDot.cs rename to Marco.Pms.Model/Dtos/Projects/WorkAreaDto.cs index 604ee3e..ffc80c4 100644 --- a/Marco.Pms.Model/Dtos/Projects/WorkAreaDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/WorkAreaDto.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace Marco.Pms.Model.Dtos.Project { - public class WorkAreaDot + public class WorkAreaDto { [Key] public Guid? Id { get; set; } diff --git a/Marco.Pms.Model/Dtos/Projects/WorkItemDot.cs b/Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs similarity index 94% rename from Marco.Pms.Model/Dtos/Projects/WorkItemDot.cs rename to Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs index e6ba436..7c98051 100644 --- a/Marco.Pms.Model/Dtos/Projects/WorkItemDot.cs +++ b/Marco.Pms.Model/Dtos/Projects/WorkItemDto.cs @@ -2,7 +2,7 @@ namespace Marco.Pms.Model.Dtos.Project { - public class WorkItemDot + public class WorkItemDto { [Key] public Guid? Id { get; set; } diff --git a/Marco.Pms.Model/Mapper/InfraMapper.cs b/Marco.Pms.Model/Mapper/InfraMapper.cs index 4ccb7c8..5364494 100644 --- a/Marco.Pms.Model/Mapper/InfraMapper.cs +++ b/Marco.Pms.Model/Mapper/InfraMapper.cs @@ -5,7 +5,7 @@ namespace Marco.Pms.Model.Mapper { public static class BuildingMapper { - public static Building ToBuildingFromBuildingDto(this BuildingDot model, Guid tenantId) + public static Building ToBuildingFromBuildingDto(this BuildingDto model, Guid tenantId) { return new Building { @@ -20,7 +20,7 @@ namespace Marco.Pms.Model.Mapper public static class FloorMapper { - public static Floor ToFloorFromFloorDto(this FloorDot model, Guid tenantId) + public static Floor ToFloorFromFloorDto(this FloorDto model, Guid tenantId) { return new Floor { @@ -34,7 +34,7 @@ namespace Marco.Pms.Model.Mapper public static class WorAreaMapper { - public static WorkArea ToWorkAreaFromWorkAreaDto(this WorkAreaDot model, Guid tenantId) + public static WorkArea ToWorkAreaFromWorkAreaDto(this WorkAreaDto model, Guid tenantId) { return new WorkArea { @@ -48,7 +48,7 @@ namespace Marco.Pms.Model.Mapper } public static class WorkItemMapper { - public static WorkItem ToWorkItemFromWorkItemDto(this WorkItemDot model, Guid tenantId) + public static WorkItem ToWorkItemFromWorkItemDto(this WorkItemDto model, Guid tenantId) { return new WorkItem { diff --git a/Marco.Pms.Model/MongoDBModels/EmployeePermissionMongoDB.cs b/Marco.Pms.Model/MongoDBModels/EmployeePermissionMongoDB.cs index 49c514e..fab2b84 100644 --- a/Marco.Pms.Model/MongoDBModels/EmployeePermissionMongoDB.cs +++ b/Marco.Pms.Model/MongoDBModels/EmployeePermissionMongoDB.cs @@ -9,5 +9,6 @@ namespace Marco.Pms.Model.MongoDBModels public List ApplicationRoleIds { get; set; } = new List(); public List PermissionIds { get; set; } = new List(); public List ProjectIds { get; set; } = new List(); + public DateTime ExpireAt { get; set; } = DateTime.UtcNow.Date.AddDays(1); } } diff --git a/Marco.Pms.Model/MongoDBModels/ProjectMongoDB.cs b/Marco.Pms.Model/MongoDBModels/ProjectMongoDB.cs index 7f3a557..aac0e2c 100644 --- a/Marco.Pms.Model/MongoDBModels/ProjectMongoDB.cs +++ b/Marco.Pms.Model/MongoDBModels/ProjectMongoDB.cs @@ -14,5 +14,6 @@ public int TeamSize { get; set; } public double CompletedWork { get; set; } public double PlannedWork { get; set; } + public DateTime ExpireAt { get; set; } = DateTime.UtcNow.Date.AddDays(1); } } diff --git a/Marco.Pms.Model/MongoDBModels/ProjectReportEmailMongoDB.cs b/Marco.Pms.Model/MongoDBModels/ProjectReportEmailMongoDB.cs new file mode 100644 index 0000000..519ea4f --- /dev/null +++ b/Marco.Pms.Model/MongoDBModels/ProjectReportEmailMongoDB.cs @@ -0,0 +1,16 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Marco.Pms.Model.MongoDBModels +{ + public class ProjectReportEmailMongoDB + { + [BsonId] // Tells MongoDB this is the primary key (_id) + [BsonRepresentation(BsonType.ObjectId)] // Optional: if your _id is ObjectId + public string Id { get; set; } = string.Empty; + public string? Body { get; set; } + public string? Subject { get; set; } + public List? Receivers { get; set; } + public bool IsSent { get; set; } = false; + } +} diff --git a/Marco.Pms.Model/MongoDBModels/StatusMasterMongoDB.cs b/Marco.Pms.Model/MongoDBModels/StatusMasterMongoDB.cs index 01a0552..77e8eb5 100644 --- a/Marco.Pms.Model/MongoDBModels/StatusMasterMongoDB.cs +++ b/Marco.Pms.Model/MongoDBModels/StatusMasterMongoDB.cs @@ -2,7 +2,7 @@ { public class StatusMasterMongoDB { - public string? Id { get; set; } + public string Id { get; set; } = string.Empty; public string? Status { get; set; } } } diff --git a/Marco.Pms.Model/Utilities/ServiceResponse.cs b/Marco.Pms.Model/Utilities/ServiceResponse.cs new file mode 100644 index 0000000..a76c45c --- /dev/null +++ b/Marco.Pms.Model/Utilities/ServiceResponse.cs @@ -0,0 +1,8 @@ +namespace Marco.Pms.Model.Utilities +{ + public class ServiceResponse + { + public object? Notification { get; set; } + public ApiResponse Response { get; set; } = ApiResponse.ErrorResponse(""); + } +} diff --git a/Marco.Pms.Model/ViewModels/Projects/ProjectAllocationVM.cs b/Marco.Pms.Model/ViewModels/Projects/ProjectAllocationVM.cs new file mode 100644 index 0000000..6d9138e --- /dev/null +++ b/Marco.Pms.Model/ViewModels/Projects/ProjectAllocationVM.cs @@ -0,0 +1,13 @@ +namespace Marco.Pms.Model.ViewModels.Projects +{ + public class ProjectAllocationVM + { + public Guid Id { get; set; } + public Guid EmployeeId { get; set; } + public Guid? JobRoleId { get; set; } + public bool IsActive { get; set; } = true; + public Guid ProjectId { get; set; } + public DateTime AllocationDate { get; set; } + public DateTime? ReAllocationDate { get; set; } + } +} diff --git a/Marco.Pms.Services/Controllers/AttendanceController.cs b/Marco.Pms.Services/Controllers/AttendanceController.cs index 2622323..7339966 100644 --- a/Marco.Pms.Services/Controllers/AttendanceController.cs +++ b/Marco.Pms.Services/Controllers/AttendanceController.cs @@ -1,14 +1,15 @@ -using System.Globalization; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.AttendanceModule; using Marco.Pms.Model.Dtos.Attendance; using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Mapper; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.AttendanceVM; 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; @@ -16,6 +17,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; +using System.Globalization; using Document = Marco.Pms.Model.DocumentManager.Document; namespace MarcoBMS.Services.Controllers @@ -27,7 +29,7 @@ namespace MarcoBMS.Services.Controllers { private readonly ApplicationDbContext _context; private readonly EmployeeHelper _employeeHelper; - private readonly ProjectsHelper _projectsHelper; + private readonly IProjectServices _projectServices; private readonly UserHelper _userHelper; private readonly S3UploadService _s3Service; private readonly PermissionServices _permission; @@ -36,11 +38,11 @@ namespace MarcoBMS.Services.Controllers public AttendanceController( - ApplicationDbContext context, EmployeeHelper employeeHelper, ProjectsHelper projectsHelper, UserHelper userHelper, S3UploadService s3Service, ILoggingService logger, PermissionServices permission, IHubContext signalR) + ApplicationDbContext context, EmployeeHelper employeeHelper, IProjectServices projectServices, UserHelper userHelper, S3UploadService s3Service, ILoggingService logger, PermissionServices permission, IHubContext signalR) { _context = context; _employeeHelper = employeeHelper; - _projectsHelper = projectsHelper; + _projectServices = projectServices; _userHelper = userHelper; _s3Service = s3Service; _logger = logger; @@ -61,7 +63,13 @@ namespace MarcoBMS.Services.Controllers { Guid TenantId = GetTenantId(); - List lstAttendance = await _context.AttendanceLogs.Include(a => a.Document).Include(a => a.Employee).Include(a => a.UpdatedByEmployee).Where(c => c.AttendanceId == attendanceid && c.TenantId == TenantId).ToListAsync(); + List lstAttendance = await _context.AttendanceLogs + .Include(a => a.Document) + .Include(a => a.Employee) + .Include(a => a.UpdatedByEmployee) + .Where(c => c.AttendanceId == attendanceid && c.TenantId == TenantId) + .ToListAsync(); + List attendanceLogVMs = new List(); foreach (var attendanceLog in lstAttendance) { @@ -83,18 +91,18 @@ namespace MarcoBMS.Services.Controllers if (dateFrom != null && DateTime.TryParse(dateFrom, out fromDate) == false) { - _logger.LogError("User sent Invalid from Date while featching attendance logs"); + _logger.LogWarning("User sent Invalid from Date while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (dateTo != null && DateTime.TryParse(dateTo, out toDate) == false) { - _logger.LogError("User sent Invalid to Date while featching attendance logs"); + _logger.LogWarning("User sent Invalid to Date while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (employeeId == Guid.Empty) { - _logger.LogError("The employee Id sent by user is empty"); + _logger.LogWarning("The employee Id sent by user is empty"); return BadRequest(ApiResponse.ErrorResponse("Employee ID is required and must not be Empty.", "Employee ID is required and must not be empty.", 400)); } List attendances = await _context.Attendes.Where(c => c.EmployeeID == employeeId && c.TenantId == TenantId && c.AttendanceDate.Date >= fromDate && c.AttendanceDate.Date <= toDate).ToListAsync(); @@ -139,9 +147,9 @@ namespace MarcoBMS.Services.Controllers { Guid TenantId = GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var hasTeamAttendancePermission = await _permission.HasPermission(new Guid("915e6bff-65f6-4e3f-aea8-3fd217d3ea9e"), LoggedInEmployee.Id); - var hasSelfAttendancePermission = await _permission.HasPermission(new Guid("ccb0589f-712b-43de-92ed-5b6088e7dc4e"), LoggedInEmployee.Id); - var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId.ToString()); + var hasTeamAttendancePermission = await _permission.HasPermission(PermissionsMaster.TeamAttendance, LoggedInEmployee.Id); + var hasSelfAttendancePermission = await _permission.HasPermission(PermissionsMaster.SelfAttendance, LoggedInEmployee.Id); + var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId); if (!hasProjectPermission) { @@ -154,18 +162,18 @@ namespace MarcoBMS.Services.Controllers if (dateFrom != null && DateTime.TryParse(dateFrom, out fromDate) == false) { - _logger.LogError("User sent Invalid fromDate while featching attendance logs"); + _logger.LogWarning("User sent Invalid fromDate while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (dateTo != null && DateTime.TryParse(dateTo, out toDate) == false) { - _logger.LogError("User sent Invalid toDate while featching attendance logs"); + _logger.LogWarning("User sent Invalid toDate while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (projectId == Guid.Empty) { - _logger.LogError("The project Id sent by user is less than or equal to zero"); + _logger.LogWarning("The project Id sent by user is less than or equal to zero"); return BadRequest(ApiResponse.ErrorResponse("Project ID is required and must be greater than zero.", "Project ID is required and must be greater than zero.", 400)); } @@ -181,7 +189,7 @@ namespace MarcoBMS.Services.Controllers List lstAttendance = await _context.Attendes.Where(c => c.ProjectID == projectId && c.AttendanceDate.Date >= fromDate.Date && c.AttendanceDate.Date <= toDate.Date && c.TenantId == TenantId).ToListAsync(); - List projectteam = await _projectsHelper.GetTeamByProject(TenantId, projectId, true); + List projectteam = await _projectServices.GetTeamByProject(TenantId, projectId, true); var jobRole = await _context.JobRoles.ToListAsync(); foreach (Attendance? attendance in lstAttendance) { @@ -255,9 +263,9 @@ namespace MarcoBMS.Services.Controllers { Guid TenantId = GetTenantId(); var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - var hasTeamAttendancePermission = await _permission.HasPermission(new Guid("915e6bff-65f6-4e3f-aea8-3fd217d3ea9e"), LoggedInEmployee.Id); - var hasSelfAttendancePermission = await _permission.HasPermission(new Guid("ccb0589f-712b-43de-92ed-5b6088e7dc4e"), LoggedInEmployee.Id); - var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId.ToString()); + var hasTeamAttendancePermission = await _permission.HasPermission(PermissionsMaster.TeamAttendance, LoggedInEmployee.Id); + var hasSelfAttendancePermission = await _permission.HasPermission(PermissionsMaster.SelfAttendance, LoggedInEmployee.Id); + var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId); if (!hasProjectPermission) { @@ -269,13 +277,13 @@ namespace MarcoBMS.Services.Controllers if (date != null && DateTime.TryParse(date, out forDate) == false) { - _logger.LogError("User sent Invalid Date while featching attendance logs"); + _logger.LogWarning("User sent Invalid Date while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (projectId == Guid.Empty) { - _logger.LogError("The project Id sent by user is less than or equal to zero"); + _logger.LogWarning("The project Id sent by user is less than or equal to zero"); return BadRequest(ApiResponse.ErrorResponse("Project ID is required and must be greater than zero.", "Project ID is required and must be greater than zero.", 400)); } @@ -288,7 +296,7 @@ namespace MarcoBMS.Services.Controllers List lstAttendance = await _context.Attendes.Where(c => c.ProjectID == projectId && c.AttendanceDate.Date == forDate && c.TenantId == TenantId).ToListAsync(); - List projectteam = await _projectsHelper.GetTeamByProject(TenantId, projectId, IncludeInActive); + List projectteam = await _projectServices.GetTeamByProject(TenantId, projectId, IncludeInActive); var idList = projectteam.Select(p => p.EmployeeId).ToList(); //var emp = await _context.Employees.Where(e => idList.Contains(e.Id)).Include(e => e.JobRole).ToListAsync(); var jobRole = await _context.JobRoles.ToListAsync(); @@ -361,7 +369,7 @@ namespace MarcoBMS.Services.Controllers Guid TenantId = GetTenantId(); Employee LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var result = new List(); - var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId.ToString()); + var hasProjectPermission = await _permission.HasProjectPermission(LoggedInEmployee, projectId); if (!hasProjectPermission) { @@ -371,8 +379,7 @@ namespace MarcoBMS.Services.Controllers List lstAttendance = await _context.Attendes.Where(c => c.ProjectID == projectId && c.Activity == ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE && c.TenantId == TenantId).ToListAsync(); - - List projectteam = await _projectsHelper.GetTeamByProject(TenantId, projectId, true); + List projectteam = await _projectServices.GetTeamByProject(TenantId, projectId, true); var idList = projectteam.Select(p => p.EmployeeId).ToList(); var jobRole = await _context.JobRoles.ToListAsync(); @@ -419,7 +426,7 @@ namespace MarcoBMS.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance \n {Error}", string.Join(",", errors)); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -433,14 +440,14 @@ namespace MarcoBMS.Services.Controllers if (recordAttendanceDot.MarkTime == null) { - _logger.LogError("User sent Invalid Mark Time while marking attendance"); + _logger.LogWarning("User sent Invalid Mark Time while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid Mark Time", "Invalid Mark Time", 400)); } DateTime finalDateTime = GetDateFromTimeStamp(recordAttendanceDot.Date, recordAttendanceDot.MarkTime); if (recordAttendanceDot.Comment == null) { - _logger.LogError("User sent Invalid comment while marking attendance"); + _logger.LogWarning("User sent Invalid comment while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid Comment", "Invalid Comment", 400)); } @@ -474,7 +481,7 @@ namespace MarcoBMS.Services.Controllers } else { - _logger.LogError("Employee {EmployeeId} sent regularization request but it check-out time is earlier than check-out"); + _logger.LogWarning("Employee {EmployeeId} sent regularization request but it check-out time is earlier than check-out"); return BadRequest(ApiResponse.ErrorResponse("Check-out time must be later than check-in time", "Check-out time must be later than check-in time", 400)); } // do nothing @@ -579,7 +586,7 @@ namespace MarcoBMS.Services.Controllers catch (Exception ex) { await transaction.RollbackAsync(); // Rollback on failure - _logger.LogError("{Error} while marking attendance", ex.Message); + _logger.LogError(ex, "An Error occured while marking attendance"); var response = new { message = ex.Message, @@ -598,7 +605,7 @@ namespace MarcoBMS.Services.Controllers if (!ModelState.IsValid) { var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); - _logger.LogError("Invalid attendance model received."); + _logger.LogWarning("Invalid attendance model received. \n {Error}", string.Join(",", errors)); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -774,7 +781,7 @@ namespace MarcoBMS.Services.Controllers catch (Exception ex) { await transaction.RollbackAsync(); - _logger.LogError("Error while recording attendance : {Error}", ex.Message); + _logger.LogError(ex, "Error while recording attendance"); return BadRequest(ApiResponse.ErrorResponse("Something went wrong", ex.Message, 500)); } } diff --git a/Marco.Pms.Services/Controllers/AuthController.cs b/Marco.Pms.Services/Controllers/AuthController.cs index 1b45eb7..429a38b 100644 --- a/Marco.Pms.Services/Controllers/AuthController.cs +++ b/Marco.Pms.Services/Controllers/AuthController.cs @@ -1,8 +1,4 @@ -using System.Net; -using System.Security.Claims; -using System.Security.Cryptography; -using System.Text; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Authentication; using Marco.Pms.Model.Dtos.Authentication; using Marco.Pms.Model.Dtos.Util; @@ -15,6 +11,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; namespace MarcoBMS.Services.Controllers { @@ -110,7 +110,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Unexpected error during login : {Error}", ex.Message); + _logger.LogError(ex, "Unexpected error during login"); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error", ex.Message, 500)); } } @@ -270,7 +270,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Unexpected error occurred while verifying MPIN : {Error}", ex.Message); + _logger.LogError(ex, "Unexpected error occurred while verifying MPIN"); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error", ex.Message, 500)); } } @@ -307,7 +307,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Unexpected error during logout : {Error}", ex.Message); + _logger.LogError(ex, "Unexpected error during logout"); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error occurred", ex.Message, 500)); } } @@ -351,7 +351,7 @@ namespace MarcoBMS.Services.Controllers if (string.IsNullOrWhiteSpace(user.UserName)) { - _logger.LogError("Username missing for user ID: {UserId}", user.Id); + _logger.LogWarning("Username missing for user ID: {UserId}", user.Id); return NotFound(ApiResponse.ErrorResponse("Username not found.", "Username not found.", 404)); } @@ -370,7 +370,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("An unexpected error occurred during token refresh. : {Error}", ex.Message); + _logger.LogError(ex, "An unexpected error occurred during token refresh."); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error occurred.", ex.Message, 500)); } } @@ -406,7 +406,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Error while sending password reset email to: {Error}", ex.Message); + _logger.LogError(ex, "Error while sending password reset email to"); return StatusCode(500, ApiResponse.ErrorResponse("Error sending password reset email.", ex.Message, 500)); } } @@ -480,7 +480,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("Error while sending reset password success email to user: {Error}", ex.Message); + _logger.LogError(ex, "Error while sending reset password success email to user"); // Continue, do not fail because of email issue } @@ -547,7 +547,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("An unexpected error occurred while sending OTP to {Email} : {Error}", generateOTP.Email ?? "", ex.Message); + _logger.LogError(ex, "An unexpected error occurred while sending OTP to {Email}", generateOTP.Email ?? ""); return StatusCode(500, ApiResponse.ErrorResponse("An unexpected error occurred.", ex.Message, 500)); } } @@ -638,7 +638,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception ex) { - _logger.LogError("An unexpected error occurred during OTP login for email {Email} : {Error}", verifyOTP.Email ?? string.Empty, ex.Message); + _logger.LogError(ex, "An unexpected error occurred during OTP login for email {Email}", verifyOTP.Email ?? string.Empty); return StatusCode(500, ApiResponse.ErrorResponse("Unexpected error", ex.Message, 500)); } } @@ -719,7 +719,7 @@ namespace MarcoBMS.Services.Controllers if (!result.Succeeded) { var errors = result.Errors.Select(e => e.Description).ToList(); - _logger.LogError("Password reset failed for user {Email}. Errors: {Errors}", changePassword.Email, string.Join("; ", errors)); + _logger.LogWarning("Password reset failed for user {Email}. Errors: {Errors}", changePassword.Email, string.Join("; ", errors)); return BadRequest(ApiResponse.ErrorResponse("Failed to change password", errors, 400)); } @@ -732,7 +732,7 @@ namespace MarcoBMS.Services.Controllers } catch (Exception exp) { - _logger.LogError("An unexpected error occurred while changing password : {Error}", exp.Message); + _logger.LogError(exp, "An unexpected error occurred while changing password"); return StatusCode(500, ApiResponse.ErrorResponse("An unexpected error occurred.", exp.Message, 500)); } } @@ -752,7 +752,7 @@ namespace MarcoBMS.Services.Controllers // Validate employee and MPIN input if (requestEmployee == null || string.IsNullOrWhiteSpace(generateMPINDto.MPIN) || generateMPINDto.MPIN.Length != 6 || !generateMPINDto.MPIN.All(char.IsDigit)) { - _logger.LogError("Employee {EmployeeId} provided invalid information to generate MPIN", loggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} provided invalid information to generate MPIN", loggedInEmployee.Id); return BadRequest(ApiResponse.ErrorResponse("Provided invalid information", "Provided invalid information", 400)); } diff --git a/Marco.Pms.Services/Controllers/DashboardController.cs b/Marco.Pms.Services/Controllers/DashboardController.cs index 3829cdc..108a3ec 100644 --- a/Marco.Pms.Services/Controllers/DashboardController.cs +++ b/Marco.Pms.Services/Controllers/DashboardController.cs @@ -6,6 +6,7 @@ using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.DashBoard; using Marco.Pms.Services.Service; +using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; @@ -21,15 +22,15 @@ namespace Marco.Pms.Services.Controllers { private readonly ApplicationDbContext _context; private readonly UserHelper _userHelper; - private readonly ProjectsHelper _projectsHelper; + private readonly IProjectServices _projectServices; private readonly ILoggingService _logger; private readonly PermissionServices _permissionServices; public static readonly Guid ActiveId = Guid.Parse("b74da4c2-d07e-46f2-9919-e75e49b12731"); - public DashboardController(ApplicationDbContext context, UserHelper userHelper, ProjectsHelper projectsHelper, ILoggingService logger, PermissionServices permissionServices) + public DashboardController(ApplicationDbContext context, UserHelper userHelper, IProjectServices projectServices, ILoggingService logger, PermissionServices permissionServices) { _context = context; _userHelper = userHelper; - _projectsHelper = projectsHelper; + _projectServices = projectServices; _logger = logger; _permissionServices = permissionServices; } @@ -182,11 +183,13 @@ namespace Marco.Pms.Services.Controllers // --- Step 1: Get the list of projects the user can access --- // This query is more efficient as it only selects the IDs needed. - var projects = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); - var accessibleActiveProjectIds = projects - .Where(p => p.ProjectStatusId == ActiveId) + var projects = await _projectServices.GetMyProjectIdsAsync(tenantId, loggedInEmployee); + + var accessibleActiveProjectIds = await _context.Projects + .Where(p => p.ProjectStatusId == ActiveId && projects.Contains(p.Id)) .Select(p => p.Id) - .ToList(); + .ToListAsync(); + if (!accessibleActiveProjectIds.Any()) { _logger.LogInfo("User {UserId} has no accessible active projects.", loggedInEmployee.Id); @@ -199,7 +202,7 @@ namespace Marco.Pms.Services.Controllers if (projectId.HasValue) { // Security Check: Ensure the requested project is in the user's accessible list. - var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value.ToString()); + var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId} (not active or not accessible).", loggedInEmployee.Id, projectId.Value); @@ -250,7 +253,7 @@ namespace Marco.Pms.Services.Controllers } catch (Exception ex) { - _logger.LogError("An unexpected error occurred in GetTotalEmployees for projectId {ProjectId} \n {Error}", projectId ?? Guid.Empty, ex.Message); + _logger.LogError(ex, "An unexpected error occurred in GetTotalEmployees for projectId {ProjectId}", projectId ?? Guid.Empty); return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); } } @@ -281,7 +284,7 @@ namespace Marco.Pms.Services.Controllers // --- Logic for a SINGLE Project --- // 2a. Security Check: Verify permission for the specific project. - var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value.ToString()); + var hasPermission = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.Value); if (!hasPermission) { _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId.Value); @@ -301,8 +304,8 @@ namespace Marco.Pms.Services.Controllers // --- Logic for ALL Accessible Projects --- // 2c. Get a list of all projects the user is allowed to see. - var accessibleProject = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); - var accessibleProjectIds = accessibleProject.Select(p => p.Id).ToList(); + var accessibleProjectIds = await _projectServices.GetMyProjectIdsAsync(tenantId, loggedInEmployee); + if (!accessibleProjectIds.Any()) { _logger.LogInfo("User {UserId} has no accessible projects.", loggedInEmployee.Id); @@ -341,7 +344,7 @@ namespace Marco.Pms.Services.Controllers } catch (Exception ex) { - _logger.LogError("An unexpected error occurred in GetTotalTasks for projectId {ProjectId} \n {Error}", projectId ?? Guid.Empty, ex.Message); + _logger.LogError(ex, "An unexpected error occurred in GetTotalTasks for projectId {ProjectId}", projectId ?? Guid.Empty); return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); } } @@ -364,7 +367,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Number of pending regularization and pending check-out are fetched successfully for employee {EmployeeId}", LoggedInEmployee.Id); return Ok(ApiResponse.SuccessResponse(response, "Pending regularization and pending check-out are fetched successfully", 200)); } - _logger.LogError("No attendance entry was found for employee {EmployeeId}", LoggedInEmployee.Id); + _logger.LogWarning("No attendance entry was found for employee {EmployeeId}", LoggedInEmployee.Id); return NotFound(ApiResponse.ErrorResponse("No attendance entry was found for this employee", "No attendance entry was found for this employee", 404)); } @@ -378,14 +381,14 @@ namespace Marco.Pms.Services.Controllers List? projectProgressionVMs = new List(); if (date != null && DateTime.TryParse(date, out currentDate) == false) { - _logger.LogError($"user send invalid date"); + _logger.LogWarning($"user send invalid date"); return BadRequest(ApiResponse.ErrorResponse("Invalid date.", "Invalid date.", 400)); } Project? project = await _context.Projects.FirstOrDefaultAsync(p => p.Id == projectId); if (project == null) { - _logger.LogError("Employee {EmployeeId} was attempted to get project attendance for date {Date}, but project not found in database", LoggedInEmployee.Id, currentDate); + _logger.LogWarning("Employee {EmployeeId} was attempted to get project attendance for date {Date}, but project not found in database", LoggedInEmployee.Id, currentDate); return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); } List? projectAllocation = await _context.ProjectAllocations.Where(p => p.ProjectId == projectId && p.IsActive && p.TenantId == tenantId).ToListAsync(); @@ -431,14 +434,14 @@ namespace Marco.Pms.Services.Controllers DateTime currentDate = DateTime.UtcNow; if (date != null && DateTime.TryParse(date, out currentDate) == false) { - _logger.LogError($"user send invalid date"); + _logger.LogWarning($"user send invalid date"); return BadRequest(ApiResponse.ErrorResponse("Invalid date.", "Invalid date.", 400)); } Project? project = await _context.Projects.FirstOrDefaultAsync(p => p.Id == projectId); if (project == null) { - _logger.LogError("Employee {EmployeeId} was attempted to get activities performed for date {Date}, but project not found in database", LoggedInEmployee.Id, currentDate); + _logger.LogWarning("Employee {EmployeeId} was attempted to get activities performed for date {Date}, but project not found in database", LoggedInEmployee.Id, currentDate); return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); } @@ -516,7 +519,7 @@ namespace Marco.Pms.Services.Controllers // Step 2: Permission check var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - bool hasAssigned = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId.ToString()); + bool hasAssigned = await _permissionServices.HasProjectPermission(loggedInEmployee, projectId); if (!hasAssigned) { diff --git a/Marco.Pms.Services/Controllers/DirectoryController.cs b/Marco.Pms.Services/Controllers/DirectoryController.cs index 4a0e41e..9eb06e0 100644 --- a/Marco.Pms.Services/Controllers/DirectoryController.cs +++ b/Marco.Pms.Services/Controllers/DirectoryController.cs @@ -77,7 +77,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } var response = await _directoryHelper.CreateContact(createContact); @@ -256,7 +256,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } var response = await _directoryHelper.CreateBucket(bucketDto); diff --git a/Marco.Pms.Services/Controllers/EmployeeController.cs b/Marco.Pms.Services/Controllers/EmployeeController.cs index 9884e53..d5d7f3d 100644 --- a/Marco.Pms.Services/Controllers/EmployeeController.cs +++ b/Marco.Pms.Services/Controllers/EmployeeController.cs @@ -1,6 +1,4 @@ -using System.Data; -using System.Net; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Attendance; using Marco.Pms.Model.Dtos.Employees; using Marco.Pms.Model.Employees; @@ -11,6 +9,7 @@ using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Employee; 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; @@ -18,6 +17,8 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; +using System.Data; +using System.Net; namespace MarcoBMS.Services.Controllers { @@ -37,13 +38,13 @@ namespace MarcoBMS.Services.Controllers private readonly ILoggingService _logger; private readonly IHubContext _signalR; private readonly PermissionServices _permission; - private readonly ProjectsHelper _projectsHelper; + private readonly IProjectServices _projectServices; private readonly Guid tenantId; public EmployeeController(UserManager userManager, IEmailSender emailSender, ApplicationDbContext context, EmployeeHelper employeeHelper, UserHelper userHelper, IConfiguration configuration, ILoggingService logger, - IHubContext signalR, PermissionServices permission, ProjectsHelper projectsHelper) + IHubContext signalR, PermissionServices permission, IProjectServices projectServices) { _context = context; _userManager = userManager; @@ -54,7 +55,7 @@ namespace MarcoBMS.Services.Controllers _logger = logger; _signalR = signalR; _permission = permission; - _projectsHelper = projectsHelper; + _projectServices = projectServices; tenantId = _userHelper.GetTenantId(); } @@ -119,8 +120,7 @@ namespace MarcoBMS.Services.Controllers loggedInEmployee.Id, projectid ?? Guid.Empty, ShowInactive); // Step 3: Fetch project access and permissions - List projects = await _projectsHelper.GetMyProjects(tenantId, loggedInEmployee); - var projectIds = projects.Select(p => p.Id).ToList(); + var projectIds = await _projectServices.GetMyProjectIdsAsync(tenantId, loggedInEmployee); var hasViewAllEmployeesPermission = await _permission.HasPermission(PermissionsMaster.ViewAllEmployees, loggedInEmployee.Id); var hasViewTeamMembersPermission = await _permission.HasPermission(PermissionsMaster.ViewTeamMembers, loggedInEmployee.Id); @@ -383,7 +383,7 @@ namespace MarcoBMS.Services.Controllers Employee? existingEmployee = await _context.Employees.FirstOrDefaultAsync(e => e.Id == model.Id.Value); if (existingEmployee == null) { - _logger.LogError("User tries to update employee {EmployeeId} but not found in database", model.Id); + _logger.LogWarning("User tries to update employee {EmployeeId} but not found in database", model.Id); return NotFound(ApiResponse.ErrorResponse("Employee not found", "Employee not found", 404)); } byte[]? imageBytes = null; @@ -496,7 +496,7 @@ namespace MarcoBMS.Services.Controllers } else { - _logger.LogError("Employee with ID {EmploueeId} not found in database", id); + _logger.LogWarning("Employee with ID {EmploueeId} not found in database", id); } return Ok(ApiResponse.SuccessResponse(new { }, "Employee Suspended successfully", 200)); } diff --git a/Marco.Pms.Services/Controllers/ForumController.cs b/Marco.Pms.Services/Controllers/ForumController.cs index 769c08a..fb6d0e7 100644 --- a/Marco.Pms.Services/Controllers/ForumController.cs +++ b/Marco.Pms.Services/Controllers/ForumController.cs @@ -44,7 +44,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } Guid tenantId = _userHelper.GetTenantId(); @@ -66,7 +66,7 @@ namespace Marco.Pms.Services.Controllers var Image = attachmentDto; if (string.IsNullOrEmpty(Image.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } @@ -160,7 +160,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } Guid tenantId = _userHelper.GetTenantId(); @@ -197,7 +197,7 @@ namespace Marco.Pms.Services.Controllers var Image = attachmentDto; if (string.IsNullOrEmpty(Image.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } @@ -336,7 +336,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket {TicketId} updated", updateTicketDto.Id); return Ok(ApiResponse.SuccessResponse(ticketVM, "Ticket Updated Successfully", 200)); } - _logger.LogError("Ticket {TicketId} not Found in database", updateTicketDto.Id); + _logger.LogWarning("Ticket {TicketId} not Found in database", updateTicketDto.Id); return NotFound(ApiResponse.ErrorResponse("Ticket not Found", "Ticket not Found", 404)); } @@ -349,7 +349,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -364,7 +364,7 @@ namespace Marco.Pms.Services.Controllers if (ticket == null) { - _logger.LogError("Ticket {TicketId} not Found in database", addCommentDto.TicketId); + _logger.LogWarning("Ticket {TicketId} not Found in database", addCommentDto.TicketId); return NotFound(ApiResponse.ErrorResponse("Ticket not Found", "Ticket not Found", 404)); } @@ -379,7 +379,7 @@ namespace Marco.Pms.Services.Controllers var Image = attachmentDto; if (string.IsNullOrEmpty(Image.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } @@ -437,7 +437,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -451,7 +451,7 @@ namespace Marco.Pms.Services.Controllers if (ticket == null) { - _logger.LogError("Ticket {TicketId} not Found in database", updateCommentDto.TicketId); + _logger.LogWarning("Ticket {TicketId} not Found in database", updateCommentDto.TicketId); return NotFound(ApiResponse.ErrorResponse("Ticket not Found", "Ticket not Found", 404)); } @@ -474,7 +474,7 @@ namespace Marco.Pms.Services.Controllers var Image = attachmentDto; if (string.IsNullOrEmpty(Image.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } @@ -552,7 +552,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("{error}", errors); + _logger.LogWarning("{error}", errors); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } @@ -568,7 +568,7 @@ namespace Marco.Pms.Services.Controllers if (tickets == null || tickets.Count > 0) { - _logger.LogError("Tickets not Found in database"); + _logger.LogWarning("Tickets not Found in database"); return NotFound(ApiResponse.ErrorResponse("Ticket not Found", "Ticket not Found", 404)); } @@ -578,12 +578,12 @@ namespace Marco.Pms.Services.Controllers { if (string.IsNullOrEmpty(forumAttachmentDto.Base64Data)) { - _logger.LogError("Base64 data is missing"); + _logger.LogWarning("Base64 data is missing"); return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Base64 data is missing", 400)); } if (forumAttachmentDto.TicketId == null) { - _logger.LogError("ticket ID is missing"); + _logger.LogWarning("ticket ID is missing"); return BadRequest(ApiResponse.ErrorResponse("ticket ID is missing", "ticket ID is missing", 400)); } var ticket = tickets.FirstOrDefault(t => t.Id == forumAttachmentDto.TicketId); diff --git a/Marco.Pms.Services/Controllers/ImageController.cs b/Marco.Pms.Services/Controllers/ImageController.cs index 48fbc3b..9014171 100644 --- a/Marco.Pms.Services/Controllers/ImageController.cs +++ b/Marco.Pms.Services/Controllers/ImageController.cs @@ -1,5 +1,4 @@ -using System.Text.Json; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Activities; using Marco.Pms.Model.Dtos.DocumentManager; using Marco.Pms.Model.Employees; @@ -13,6 +12,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; +using System.Text.Json; namespace Marco.Pms.Services.Controllers { @@ -54,7 +54,7 @@ namespace Marco.Pms.Services.Controllers } // Step 2: Check project access permission - var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.ToString()); + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId); if (!hasPermission) { _logger.LogWarning("[GetImageList] Access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); diff --git a/Marco.Pms.Services/Controllers/MasterController.cs b/Marco.Pms.Services/Controllers/MasterController.cs index ebd8998..9000cdf 100644 --- a/Marco.Pms.Services/Controllers/MasterController.cs +++ b/Marco.Pms.Services/Controllers/MasterController.cs @@ -168,7 +168,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("activity updated successfully from tenant {tenantId}", tenantId); return Ok(ApiResponse.SuccessResponse(activityVM, "activity updated successfully", 200)); } - _logger.LogError("Activity {ActivityId} not found", id); + _logger.LogWarning("Activity {ActivityId} not found", id); return NotFound(ApiResponse.ErrorResponse("Activity not found", "Activity not found", 404)); } @@ -230,7 +230,7 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Status master {TicketStatusId} added successfully from tenant {tenantId}", statusMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(statusVM, "Ticket Status master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("Sent Empty payload", "Sent Empty payload", 400)); } @@ -251,10 +251,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Status master {TicketStatusId} updated successfully from tenant {tenantId}", statusMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(statusVM, "Ticket Status master updated successfully", 200)); } - _logger.LogError("Ticket Status master {TicketStatusId} not found in database", statusMasterDto.Id != null ? statusMasterDto.Id.Value : Guid.Empty); + _logger.LogWarning("Ticket Status master {TicketStatusId} not found in database", statusMasterDto.Id != null ? statusMasterDto.Id.Value : Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Ticket Status master not found", "Ticket Status master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("Sent Empty payload", "Sent Empty payload", 400)); } @@ -281,7 +281,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Ticket Status {TickeStatusId} not found in database", id); + _logger.LogWarning("Ticket Status {TickeStatusId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Ticket Status not found", "Ticket Status not found", 404)); } } @@ -318,7 +318,7 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket type master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -339,10 +339,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Type master {TicketTypeId} updated successfully from tenant {tenantId}", typeMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket type master updated successfully", 200)); } - _logger.LogError("Ticket type master {TicketTypeId} not found in database", typeMasterDto.Id != null ? typeMasterDto.Id.Value : Guid.Empty); + _logger.LogWarning("Ticket type master {TicketTypeId} not found in database", typeMasterDto.Id != null ? typeMasterDto.Id.Value : Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Ticket type master not found", "Ticket type master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -369,7 +369,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Ticket Type {TickeTypeId} not found in database", id); + _logger.LogWarning("Ticket Type {TickeTypeId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Ticket Type not found", "Ticket Type not found", 404)); } } @@ -407,7 +407,7 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket Priority master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } [HttpPost("ticket-priorities/edit/{id}")] @@ -427,10 +427,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Priority master {TicketPriorityId} updated successfully from tenant {tenantId}", typeMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket Priority master updated successfully", 200)); } - _logger.LogError("Ticket Priority master {TicketPriorityId} not found in database", priorityMasterDto.Id != null ? priorityMasterDto.Id.Value : Guid.Empty); + _logger.LogWarning("Ticket Priority master {TicketPriorityId} not found in database", priorityMasterDto.Id != null ? priorityMasterDto.Id.Value : Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Ticket Priority master not found", "Ticket Priority master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -457,7 +457,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Ticket Priority {TickePriorityId} not found in database", id); + _logger.LogWarning("Ticket Priority {TickePriorityId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Ticket Priority not found", "Ticket Priority not found", 404)); } } @@ -494,7 +494,7 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket tag master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -515,10 +515,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Ticket Tag master {TicketTypeId} updated successfully from tenant {tenantId}", tagMaster.Id, tenantId); return Ok(ApiResponse.SuccessResponse(typeVM, "Ticket tag master updated successfully", 200)); } - _logger.LogError("Ticket tag master {TicketTypeId} not found in database", tagMasterDto.Id != null ? tagMasterDto.Id.Value : Guid.Empty); + _logger.LogWarning("Ticket tag master {TicketTypeId} not found in database", tagMasterDto.Id != null ? tagMasterDto.Id.Value : Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Ticket tag master not found", "Ticket tag master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -545,7 +545,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Ticket Tag {TickeTagId} not found in database", id); + _logger.LogWarning("Ticket Tag {TickeTagId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Ticket tag not found", "Ticket tag not found", 404)); } } @@ -609,7 +609,7 @@ namespace Marco.Pms.Services.Controllers return Ok(ApiResponse.SuccessResponse(workCategoryMasterVM, "Work category master added successfully", 200)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -624,7 +624,7 @@ namespace Marco.Pms.Services.Controllers { if (workCategory.IsSystem) { - _logger.LogError("User tries to update system-defined work category"); + _logger.LogWarning("User tries to update system-defined work category"); return BadRequest(ApiResponse.ErrorResponse("Cannot update system-defined work", "Cannot update system-defined work", 400)); } workCategory = workCategoryMasterDto.ToWorkCategoryMasterFromWorkCategoryMasterDto(tenantId); @@ -635,10 +635,10 @@ namespace Marco.Pms.Services.Controllers _logger.LogInfo("Work category master {WorkCategoryId} updated successfully from tenant {tenantId}", workCategory.Id, tenantId); return Ok(ApiResponse.SuccessResponse(workCategoryMasterVM, "Work category master updated successfully", 200)); } - _logger.LogError("Work category master {WorkCategoryId} not found in database", workCategoryMasterDto.Id ?? Guid.Empty); + _logger.LogWarning("Work category master {WorkCategoryId} not found in database", workCategoryMasterDto.Id ?? Guid.Empty); return NotFound(ApiResponse.ErrorResponse("Work category master not found", "Work category master not found", 404)); } - _logger.LogError("User sent empyt payload"); + _logger.LogWarning("User sent empyt payload"); return BadRequest(ApiResponse.ErrorResponse("User sent Empty payload", "User sent Empty payload", 400)); } @@ -666,7 +666,7 @@ namespace Marco.Pms.Services.Controllers } else { - _logger.LogError("Work category {WorkCategoryId} not found in database", id); + _logger.LogWarning("Work category {WorkCategoryId} not found in database", id); return NotFound(ApiResponse.ErrorResponse("Work category not found", "Work category not found", 404)); } } @@ -689,7 +689,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } var response = await _masterHelper.CreateWorkStatus(createWorkStatusDto); @@ -803,7 +803,7 @@ namespace Marco.Pms.Services.Controllers .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - _logger.LogError("User sent Invalid Date while marking attendance"); + _logger.LogWarning("User sent Invalid Date while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } var response = await _masterHelper.CreateContactTag(contactTagDto); diff --git a/Marco.Pms.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index 07ddbfd..796fd39 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -1,24 +1,12 @@ -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.Activities; -using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Dtos.Project; using Marco.Pms.Model.Employees; -using Marco.Pms.Model.Entitlements; -using Marco.Pms.Model.Mapper; -using Marco.Pms.Model.Master; -using Marco.Pms.Model.MongoDBModels; -using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; -using Marco.Pms.Model.ViewModels.Employee; -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; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; -using Microsoft.EntityFrameworkCore; +using Microsoft.CodeAnalysis; using MongoDB.Driver; namespace MarcoBMS.Services.Controllers @@ -28,175 +16,88 @@ namespace MarcoBMS.Services.Controllers [Authorize] public class ProjectController : ControllerBase { - private readonly ApplicationDbContext _context; + private readonly IProjectServices _projectServices; private readonly UserHelper _userHelper; private readonly ILoggingService _logger; - //private readonly RolesHelper _rolesHelper; - private readonly ProjectsHelper _projectsHelper; - private readonly IHubContext _signalR; - private readonly PermissionServices _permission; - private readonly CacheUpdateHelper _cache; - private readonly IServiceScopeFactory _serviceScopeFactory; - private readonly Guid ViewProjects; - private readonly Guid ManageProject; - private readonly Guid ViewInfra; - private readonly Guid ManageInfra; + private readonly ISignalRService _signalR; private readonly Guid tenantId; - public ProjectController(ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, RolesHelper rolesHelper, ProjectsHelper projectHelper, - IHubContext signalR, PermissionServices permission, CacheUpdateHelper cache, IServiceScopeFactory serviceScopeFactory) + public ProjectController( + UserHelper userHelper, + ILoggingService logger, + ISignalRService signalR, + IProjectServices projectServices) { - _context = context; _userHelper = userHelper; _logger = logger; - //_rolesHelper = rolesHelper; - _projectsHelper = projectHelper; _signalR = signalR; - _cache = cache; - _permission = permission; - ViewProjects = Guid.Parse("6ea44136-987e-44ba-9e5d-1cf8f5837ebc"); - ManageProject = Guid.Parse("172fc9b6-755b-4f62-ab26-55c34a330614"); - ViewInfra = Guid.Parse("8d7cc6e3-9147-41f7-aaa7-fa507e450bd4"); - ManageInfra = Guid.Parse("f2aee20a-b754-4537-8166-f9507b44585b"); - tenantId = _userHelper.GetTenantId(); - _serviceScopeFactory = serviceScopeFactory; + _projectServices = projectServices; + tenantId = userHelper.GetTenantId(); } + #region =================================================================== Project Get APIs =================================================================== + [HttpGet("list/basic")] - public async Task GetAllProjects() + public async Task GetAllProjectsBasic() { - if (!ModelState.IsValid) - { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - - } - Guid tenantId = _userHelper.GetTenantId(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // Defensive check for null employee (important for robust APIs) - if (LoggedInEmployee == null) - { - return Unauthorized(ApiResponse.ErrorResponse("Employee not found.", null, 401)); - } - - - List projects = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); - - - // 4. Project projection to ProjectInfoVM - // This part is already quite efficient. - // Ensure ToProjectInfoVMFromProject() is also optimized and doesn't perform N+1 queries. - // If ProjectInfoVM only needs a subset of Project properties, - // you can use a LINQ Select directly on the IQueryable before ToListAsync() - // to fetch only the required columns from the database. - List response = projects - .Select(project => project.ToProjectInfoVMFromProject()) - .ToList(); - - - //List response = new List(); - - //foreach (var project in projects) - //{ - // response.Add(project.ToProjectInfoVMFromProject()); - //} - - return Ok(ApiResponse.SuccessResponse(response, "Success.", 200)); + // Get the current user + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetAllProjectsBasicAsync(tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } + /// + /// Retrieves a list of projects accessible to the current user, including aggregated details. + /// This method is optimized to use a cache-first approach. If data is not in the cache, + /// it fetches and aggregates data efficiently from the database in parallel. + /// + /// An ApiResponse containing a list of projects or an error. + [HttpGet("list")] - public async Task GetAll() + public async Task GetAllProjects() { + // --- Input Validation and Initial Setup --- if (!ModelState.IsValid) { var errors = ModelState.Values .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - + _logger.LogWarning("GetAllProjects called with invalid model state. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - Guid tenantId = _userHelper.GetTenantId(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - //List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(LoggedInEmployee.Id); - //string[] projectsId = []; - //List projects = new List(); - - ///* User with permission manage project can see all projects */ - //if (featurePermission != null && featurePermission.Exists(c => c.Id.ToString() == "172fc9b6-755b-4f62-ab26-55c34a330614")) - //{ - // projects = await _projectsHelper.GetAllProjectByTanentID(LoggedInEmployee.TenantId); - //} - //else - //{ - // List allocation = await _projectsHelper.GetProjectByEmployeeID(LoggedInEmployee.Id); - // projectsId = allocation.Select(c => c.ProjectId.ToString()).ToArray(); - // projects = await _context.Projects.Where(c => projectsId.Contains(c.Id.ToString()) && c.TenantId == tenantId).ToListAsync(); - //} - - List projects = await _projectsHelper.GetMyProjects(tenantId, LoggedInEmployee); - - - - - List response = new List(); - foreach (var project in projects) - { - var result = project.ToProjectListVMFromProject(); - var team = await _context.ProjectAllocations.Where(p => p.TenantId == tenantId && p.ProjectId == project.Id && p.IsActive == true).ToListAsync(); - - result.TeamSize = team.Count(); - - List buildings = await _context.Buildings.Where(b => b.ProjectId == project.Id && b.TenantId == tenantId).ToListAsync(); - List idList = buildings.Select(b => b.Id).ToList(); - - List floors = await _context.Floor.Where(f => idList.Contains(f.BuildingId) && f.TenantId == tenantId).ToListAsync(); - idList = floors.Select(f => f.Id).ToList(); - - List workAreas = await _context.WorkAreas.Where(a => idList.Contains(a.FloorId) && a.TenantId == tenantId).ToListAsync(); - idList = workAreas.Select(a => a.Id).ToList(); - - List workItems = await _context.WorkItems.Where(i => idList.Contains(i.WorkAreaId) && i.TenantId == tenantId).Include(i => i.ActivityMaster).ToListAsync(); - double completedTask = 0; - double plannedTask = 0; - foreach (var workItem in workItems) - { - completedTask += workItem.CompletedWork; - plannedTask += workItem.PlannedWork; - } - result.PlannedWork = plannedTask; - result.CompletedWork = completedTask; - response.Add(result); - } - - return Ok(ApiResponse.SuccessResponse(response, "Success.", 200)); + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetAllProjectsAsync(tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } + /// + /// Retrieves details for a specific project by its ID. + /// This endpoint is optimized with a cache-first strategy and parallel permission checks. + /// + /// The unique identifier of the project. + /// 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) { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get project called with invalid model state for ID {ProjectId}. Errors: {Errors}", id, string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - var project = await _context.Projects.Where(c => c.TenantId == _userHelper.GetTenantId() && c.Id == id).SingleOrDefaultAsync(); - if (project == null) return NotFound(ApiResponse.ErrorResponse("Project not found", "Project not found", 404)); - return Ok(ApiResponse.SuccessResponse(project, "Success.", 200)); + 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) @@ -212,92 +113,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(ViewProjects, 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.ToString()); - 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 = GetProjectViewModel(project); - if (project != null) - { - await _cache.AddProjectDetails(project); - } - } - else - { - projectVM = new ProjectVM - { - Id = projectDetails.Id != null ? Guid.Parse(projectDetails.Id) : Guid.Empty, - Name = projectDetails.Name, - ShortName = projectDetails.ShortName, - ProjectAddress = projectDetails.ProjectAddress, - StartDate = projectDetails.StartDate, - EndDate = projectDetails.EndDate, - ContactPerson = projectDetails.ContactPerson, - ProjectStatus = new StatusMaster - { - Id = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, - Status = projectDetails.ProjectStatus?.Status, - TenantId = tenantId - } - //ProjectStatusId = projectDetails.ProjectStatus?.Id != null ? Guid.Parse(projectDetails.ProjectStatus.Id) : Guid.Empty, - }; - } - - 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)); - } - - private ProjectVM? GetProjectViewModel(Project? project) - { - if (project == null) - { - return null; - } - return new ProjectVM - { - Id = project.Id, - Name = project.Name, - ShortName = project.ShortName, - StartDate = project.StartDate, - EndDate = project.EndDate, - ProjectStatus = project.ProjectStatus, - ContactPerson = project.ContactPerson, - ProjectAddress = project.ProjectAddress, - }; + 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(); @@ -311,132 +133,19 @@ namespace MarcoBMS.Services.Controllers } - var project = await _context.Projects.Where(c => c.TenantId == _userHelper.GetTenantId() && 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); } - private async Task GetProjectViewModel(Guid? id, Project project) - { - ProjectDetailsVM vm = new ProjectDetailsVM(); + #endregion - // 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; - } - - private Guid GetTenantId() - { - return _userHelper.GetTenantId(); - //var tenant = User.FindFirst("TenantId")?.Value; - //return (tenant != null ? Convert.ToInt32(tenant) : 1); - } + #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) @@ -446,908 +155,268 @@ namespace MarcoBMS.Services.Controllers } // 2. Prepare data without I/O - Guid tenantId = _userHelper.GetTenantId(); // Assuming this is fast and from claims 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(); + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Create_Project", Response = response.Data }; + await _signalR.SendNotificationAsync(notification); } - 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(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)); + return StatusCode(response.StatusCode, response); } - [HttpPut] - [Route("update/{id}")] - public async Task Update([FromRoute] Guid id, [FromBody] UpdateProjectDto updateProjectDto) + /// + /// 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. + + [HttpPut("update/{id}")] + public async Task UpdateProject([FromRoute] Guid id, [FromBody] UpdateProjectDto updateProjectDto) { - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + // --- Step 1: Input Validation --- if (!ModelState.IsValid) { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Update project called with invalid model state for ID {ProjectId}. Errors: {Errors}", id, string.Join(", ", errors)); + 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) { - Guid TenantId = GetTenantId(); - - 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() }; - - 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)); + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Update_Project", Response = response.Data }; + await _signalR.SendNotificationAsync(notification); } + return StatusCode(response.StatusCode, response); } - //[HttpPost("assign-employee")] - //public async Task AssignEmployee(int? allocationid, int employeeId, int projectId) - //{ - // var employee = await _context.Employees.FindAsync(employeeId); - // var project = _projectrepo.Get(c => c.Id == projectId); - // if (employee == null || project == null) - // { - // return NotFound(); - // } + #endregion - // // Logic to add the product to a new table (e.g., selected products) + #region =================================================================== Project Allocation APIs =================================================================== - // if (allocationid == null) - // { - // // Add allocation - // ProjectAllocation allocation = new ProjectAllocation() - // { - // EmployeeId = employeeId, - // ProjectId = project.Id, - // AllocationDate = DateTime.UtcNow, - // //EmployeeRole = employee.Rol - // TenantId = project.TenantId - // }; - - // _unitOfWork.ProjectAllocation.CreateAsync(allocation); - // } - // else - // { - // //remove allocation - // var allocation = await _context.ProjectAllocations.FindAsync(allocationid); - // if (allocation != null) - // { - // allocation.ReAllocationDate = DateTime.UtcNow; - - // _unitOfWork.ProjectAllocation.UpdateAsync(allocation.Id, allocation); - // } - // else - // { - // return NotFound(); - // } - // } - - // return Ok(); - //} - - [HttpGet] - [Route("employees/get/{projectid?}/{includeInactive?}")] - public async Task GetEmployeeByProjectID(Guid? projectid, bool includeInactive = false) + [HttpGet("employees/get/{projectid?}/{includeInactive?}")] + public async Task GetEmployeeByProjectId(Guid? projectId, bool includeInactive = false) { + // --- Step 1: Input Validation --- if (!ModelState.IsValid) { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - - } - Guid TenantId = GetTenantId(); - - if (projectid != null) - { - // Fetch assigned project - List result = new List(); - - if ((bool)includeInactive) - { - - result = await (from rpm in _context.Employees.Include(c => c.JobRole) - join fp in _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == projectid) - on rpm.Id equals fp.EmployeeId - select rpm).ToListAsync(); - } - else - { - result = await (from rpm in _context.Employees.Include(c => c.JobRole) - join fp in _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == projectid && c.IsActive == true) - on rpm.Id equals fp.EmployeeId - select rpm).ToListAsync(); - } - - List resultVM = new List(); - foreach (Employee employee in result) - { - EmployeeVM vm = employee.ToEmployeeVMFromEmployee(); - resultVM.Add(vm); - } - - return Ok(ApiResponse.SuccessResponse(resultVM, "Success.", 200)); - } - else - { - return NotFound(ApiResponse.ErrorResponse("Invalid Input Parameter", 404)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get employee list by ProjectId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetEmployeeByProjectIdAsync(projectId, includeInactive, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } - [HttpGet] - [Route("allocation/{projectId}")] + [HttpGet("allocation/{projectId}")] public async Task GetProjectAllocation(Guid? projectId) { + // --- Step 1: Input Validation --- if (!ModelState.IsValid) { - var errors = ModelState.Values - .SelectMany(v => v.Errors) - .Select(e => e.ErrorMessage) - .ToList(); - return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); - + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get employee list by ProjectId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - Guid TenantId = GetTenantId(); - - var employees = await _context.ProjectAllocations - .Where(c => c.TenantId == TenantId && c.ProjectId == projectId && c.Employee != null) - .Include(e => e.Employee) - .Select(e => new - { - ID = e.Id, - EmployeeId = e.EmployeeId, - ProjectId = e.ProjectId, - AllocationDate = e.AllocationDate, - ReAllocationDate = e.ReAllocationDate, - FirstName = e.Employee != null ? e.Employee.FirstName : string.Empty, - LastName = e.Employee != null ? e.Employee.LastName : string.Empty, - MiddleName = e.Employee != null ? e.Employee.MiddleName : string.Empty, - IsActive = e.IsActive, - JobRoleId = (e.JobRoleId != null ? e.JobRoleId : e.Employee != null ? e.Employee.JobRoleId : null) - }).ToListAsync(); - - return Ok(ApiResponse.SuccessResponse(employees, "Success.", 200)); + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetProjectAllocationAsync(projectId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } [HttpPost("allocation")] - public async Task ManageAllocation(List projectAllocationDot) + public async Task ManageAllocation([FromBody] List projectAllocationDot) { - if (projectAllocationDot != null) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - Guid TenentID = GetTenantId(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - List? result = new List(); - List employeeIds = new List(); - List projectIds = new List(); - - foreach (var item in projectAllocationDot) - { - try - { - ProjectAllocation projectAllocation = item.ToProjectAllocationFromProjectAllocationDto(TenentID); - ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == projectAllocation.EmployeeId - && c.ProjectId == projectAllocation.ProjectId - && c.ReAllocationDate == null - && c.TenantId == TenentID).SingleOrDefaultAsync(); - - if (projectAllocationFromDb != null) - { - _context.ProjectAllocations.Attach(projectAllocationFromDb); - - if (item.Status) - { - projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; - projectAllocationFromDb.IsActive = true; - _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - } - else - { - projectAllocationFromDb.ReAllocationDate = DateTime.Now; - projectAllocationFromDb.IsActive = false; - _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - - employeeIds.Add(projectAllocation.EmployeeId); - projectIds.Add(projectAllocation.ProjectId); - } - await _context.SaveChangesAsync(); - var result1 = new - { - Id = projectAllocationFromDb.Id, - EmployeeId = projectAllocation.EmployeeId, - JobRoleId = projectAllocation.JobRoleId, - IsActive = projectAllocation.IsActive, - ProjectId = projectAllocation.ProjectId, - AllocationDate = projectAllocation.AllocationDate, - ReAllocationDate = projectAllocation.ReAllocationDate, - TenantId = projectAllocation.TenantId - }; - result.Add(result1); - } - else - { - projectAllocation.AllocationDate = DateTime.Now; - projectAllocation.IsActive = true; - _context.ProjectAllocations.Add(projectAllocation); - await _context.SaveChangesAsync(); - - employeeIds.Add(projectAllocation.EmployeeId); - projectIds.Add(projectAllocation.ProjectId); - } - await _cache.ClearAllProjectIds(item.EmpID); - - } - catch (Exception ex) - { - return Ok(ApiResponse.ErrorResponse(ex.Message, ex, 400)); - } - } - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeList = employeeIds }; - - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); - - } - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Work Item Details are not valid.", 400)); - - } - - - [HttpGet("infra-details/{projectId}")] - public async Task GetInfraDetails(Guid projectId) - { - _logger.LogInfo("GetInfraDetails called for ProjectId: {ProjectId}", projectId); - - // Step 1: Get logged-in employee - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // Step 2: Check project-specific permission - var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.ToString()); - if (!hasProjectPermission) - { - _logger.LogWarning("Project access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - // Step 3: Check 'ViewInfra' permission - var hasViewInfraPermission = await _permission.HasPermission(ViewInfra, loggedInEmployee.Id); - if (!hasViewInfraPermission) + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.ManageAllocationAsync(projectAllocationDot, tenantId, loggedInEmployee); + if (response.Success) { - _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have access to view infra", 403)); + List employeeIds = response.Data.Select(pa => pa.EmployeeId).ToList(); + List projectIds = response.Data.Select(pa => pa.ProjectId).ToList(); + + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeList = employeeIds }; + await _signalR.SendNotificationAsync(notification); } - var result = await _cache.GetBuildingInfra(projectId); - if (result == null) - { - - // Step 4: Fetch buildings for the project - var buildings = await _context.Buildings - .Where(b => b.ProjectId == projectId) - .ToListAsync(); - - var buildingIds = buildings.Select(b => b.Id).ToList(); - - // Step 5: Fetch floors associated with the buildings - var floors = await _context.Floor - .Where(f => buildingIds.Contains(f.BuildingId)) - .ToListAsync(); - - var floorIds = floors.Select(f => f.Id).ToList(); - - // Step 6: Fetch work areas associated with the floors - var workAreas = await _context.WorkAreas - .Where(wa => floorIds.Contains(wa.FloorId)) - .ToListAsync(); - var workAreaIds = workAreas.Select(wa => wa.Id).ToList(); - - // Step 7: Fetch work items associated with the work area - var workItems = await _context.WorkItems - .Where(wi => workAreaIds.Contains(wi.WorkAreaId)) - .ToListAsync(); - - // Step 8: Build the infra hierarchy (Building > Floors > Work Areas) - List Buildings = new List(); - foreach (var building in buildings) - { - double buildingPlannedWorks = 0; - double buildingCompletedWorks = 0; - - var selectedFloors = floors.Where(f => f.BuildingId == building.Id).ToList(); - List Floors = new List(); - foreach (var floor in selectedFloors) - { - double floorPlannedWorks = 0; - double floorCompletedWorks = 0; - var selectedWorkAreas = workAreas.Where(wa => wa.FloorId == floor.Id).ToList(); - List WorkAreas = new List(); - foreach (var workArea in selectedWorkAreas) - { - double workAreaPlannedWorks = 0; - double workAreaCompletedWorks = 0; - var selectedWorkItems = workItems.Where(wi => wi.WorkAreaId == workArea.Id).ToList(); - foreach (var workItem in selectedWorkItems) - { - workAreaPlannedWorks += workItem.PlannedWork; - workAreaCompletedWorks += workItem.CompletedWork; - } - WorkAreaMongoDB workAreaMongo = new WorkAreaMongoDB - { - Id = workArea.Id.ToString(), - AreaName = workArea.AreaName, - PlannedWork = workAreaPlannedWorks, - CompletedWork = workAreaCompletedWorks - }; - WorkAreas.Add(workAreaMongo); - floorPlannedWorks += workAreaPlannedWorks; - floorCompletedWorks += workAreaCompletedWorks; - } - FloorMongoDB floorMongoDB = new FloorMongoDB - { - Id = floor.Id.ToString(), - FloorName = floor.FloorName, - PlannedWork = floorPlannedWorks, - CompletedWork = floorCompletedWorks, - WorkAreas = WorkAreas - }; - Floors.Add(floorMongoDB); - buildingPlannedWorks += floorPlannedWorks; - buildingCompletedWorks += floorCompletedWorks; - } - - var buildingMongo = new BuildingMongoDB - { - Id = building.Id.ToString(), - BuildingName = building.Name, - Description = building.Description, - PlannedWork = buildingPlannedWorks, - CompletedWork = buildingCompletedWorks, - Floors = Floors - }; - Buildings.Add(buildingMongo); - } - result = Buildings; - } - - _logger.LogInfo("Infra details fetched successfully for ProjectId: {ProjectId}, EmployeeId: {EmployeeId}, Buildings: {Count}", - projectId, loggedInEmployee.Id, result.Count); - - return Ok(ApiResponse.SuccessResponse(result, "Infra details fetched successfully", 200)); - } - - [HttpGet("tasks/{workAreaId}")] - public async Task GetWorkItems(Guid workAreaId) - { - _logger.LogInfo("GetWorkItems called for WorkAreaId: {WorkAreaId}", workAreaId); - - // Step 1: Get the currently logged-in employee - var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - // Step 2: Check if the employee has ViewInfra permission - var hasViewInfraPermission = await _permission.HasPermission(ViewInfra, loggedInEmployee.Id); - if (!hasViewInfraPermission) - { - _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); - return StatusCode(403, ApiResponse.ErrorResponse("Access denied", "You don't have permission to view infrastructure", 403)); - } - - // Step 3: Check if the specified Work Area exists - var isWorkAreaExist = await _context.WorkAreas.AnyAsync(wa => wa.Id == workAreaId); - if (!isWorkAreaExist) - { - _logger.LogWarning("Work Area not found for WorkAreaId: {WorkAreaId}", workAreaId); - return NotFound(ApiResponse.ErrorResponse("Work Area not found", "Work Area not found in database", 404)); - } - - // Step 4: Fetch WorkItems with related Activity and Work Category data - var workItemVMs = await _cache.GetWorkItemDetailsByWorkArea(workAreaId); - if (workItemVMs == null) - { - var workItems = await _context.WorkItems - .Include(wi => wi.ActivityMaster) - .Include(wi => wi.WorkCategoryMaster) - .Where(wi => wi.WorkAreaId == workAreaId) - .ToListAsync(); - - workItemVMs = workItems.Select(wi => new WorkItemMongoDB - { - Id = wi.Id.ToString(), - WorkAreaId = wi.WorkAreaId.ToString(), - ParentTaskId = wi.ParentTaskId.ToString(), - ActivityMaster = new ActivityMasterMongoDB - { - Id = wi.ActivityId.ToString(), - ActivityName = wi.ActivityMaster != null ? wi.ActivityMaster.ActivityName : null, - UnitOfMeasurement = wi.ActivityMaster != null ? wi.ActivityMaster.UnitOfMeasurement : null - }, - WorkCategoryMaster = new WorkCategoryMasterMongoDB - { - Id = wi.WorkCategoryId.ToString() ?? "", - Name = wi.WorkCategoryMaster != null ? wi.WorkCategoryMaster.Name : "", - Description = wi.WorkCategoryMaster != null ? wi.WorkCategoryMaster.Description : "" - }, - PlannedWork = wi.PlannedWork, - CompletedWork = wi.CompletedWork, - Description = wi.Description, - TaskDate = wi.TaskDate, - }).ToList(); - - await _cache.ManageWorkItemDetails(workItems); - } - - _logger.LogInfo("{Count} work items fetched successfully for WorkAreaId: {WorkAreaId}", workItemVMs.Count, workAreaId); - - // Step 5: Return result - return Ok(ApiResponse.SuccessResponse(workItemVMs, $"{workItemVMs.Count} records of tasks fetched successfully", 200)); - } - - [HttpPost("task")] - public async Task CreateProjectTask(List workItemDtos) - { - _logger.LogInfo("CreateProjectTask called with {Count} items", workItemDtos?.Count ?? 0); - - // Validate request - if (workItemDtos == null || !workItemDtos.Any()) - { - _logger.LogWarning("No work items provided in the request."); - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Work Item details are not valid.", 400)); - } - - Guid tenantId = GetTenantId(); - var workItemsToCreate = new List(); - var workItemsToUpdate = new List(); - var responseList = new List(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - string message = ""; - List workAreaIds = new List(); - var workItemIds = workItemDtos.Where(wi => wi.Id != null && wi.Id != Guid.Empty).Select(wi => wi.Id).ToList(); - var workItems = await _context.WorkItems.AsNoTracking().Where(wi => workItemIds.Contains(wi.Id)).ToListAsync(); - - foreach (var itemDto in workItemDtos) - { - var workItem = itemDto.ToWorkItemFromWorkItemDto(tenantId); - var workArea = await _context.WorkAreas.Include(a => a.Floor).FirstOrDefaultAsync(a => a.Id == workItem.WorkAreaId) ?? new WorkArea(); - - Building building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == (workArea.Floor != null ? workArea.Floor.BuildingId : Guid.Empty)) ?? new Building(); - - if (itemDto.Id != null && itemDto.Id != Guid.Empty) - { - // Update existing - workItemsToUpdate.Add(workItem); - message = $"Task Updated in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}"; - var existingWorkItem = workItems.FirstOrDefault(wi => wi.Id == workItem.Id); - double plannedWork = 0; - double completedWork = 0; - if (existingWorkItem != null) - { - if (existingWorkItem.PlannedWork != workItem.PlannedWork && existingWorkItem.CompletedWork != workItem.CompletedWork) - { - plannedWork = workItem.PlannedWork - existingWorkItem.PlannedWork; - completedWork = workItem.CompletedWork - existingWorkItem.CompletedWork; - } - else if (existingWorkItem.PlannedWork == workItem.PlannedWork && existingWorkItem.CompletedWork != workItem.CompletedWork) - { - plannedWork = 0; - completedWork = workItem.CompletedWork - existingWorkItem.CompletedWork; - } - else if (existingWorkItem.PlannedWork != workItem.PlannedWork && existingWorkItem.CompletedWork == workItem.CompletedWork) - { - plannedWork = workItem.PlannedWork - existingWorkItem.PlannedWork; - completedWork = 0; - } - await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, plannedWork, completedWork); - } - } - else - { - // Create new - workItem.Id = Guid.NewGuid(); - workItemsToCreate.Add(workItem); - message = $"Task Added in Building: {building.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}"; - await _cache.UpdatePlannedAndCompleteWorksInBuilding(workArea.Id, workItem.PlannedWork, workItem.CompletedWork); - } - - responseList.Add(new WorkItemVM - { - WorkItemId = workItem.Id, - WorkItem = workItem - }); - workAreaIds.Add(workItem.WorkAreaId); - - } - string responseMessage = ""; - // Apply DB changes - if (workItemsToCreate.Any()) - { - _logger.LogInfo("Adding {Count} new work items", workItemsToCreate.Count); - await _context.WorkItems.AddRangeAsync(workItemsToCreate); - responseMessage = "Task Added Successfully"; - await _cache.ManageWorkItemDetails(workItemsToCreate); - } - - if (workItemsToUpdate.Any()) - { - _logger.LogInfo("Updating {Count} existing work items", workItemsToUpdate.Count); - _context.WorkItems.UpdateRange(workItemsToUpdate); - responseMessage = "Task Updated Successfully"; - await _cache.ManageWorkItemDetails(workItemsToUpdate); - } - - await _context.SaveChangesAsync(); - - _logger.LogInfo("CreateProjectTask completed successfully. Created: {Created}, Updated: {Updated}", workItemsToCreate.Count, workItemsToUpdate.Count); - - - - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = message }; - - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - - return Ok(ApiResponse.SuccessResponse(responseList, responseMessage, 200)); - } - - [HttpDelete("task/{id}")] - public async Task DeleteProjectTask(Guid id) - { - Guid tenantId = _userHelper.GetTenantId(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - List workAreaIds = new List(); - WorkItem? task = await _context.WorkItems.AsNoTracking().Include(t => t.WorkArea).FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); - if (task != null) - { - if (task.CompletedWork == 0) - { - var assignedTask = await _context.TaskAllocations.Where(t => t.WorkItemId == id).ToListAsync(); - if (assignedTask.Count == 0) - { - _context.WorkItems.Remove(task); - await _context.SaveChangesAsync(); - _logger.LogInfo("Task with ID {WorkItemId} has been successfully deleted.", task.Id); - - var floorId = task.WorkArea?.FloorId; - var floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == floorId); - - - workAreaIds.Add(task.WorkAreaId); - - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = $"Task Deleted in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}, in Area: {task.WorkArea?.AreaName} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}" }; - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - } - else - { - _logger.LogWarning("Task with ID {WorkItemId} is currently assigned and cannot be deleted.", task.Id); - return BadRequest(ApiResponse.ErrorResponse("Task is currently assigned and cannot be deleted.", "Task is currently assigned and cannot be deleted.", 400)); - } - } - else - { - double percentage = (task.CompletedWork / task.PlannedWork) * 100; - percentage = Math.Round(percentage, 2); - _logger.LogWarning("Task with ID {WorkItemId} is {CompletionPercentage}% complete and cannot be deleted", task.Id, percentage); - return BadRequest(ApiResponse.ErrorResponse(System.String.Format("Task is {0}% complete and cannot be deleted", percentage), System.String.Format("Task is {0}% complete and cannot be deleted", percentage), 400)); - - } - } - else - { - _logger.LogError("Task with ID {WorkItemId} not found ID database", id); - } - return Ok(ApiResponse.SuccessResponse(new { }, "Task deleted successfully", 200)); - } - - [HttpPost("manage-infra")] - public async Task ManageProjectInfra(List infraDots) - { - Guid tenantId = GetTenantId(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - - var responseData = new InfraVM { }; - string responseMessage = ""; - string message = ""; - List projectIds = new List(); - if (infraDots != null) - { - foreach (var item in infraDots) - { - if (item.Building != null) - { - - Building building = item.Building.ToBuildingFromBuildingDto(tenantId); - building.TenantId = GetTenantId(); - - if (item.Building.Id == null) - { - //create - _context.Buildings.Add(building); - await _context.SaveChangesAsync(); - responseData.building = building; - responseMessage = "Buliding Added Successfully"; - message = "Building Added"; - await _cache.AddBuildngInfra(building.ProjectId, building); - } - else - { - //update - _context.Buildings.Update(building); - await _context.SaveChangesAsync(); - responseData.building = building; - responseMessage = "Buliding Updated Successfully"; - message = "Building Updated"; - await _cache.UpdateBuildngInfra(building.ProjectId, building); - } - projectIds.Add(building.ProjectId); - } - if (item.Floor != null) - { - Floor floor = item.Floor.ToFloorFromFloorDto(tenantId); - floor.TenantId = GetTenantId(); - bool isCreated = false; - - if (item.Floor.Id == null) - { - //create - _context.Floor.Add(floor); - await _context.SaveChangesAsync(); - responseData.floor = floor; - responseMessage = "Floor Added Successfully"; - message = "Floor Added"; - isCreated = true; - } - else - { - //update - _context.Floor.Update(floor); - await _context.SaveChangesAsync(); - responseData.floor = floor; - responseMessage = "Floor Updated Successfully"; - message = "Floor Updated"; - } - Building? building = await _context.Buildings.FirstOrDefaultAsync(b => b.Id == floor.BuildingId); - var projectId = building?.ProjectId ?? Guid.Empty; - projectIds.Add(projectId); - message = $"{message} in Building: {building?.Name}"; - if (isCreated) - { - await _cache.AddBuildngInfra(projectId, floor: floor); - } - else - { - await _cache.UpdateBuildngInfra(projectId, floor: floor); - } - } - if (item.WorkArea != null) - { - WorkArea workArea = item.WorkArea.ToWorkAreaFromWorkAreaDto(tenantId); - workArea.TenantId = GetTenantId(); - bool isCreated = false; - - if (item.WorkArea.Id == null) - { - //create - _context.WorkAreas.Add(workArea); - await _context.SaveChangesAsync(); - responseData.workArea = workArea; - responseMessage = "Work Area Added Successfully"; - message = "Work Area Added"; - isCreated = true; - } - else - { - //update - _context.WorkAreas.Update(workArea); - await _context.SaveChangesAsync(); - responseData.workArea = workArea; - responseMessage = "Work Area Updated Successfully"; - message = "Work Area Updated"; - } - Floor? floor = await _context.Floor.Include(f => f.Building).FirstOrDefaultAsync(f => f.Id == workArea.FloorId); - var projectId = floor?.Building?.ProjectId ?? Guid.Empty; - projectIds.Add(projectId); - message = $"{message} in Building: {floor?.Building?.Name}, on Floor: {floor?.FloorName}"; - if (isCreated) - { - await _cache.AddBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - } - else - { - await _cache.UpdateBuildngInfra(projectId, workArea: workArea, buildingId: floor?.BuildingId); - } - } - } - message = $"{message} by {LoggedInEmployee.FirstName} {LoggedInEmployee.LastName}"; - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds, Message = message }; - - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - return Ok(ApiResponse.SuccessResponse(responseData, responseMessage, 200)); - } - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Infra Details are not valid.", 400)); + return StatusCode(response.StatusCode, response); } [HttpGet("assigned-projects/{employeeId}")] public async Task GetProjectsByEmployee([FromRoute] Guid employeeId) { - - Guid tenantId = _userHelper.GetTenantId(); - if (employeeId == Guid.Empty) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "Employee id not valid.", 400)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get project list by employee Id called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - List projectList = await _context.ProjectAllocations - .Where(c => c.TenantId == tenantId && c.EmployeeId == employeeId && c.IsActive) - .Select(c => c.ProjectId).Distinct() - .ToListAsync(); - - if (!projectList.Any()) - { - return NotFound(ApiResponse.SuccessResponse(new List(), "No projects found.", 200)); - } - - - List projectlist = await _context.Projects - .Where(p => projectList.Contains(p.Id)) - .ToListAsync(); - - List projects = new List(); - - - foreach (var project in projectlist) - { - - projects.Add(project.ToProjectListVMFromProject()); - } - - - - return Ok(ApiResponse.SuccessResponse(projects, "Success.", 200)); + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetProjectsByEmployeeAsync(employeeId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } [HttpPost("assign-projects/{employeeId}")] - public async Task AssigneProjectsToEmployee([FromBody] List projectAllocationDtos, [FromRoute] Guid employeeId) + public async Task AssigneProjectsToEmployee([FromBody] List projectAllocationDtos, [FromRoute] Guid employeeId) { - if (projectAllocationDtos != null && employeeId != Guid.Empty) + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) { - Guid TenentID = GetTenantId(); - var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); - List? result = new List(); - List projectIds = new List(); - - foreach (var projectAllocationDto in projectAllocationDtos) - { - try - { - ProjectAllocation projectAllocation = projectAllocationDto.ToProjectAllocationFromProjectsAllocationDto(TenentID, employeeId); - ProjectAllocation? projectAllocationFromDb = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.ProjectId == projectAllocationDto.ProjectId && c.ReAllocationDate == null && c.TenantId == TenentID).SingleOrDefaultAsync(); - - if (projectAllocationFromDb != null) - { - - - _context.ProjectAllocations.Attach(projectAllocationFromDb); - - if (projectAllocationDto.Status) - { - projectAllocationFromDb.JobRoleId = projectAllocation.JobRoleId; ; - projectAllocationFromDb.IsActive = true; - _context.Entry(projectAllocationFromDb).Property(e => e.JobRoleId).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - } - else - { - projectAllocationFromDb.ReAllocationDate = DateTime.UtcNow; - projectAllocationFromDb.IsActive = false; - _context.Entry(projectAllocationFromDb).Property(e => e.ReAllocationDate).IsModified = true; - _context.Entry(projectAllocationFromDb).Property(e => e.IsActive).IsModified = true; - - projectIds.Add(projectAllocation.ProjectId); - } - await _context.SaveChangesAsync(); - var result1 = new - { - Id = projectAllocationFromDb.Id, - EmployeeId = projectAllocation.EmployeeId, - JobRoleId = projectAllocation.JobRoleId, - IsActive = projectAllocation.IsActive, - ProjectId = projectAllocation.ProjectId, - AllocationDate = projectAllocation.AllocationDate, - ReAllocationDate = projectAllocation.ReAllocationDate, - TenantId = projectAllocation.TenantId - }; - result.Add(result1); - } - else - { - projectAllocation.AllocationDate = DateTime.Now; - projectAllocation.IsActive = true; - _context.ProjectAllocations.Add(projectAllocation); - await _context.SaveChangesAsync(); - - projectIds.Add(projectAllocation.ProjectId); - - } - - - } - catch (Exception ex) - { - - return Ok(ApiResponse.ErrorResponse(ex.Message, ex, 400)); - } - } - await _cache.ClearAllProjectIds(employeeId); - var notification = new { LoggedInUserId = LoggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeId = employeeId }; - - await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); - - return Ok(ApiResponse.SuccessResponse(result, "Data saved successfully", 200)); + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); } - else + + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.AssigneProjectsToEmployeeAsync(projectAllocationDtos, employeeId, tenantId, loggedInEmployee); + if (response.Success) { - return BadRequest(ApiResponse.ErrorResponse("Invalid details.", "All Field is required", 400)); + List projectIds = response.Data.Select(pa => pa.ProjectId).ToList(); + + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Assign_Project", ProjectIds = projectIds, EmployeeId = employeeId }; + await _signalR.SendNotificationAsync(notification); } + return StatusCode(response.StatusCode, response); + } + + #endregion + + #region =================================================================== Project InfraStructure Get APIs =================================================================== + + [HttpGet("infra-details/{projectId}")] + public async Task GetInfraDetails(Guid projectId) + { + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) + { + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get Project Infrastructure by ProjectId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + } + + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetInfraDetailsAsync(projectId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); } + [HttpGet("tasks/{workAreaId}")] + public async Task GetWorkItems(Guid workAreaId) + { + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) + { + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("Get Work Items by WorkAreaId called with invalid model state \n Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + } + + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.GetWorkItemsAsync(workAreaId, tenantId, loggedInEmployee); + return StatusCode(response.StatusCode, response); + } + + #endregion + + #region =================================================================== Project Infrastructre Manage APIs =================================================================== + + [HttpPost("manage-infra")] + public async Task ManageProjectInfra(List infraDtos) + { + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) + { + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + } + + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var serviceResponse = await _projectServices.ManageProjectInfraAsync(infraDtos, tenantId, loggedInEmployee); + var response = serviceResponse.Response; + var notification = serviceResponse.Notification; + if (notification != null) + { + await _signalR.SendNotificationAsync(notification); + } + return StatusCode(response.StatusCode, response); + + } + + [HttpPost("task")] + public async Task CreateProjectTask([FromBody] List workItemDtos) + { + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) + { + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + } + + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var response = await _projectServices.CreateProjectTaskAsync(workItemDtos, tenantId, loggedInEmployee); + if (response.Success) + { + List workAreaIds = response.Data.Select(pa => pa.WorkItem?.WorkAreaId ?? Guid.Empty).ToList(); + string message = response.Message; + + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "WorkItem", WorkAreaIds = workAreaIds, Message = message }; + await _signalR.SendNotificationAsync(notification); + } + return StatusCode(response.StatusCode, response); + + } + + [HttpDelete("task/{id}")] + public async Task DeleteProjectTask(Guid id) + { + // --- Step 1: Input Validation --- + if (!ModelState.IsValid) + { + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + _logger.LogWarning("project Alocation called with invalid model state for list of projects. Errors: {Errors}", string.Join(", ", errors)); + return BadRequest(ApiResponse.ErrorResponse("Invalid request data provided.", errors, 400)); + } + + // --- Step 2: Prepare data without I/O --- + Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + var serviceResponse = await _projectServices.DeleteProjectTaskAsync(id, tenantId, loggedInEmployee); + var response = serviceResponse.Response; + var notification = serviceResponse.Notification; + if (notification != null) + { + await _signalR.SendNotificationAsync(notification); + } + return StatusCode(response.StatusCode, response); + } + + #endregion } } \ No newline at end of file diff --git a/Marco.Pms.Services/Controllers/ReportController.cs b/Marco.Pms.Services/Controllers/ReportController.cs index 11dec58..a46c391 100644 --- a/Marco.Pms.Services/Controllers/ReportController.cs +++ b/Marco.Pms.Services/Controllers/ReportController.cs @@ -1,16 +1,19 @@ -using System.Data; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Mail; -using Marco.Pms.Model.Employees; using Marco.Pms.Model.Mail; +using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Utilities; using Marco.Pms.Services.Helpers; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; using MongoDB.Driver; +using System.Data; +using System.Globalization; +using System.Net.Mail; namespace Marco.Pms.Services.Controllers { @@ -25,7 +28,11 @@ namespace Marco.Pms.Services.Controllers private readonly UserHelper _userHelper; private readonly IWebHostEnvironment _env; private readonly ReportHelper _reportHelper; - public ReportController(ApplicationDbContext context, IEmailSender emailSender, ILoggingService logger, UserHelper userHelper, IWebHostEnvironment env, ReportHelper reportHelper) + private readonly IConfiguration _configuration; + private readonly CacheUpdateHelper _cache; + private readonly IServiceScopeFactory _serviceScopeFactory; + public ReportController(ApplicationDbContext context, IEmailSender emailSender, ILoggingService logger, UserHelper userHelper, + IWebHostEnvironment env, ReportHelper reportHelper, IConfiguration configuration, CacheUpdateHelper cache, IServiceScopeFactory serviceScopeFactory) { _context = context; _emailSender = emailSender; @@ -33,27 +40,122 @@ namespace Marco.Pms.Services.Controllers _userHelper = userHelper; _env = env; _reportHelper = reportHelper; + _configuration = configuration; + _cache = cache; + _serviceScopeFactory = serviceScopeFactory; } - [HttpPost("set-mail")] + /// + /// Adds new mail details for a project report. + /// + /// The mail details data. + /// An API response indicating success or failure. + [HttpPost("mail-details")] // More specific route for adding mail details public async Task AddMailDetails([FromBody] MailDetailsDto mailDetailsDto) { + // 1. Get Tenant ID and Basic Authorization Check Guid tenantId = _userHelper.GetTenantId(); - MailDetails mailDetails = new MailDetails + if (tenantId == Guid.Empty) + { + _logger.LogWarning("Authorization Error: Attempt to add mail details with an empty or invalid tenant ID."); + return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "Tenant ID not found or invalid.", 401)); + } + + // 2. Input Validation (Leverage Model Validation attributes on DTO) + if (mailDetailsDto == null) + { + _logger.LogWarning("Validation Error: MailDetails DTO is null. TenantId: {TenantId}", tenantId); + return BadRequest(ApiResponse.ErrorResponse("Invalid Data", "Request body is empty.", 400)); + } + + // Ensure ProjectId and Recipient are not empty + if (mailDetailsDto.ProjectId == Guid.Empty) + { + _logger.LogWarning("Validation Error: Project ID is empty. TenantId: {TenantId}", tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Project ID cannot be empty.", 400)); + } + + if (string.IsNullOrWhiteSpace(mailDetailsDto.Recipient)) + { + _logger.LogWarning("Validation Error: Recipient email is empty. ProjectId: {ProjectId}, TenantId: {TenantId}", mailDetailsDto.ProjectId, tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Recipient email cannot be empty.", 400)); + } + + // Optional: Validate email format using regex or System.Net.Mail.MailAddress + try + { + var mailAddress = new MailAddress(mailDetailsDto.Recipient); + _logger.LogInfo("nothing"); + } + catch (FormatException) + { + _logger.LogWarning("Validation Error: Invalid recipient email format '{Recipient}'. ProjectId: {ProjectId}, TenantId: {TenantId}", mailDetailsDto.Recipient, mailDetailsDto.ProjectId, tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Invalid recipient email format.", 400)); + } + + // 3. Validate MailListId (Foreign Key Check) + // Ensure the MailListId refers to an existing MailBody (template) within the same tenant. + if (mailDetailsDto.MailListId != Guid.Empty) // Only validate if a MailListId is provided + { + bool mailTemplateExists; + try + { + mailTemplateExists = await _context.MailingList + .AsNoTracking() + .AnyAsync(m => m.Id == mailDetailsDto.MailListId && m.TenantId == tenantId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Database Error: Failed to check existence of MailListId '{MailListId}' for TenantId: {TenantId}.", mailDetailsDto.MailListId, tenantId); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while validating mail template.", 500)); + } + + if (!mailTemplateExists) + { + _logger.LogWarning("Validation Error: Provided MailListId '{MailListId}' does not exist or does not belong to TenantId: {TenantId}.", mailDetailsDto.MailListId, tenantId); + return NotFound(ApiResponse.ErrorResponse("Invalid Mail Template", "The specified mail template (MailListId) was not found or accessible.", 404)); + } + } + // If MailListId can be null/empty and implies no specific template, adjust logic accordingly. + // Currently assumes it must exist if provided. + + // 4. Create and Add New Mail Details + var newMailDetails = new MailDetails { ProjectId = mailDetailsDto.ProjectId, Recipient = mailDetailsDto.Recipient, Schedule = mailDetailsDto.Schedule, MailListId = mailDetailsDto.MailListId, - TenantId = tenantId + TenantId = tenantId, }; - _context.MailDetails.Add(mailDetails); - await _context.SaveChangesAsync(); - return Ok("Success"); + + try + { + _context.MailDetails.Add(newMailDetails); + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully added new mail details with ID {MailDetailsId} for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}.", newMailDetails.Id, newMailDetails.ProjectId, newMailDetails.Recipient, tenantId); + + // 5. Return Success Response (201 Created is ideal for resource creation) + return StatusCode(201, ApiResponse.SuccessResponse( + newMailDetails, // Return the newly created object (or a DTO of it) + "Mail details added successfully.", + 201)); + } + catch (DbUpdateException dbEx) + { + _logger.LogError(dbEx, "Database Error: Failed to save new mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}.", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId); + // Check for specific constraint violations if applicable (e.g., duplicate recipient for a project) + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while saving the mail details.", 500)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected Error: An unhandled exception occurred while adding mail details for ProjectId: {ProjectId}, Recipient: '{Recipient}', TenantId: {TenantId}.", newMailDetails.ProjectId, newMailDetails.Recipient, tenantId); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500)); + } } - [HttpPost("mail-template")] - public async Task AddMailTemplate([FromBody] MailTemeplateDto mailTemeplateDto) + [HttpPost("mail-template1")] + public async Task AddMailTemplate1([FromBody] MailTemeplateDto mailTemeplateDto) { Guid tenantId = _userHelper.GetTenantId(); if (string.IsNullOrWhiteSpace(mailTemeplateDto.Body) && string.IsNullOrWhiteSpace(mailTemeplateDto.Title)) @@ -80,116 +182,298 @@ namespace Marco.Pms.Services.Controllers return Ok("Success"); } + /// + /// Adds a new mail template. + /// + /// The mail template data. + /// An API response indicating success or failure. + [HttpPost("mail-template")] // More specific route for adding a template + public async Task AddMailTemplate([FromBody] MailTemeplateDto mailTemplateDto) // Renamed parameter for consistency + { + // 1. Get Tenant ID and Basic Authorization Check + Guid tenantId = _userHelper.GetTenantId(); + if (tenantId == Guid.Empty) + { + _logger.LogWarning("Authorization Error: Attempt to add mail template with an empty or invalid tenant ID."); + return Unauthorized(ApiResponse.ErrorResponse("Unauthorized", "Tenant ID not found or invalid.", 401)); + } + + // 2. Input Validation (Moved to model validation if possible, or keep explicit) + // Use proper model validation attributes ([Required], [StringLength]) on MailTemeplateDto + // and rely on ASP.NET Core's automatic model validation if possible. + // If not, these checks are good. + if (mailTemplateDto == null) + { + _logger.LogWarning("Validation Error: Mail template DTO is null."); + return BadRequest(ApiResponse.ErrorResponse("Invalid Data", "Request body is empty.", 400)); + } + + if (string.IsNullOrWhiteSpace(mailTemplateDto.Title)) + { + _logger.LogWarning("Validation Error: Mail template title is empty or whitespace. TenantId: {TenantId}", tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Mail template title cannot be empty.", 400)); + } + + // The original logic checked both body and title, but often a template needs at least a title. + // Re-evalute if body can be empty. If so, remove the body check. Assuming title is always mandatory. + // If both body and title are empty, it's definitely invalid. + if (string.IsNullOrWhiteSpace(mailTemplateDto.Body) && string.IsNullOrWhiteSpace(mailTemplateDto.Subject)) + { + _logger.LogWarning("Validation Error: Mail template body and subject are both empty or whitespace for title '{Title}'. TenantId: {TenantId}", mailTemplateDto.Title, tenantId); + return BadRequest(ApiResponse.ErrorResponse("Validation Failed", "Mail template body or subject must be provided.", 400)); + } + + // 3. Check for Existing Template Title (Case-Insensitive) + // Use AsNoTracking() for read-only query + MailingList? existingTemplate; + try + { + existingTemplate = await _context.MailingList + .AsNoTracking() // Important for read-only checks + .FirstOrDefaultAsync(t => t.Title.ToLower() == mailTemplateDto.Title.ToLower() && t.TenantId == tenantId); // IMPORTANT: Filter by TenantId! + } + catch (Exception ex) + { + _logger.LogError(ex, "Database Error: Failed to check for existing mail template with title '{Title}' for TenantId: {TenantId}.", mailTemplateDto.Title, tenantId); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while checking for existing templates.", 500)); + } + + + if (existingTemplate != null) + { + _logger.LogWarning("Conflict Error: User tries to add email template with title '{Title}' which already exists for TenantId: {TenantId}.", mailTemplateDto.Title, tenantId); + return Conflict(ApiResponse.ErrorResponse("Conflict", $"Email template with title '{mailTemplateDto.Title}' already exists.", 409)); + } + + // 4. Create and Add New Template + var newMailingList = new MailingList + { + Title = mailTemplateDto.Title, + Body = mailTemplateDto.Body, + Subject = mailTemplateDto.Subject, + Keywords = mailTemplateDto.Keywords, + TenantId = tenantId, + }; + + try + { + _context.MailingList.Add(newMailingList); + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully added new mail template with ID {TemplateId} and title '{Title}' for TenantId: {TenantId}.", newMailingList.Id, newMailingList.Title, tenantId); + + // 5. Return Success Response (201 Created is ideal for resource creation) + // It's good practice to return the created resource or its ID. + return StatusCode(201, ApiResponse.SuccessResponse( + newMailingList, // Return the newly created object (or a DTO of it) + "Mail template added successfully.", + 201)); + } + catch (DbUpdateException dbEx) + { + _logger.LogError(dbEx, "Database Error: Failed to save new mail template '{Title}' for TenantId: {TenantId}. : {Error}", mailTemplateDto.Title, tenantId); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An error occurred while saving the mail template.", 500)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected Error: An unhandled exception occurred while adding mail template '{Title}' for TenantId: {TenantId}.", mailTemplateDto.Title, tenantId); + return StatusCode(500, ApiResponse.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500)); + } + } + [HttpGet("project-statistics")] public async Task SendProjectReport() { Guid tenantId = _userHelper.GetTenantId(); - // Use AsNoTracking() for read-only queries to improve performance - List mailDetails = await _context.MailDetails + // 1. OPTIMIZATION: Perform grouping and projection on the database server. + // This is far more efficient than loading all entities into memory. + var projectMailGroups = await _context.MailDetails .AsNoTracking() - .Include(m => m.MailBody) .Where(m => m.TenantId == tenantId) - .ToListAsync(); - - int successCount = 0; - int notFoundCount = 0; - int invalidIdCount = 0; - - var groupedMails = mailDetails .GroupBy(m => new { m.ProjectId, m.MailListId }) .Select(g => new { ProjectId = g.Key.ProjectId, - MailListId = g.Key.MailListId, Recipients = g.Select(m => m.Recipient).Distinct().ToList(), - MailBody = g.FirstOrDefault()?.MailBody?.Body ?? "", - Subject = g.FirstOrDefault()?.MailBody?.Subject ?? string.Empty, + // Project the mail body and subject from the first record in the group + MailInfo = g.Select(m => new { Body = m.MailBody != null ? m.MailBody.Body : "", Subject = m.MailBody != null ? m.MailBody.Subject : "" }).FirstOrDefault() }) - .ToList(); + .ToListAsync(); - var semaphore = new SemaphoreSlim(1); - - // Using Task.WhenAll to send reports concurrently for better performance - var sendTasks = groupedMails.Select(async mailDetail => + if (!projectMailGroups.Any()) { - await semaphore.WaitAsync(); - try + return Ok(ApiResponse.SuccessResponse(new { }, "No projects found to send reports for.", 200)); + } + + int successCount = 0; + int notFoundCount = 0; + int invalidIdCount = 0; + int failureCount = 0; + + // 2. OPTIMIZATION: Use true concurrency by removing SemaphoreSlim(1) + // and giving each task its own isolated set of services (including DbContext). + var sendTasks = projectMailGroups.Select(async mailGroup => + { + // SOLUTION: Create a new Dependency Injection scope for each parallel task. + using (var scope = _serviceScopeFactory.CreateScope()) { - var response = await GetProjectStatistics(mailDetail.ProjectId, mailDetail.Recipients, mailDetail.MailBody, mailDetail.Subject, tenantId); - if (response.StatusCode == 200) - Interlocked.Increment(ref successCount); - else if (response.StatusCode == 404) - Interlocked.Increment(ref notFoundCount); - else if (response.StatusCode == 400) - Interlocked.Increment(ref invalidIdCount); - } - finally - { - semaphore.Release(); + // Resolve a new instance of the helper from this isolated scope. + // This ensures each task gets its own thread-safe DbContext. + var reportHelper = scope.ServiceProvider.GetRequiredService(); + + try + { + // Ensure MailInfo and ProjectId are valid before proceeding + if (mailGroup.MailInfo == null || mailGroup.ProjectId == Guid.Empty) + { + Interlocked.Increment(ref invalidIdCount); + return; + } + + var response = await reportHelper.GetProjectStatistics( + mailGroup.ProjectId, + mailGroup.Recipients, + mailGroup.MailInfo.Body, + mailGroup.MailInfo.Subject, + tenantId); + + // Use a switch expression for cleaner counting + switch (response.StatusCode) + { + case 200: Interlocked.Increment(ref successCount); break; + case 404: Interlocked.Increment(ref notFoundCount); break; + case 400: Interlocked.Increment(ref invalidIdCount); break; + default: Interlocked.Increment(ref failureCount); break; + } + } + catch (Exception ex) + { + // 3. OPTIMIZATION: Make the process resilient. + // If one task fails unexpectedly, log it and continue with others. + _logger.LogError(ex, "Failed to send report for project {ProjectId}", mailGroup.ProjectId); + Interlocked.Increment(ref failureCount); + } } }).ToList(); await Task.WhenAll(sendTasks); - //var response = await GetProjectStatistics(Guid.Parse("2618eb89-2823-11f0-9d9e-bc241163f504"), "ashutosh.nehete@marcoaiot.com", tenantId); + var summaryMessage = $"Processing complete. Success: {successCount}, Not Found: {notFoundCount}, Invalid ID: {invalidIdCount}, Failures: {failureCount}."; _logger.LogInfo( - "Emails of project reports sent for tenant {TenantId}. Successfully sent: {SuccessCount}, Projects not found: {NotFoundCount}, Invalid IDs: {InvalidIdsCount}", - tenantId, successCount, notFoundCount, invalidIdCount); + "Project report sending complete for tenant {TenantId}. Success: {SuccessCount}, Not Found: {NotFoundCount}, Invalid ID: {InvalidIdCount}, Failures: {FailureCount}", + tenantId, successCount, notFoundCount, invalidIdCount, failureCount); return Ok(ApiResponse.SuccessResponse( - new { }, - $"Reports sent successfully: {successCount}. Projects not found: {notFoundCount}. Invalid IDs: {invalidIdCount}.", + new { successCount, notFoundCount, invalidIdCount, failureCount }, + summaryMessage, 200)); } - /// - /// Retrieves project statistics for a given project ID and sends an email report. - /// - /// The ID of the project. - /// The email address of the recipient. - /// An ApiResponse indicating the success or failure of retrieving statistics and sending the email. - private async Task> GetProjectStatistics(Guid projectId, List recipientEmails, string body, string subject, Guid tenantId) + + [HttpPost("add-report-mail")] + public async Task StoreProjectStatistics() { + Guid tenantId = _userHelper.GetTenantId(); - if (projectId == Guid.Empty) + // 1. Database-Side Grouping (Still the most efficient way to get initial data) + var projectMailGroups = await _context.MailDetails + .AsNoTracking() + .Where(m => m.TenantId == tenantId && m.ProjectId != Guid.Empty) + .GroupBy(m => new { m.ProjectId, m.MailListId }) + .Select(g => new + { + g.Key.ProjectId, + Recipients = g.Select(m => m.Recipient).Distinct().ToList(), + MailInfo = g.Select(m => new { Body = m.MailBody != null ? m.MailBody.Body : "", Subject = m.MailBody != null ? m.MailBody.Subject : "" }).FirstOrDefault() + }) + .ToListAsync(); + + if (!projectMailGroups.Any()) { - _logger.LogError("Provided empty project ID while fetching project report."); - return ApiResponse.ErrorResponse("Provided empty Project ID.", "Provided empty Project ID.", 400); + _logger.LogInfo("No project mail details found for tenant {TenantId} to process.", tenantId); + return Ok(ApiResponse.SuccessResponse("No project reports to generate.", "No project reports to generate.", 200)); } + string env = _configuration["environment:Title"] ?? string.Empty; - var statisticReport = await _reportHelper.GetDailyProjectReport(projectId, tenantId); - - if (statisticReport == null) + // 2. Process each group concurrently, but with isolated DBContexts. + var processingTasks = projectMailGroups.Select(async group => { - _logger.LogWarning("User attempted to fetch project progress for project ID {ProjectId} but not found.", projectId); - return ApiResponse.ErrorResponse("Project not found.", "Project not found.", 404); - } + // SOLUTION: Create a new DI scope for each parallel task. + using (var scope = _serviceScopeFactory.CreateScope()) + { + // Resolve services from this new, isolated scope. + // These helpers will get their own fresh DbContext instance. + var reportHelper = scope.ServiceProvider.GetRequiredService(); + var emailSender = scope.ServiceProvider.GetRequiredService(); + var cache = scope.ServiceProvider.GetRequiredService(); // e.g., IProjectReportCache - // Send Email - var emailBody = await _emailSender.SendProjectStatisticsEmail(recipientEmails, body, subject, statisticReport); - var employee = await _context.Employees.FirstOrDefaultAsync(e => e.Email != null && recipientEmails.Contains(e.Email)) ?? new Employee(); - - List mailLogs = new List(); - foreach (var recipientEmail in recipientEmails) - { - mailLogs.Add( - new MailLog + // The rest of the logic is the same, but now it's thread-safe. + try { - ProjectId = projectId, - EmailId = recipientEmail, - Body = emailBody, - EmployeeId = employee.Id, - TimeStamp = DateTime.UtcNow, - TenantId = tenantId - }); + var projectId = group.ProjectId; + var statisticReport = await reportHelper.GetDailyProjectReport(projectId, tenantId); + + if (statisticReport == null) + { + _logger.LogWarning("Statistic report for project ID {ProjectId} not found. Skipping.", projectId); + return; + } + + if (group.MailInfo == null) + { + _logger.LogWarning("MailBody info for project ID {ProjectId} not found. Skipping.", projectId); + return; + } + + var date = statisticReport.Date.ToString("dd-MMM-yyyy", CultureInfo.InvariantCulture); + // Assuming the first param to SendProjectStatisticsEmail was just a placeholder + var emailBody = await emailSender.SendProjectStatisticsEmail(new List(), group.MailInfo.Body, string.Empty, statisticReport); + + string subject = group.MailInfo.Subject + .Replace("{{DATE}}", date) + .Replace("{{PROJECT_NAME}}", statisticReport.ProjectName); + + subject = string.IsNullOrWhiteSpace(env) ? subject : $"({env}) {subject}"; + + var mail = new ProjectReportEmailMongoDB + { + IsSent = false, + Body = emailBody, + Receivers = group.Recipients, + Subject = subject, + }; + + await cache.AddProjectReportMail(mail); + } + catch (Exception ex) + { + // It's good practice to log any unexpected errors within a concurrent task. + _logger.LogError(ex, "Failed to process project report for ProjectId {ProjectId}", group.ProjectId); + } + } + }); + + // Await all the concurrent, now thread-safe, tasks. + await Task.WhenAll(processingTasks); + + return Ok(ApiResponse.SuccessResponse( + $"{projectMailGroups.Count} Project Report Mail(s) are queued for storage.", + "Project Report Mail processing initiated.", + 200)); + } + + + [HttpGet("report-mail")] + public async Task GetProjectStatisticsFromCache() + { + var mailList = await _cache.GetProjectReportMail(false); + if (mailList == null) + { + return NotFound(ApiResponse.ErrorResponse("Not mail found", "Not mail found", 404)); } - _context.MailLogs.AddRange(mailLogs); - - await _context.SaveChangesAsync(); - return ApiResponse.SuccessResponse(statisticReport, "Email sent successfully", 200); + return Ok(ApiResponse.SuccessResponse(mailList, "Fetched list of mail body successfully", 200)); } } } diff --git a/Marco.Pms.Services/Controllers/UserController.cs b/Marco.Pms.Services/Controllers/UserController.cs index 2aeb208..8269d3e 100644 --- a/Marco.Pms.Services/Controllers/UserController.cs +++ b/Marco.Pms.Services/Controllers/UserController.cs @@ -4,6 +4,7 @@ using Marco.Pms.Model.Mapper; using Marco.Pms.Model.Projects; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Employee; +using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -19,14 +20,14 @@ namespace MarcoBMS.Services.Controllers private readonly UserHelper _userHelper; private readonly EmployeeHelper _employeeHelper; - private readonly ProjectsHelper _projectsHelper; + private readonly IProjectServices _projectServices; private readonly RolesHelper _rolesHelper; - public UserController(EmployeeHelper employeeHelper, ProjectsHelper projectsHelper, UserHelper userHelper, RolesHelper rolesHelper) + public UserController(EmployeeHelper employeeHelper, IProjectServices projectServices, UserHelper userHelper, RolesHelper rolesHelper) { _userHelper = userHelper; _employeeHelper = employeeHelper; - _projectsHelper = projectsHelper; + _projectServices = projectServices; _rolesHelper = rolesHelper; } @@ -50,18 +51,18 @@ namespace MarcoBMS.Services.Controllers emp = await _employeeHelper.GetEmployeeByApplicationUserID(user.Id); } - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(emp.Id); + List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeId(emp.Id); string[] projectsId = []; /* User with permission manage project can see all projects */ if (featurePermission != null && featurePermission.Exists(c => c.Id.ToString() == "172fc9b6-755b-4f62-ab26-55c34a330614")) { - List projects = await _projectsHelper.GetAllProjectByTanentID(emp.TenantId); + List projects = await _projectServices.GetAllProjectByTanentID(emp.TenantId); projectsId = projects.Select(c => c.Id.ToString()).ToArray(); } else { - List allocation = await _projectsHelper.GetProjectByEmployeeID(emp.Id); + List allocation = await _projectServices.GetProjectByEmployeeID(emp.Id); projectsId = allocation.Select(c => c.ProjectId.ToString()).ToArray(); } EmployeeProfile profile = new EmployeeProfile() { }; diff --git a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs index ae6264e..d942ab1 100644 --- a/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs +++ b/Marco.Pms.Services/Helpers/CacheUpdateHelper.cs @@ -1,7 +1,10 @@ using Marco.Pms.CacheHelper; +using Marco.Pms.DataAccess.Data; +using Marco.Pms.Model.Master; using Marco.Pms.Model.MongoDBModels; using Marco.Pms.Model.Projects; using MarcoBMS.Services.Service; +using Microsoft.EntityFrameworkCore; using Project = Marco.Pms.Model.Projects.Project; namespace Marco.Pms.Services.Helpers @@ -10,37 +13,423 @@ namespace Marco.Pms.Services.Helpers { private readonly ProjectCache _projectCache; private readonly EmployeeCache _employeeCache; + private readonly ReportCache _reportCache; private readonly ILoggingService _logger; + private readonly IDbContextFactory _dbContextFactory; + private readonly ApplicationDbContext _context; + private readonly GeneralHelper _generalHelper; - public CacheUpdateHelper(ProjectCache projectCache, EmployeeCache employeeCache, ILoggingService logger) + public CacheUpdateHelper(ProjectCache projectCache, EmployeeCache employeeCache, ReportCache reportCache, ILoggingService logger, + IDbContextFactory dbContextFactory, ApplicationDbContext context, GeneralHelper generalHelper) { _projectCache = projectCache; _employeeCache = employeeCache; + _reportCache = reportCache; _logger = logger; + _dbContextFactory = dbContextFactory; + _context = context; + _generalHelper = generalHelper; } - // ------------------------------------ Project Details and Infrastructure Cache --------------------------------------- + // ------------------------------------ Project Details Cache --------------------------------------- + public async Task AddProjectDetails(Project project) { + // --- Step 1: Fetch all required data from the database in parallel --- + + // Each task uses its own DbContext instance to avoid concurrency issues. + var statusTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.StatusMasters + .AsNoTracking() + .Where(s => s.Id == project.ProjectStatusId) + .Select(s => new { s.Id, s.Status }) // Projection + .FirstOrDefaultAsync(); + }); + + var teamSizeTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.ProjectAllocations + .AsNoTracking() + .CountAsync(pa => pa.ProjectId == project.Id && pa.IsActive); // Server-side count is efficient + }); + + // This task fetches the entire infrastructure hierarchy and performs aggregations in the database. + var infrastructureTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + + // 1. Fetch all hierarchical data using projections. + // This is still a chain, but it's inside one task and much faster due to projections. + var buildings = await context.Buildings.AsNoTracking() + .Where(b => b.ProjectId == project.Id) + .Select(b => new { b.Id, b.ProjectId, b.Name, b.Description }) + .ToListAsync(); + var buildingIds = buildings.Select(b => b.Id).ToList(); + + var floors = await context.Floor.AsNoTracking() + .Where(f => buildingIds.Contains(f.BuildingId)) + .Select(f => new { f.Id, f.BuildingId, f.FloorName }) + .ToListAsync(); + var floorIds = floors.Select(f => f.Id).ToList(); + + var workAreas = await context.WorkAreas.AsNoTracking() + .Where(wa => floorIds.Contains(wa.FloorId)) + .Select(wa => new { wa.Id, wa.FloorId, wa.AreaName }) + .ToListAsync(); + var workAreaIds = workAreas.Select(wa => wa.Id).ToList(); + + // 2. THE KEY OPTIMIZATION: Aggregate work items in the database. + var workSummaries = await context.WorkItems.AsNoTracking() + .Where(wi => workAreaIds.Contains(wi.WorkAreaId)) + .GroupBy(wi => wi.WorkAreaId) // Group by parent on the DB server + .Select(g => new // Let the DB do the SUM + { + WorkAreaId = g.Key, + PlannedWork = g.Sum(i => i.PlannedWork), + CompletedWork = g.Sum(i => i.CompletedWork) + }) + .ToDictionaryAsync(x => x.WorkAreaId); // Return a ready-to-use dictionary + + return (buildings, floors, workAreas, workSummaries); + }); + + // Wait for all parallel database operations to complete. + await Task.WhenAll(statusTask, teamSizeTask, infrastructureTask); + + // Get the results from the completed tasks. + var status = await statusTask; + var teamSize = await teamSizeTask; + var (allBuildings, allFloors, allWorkAreas, workSummariesByWorkAreaId) = await infrastructureTask; + + // --- Step 2: Process the fetched data and build the MongoDB model --- + + var projectDetails = new ProjectMongoDB + { + Id = project.Id.ToString(), + Name = project.Name, + ShortName = project.ShortName, + ProjectAddress = project.ProjectAddress, + StartDate = project.StartDate, + EndDate = project.EndDate, + ContactPerson = project.ContactPerson, + TeamSize = teamSize + }; + + projectDetails.ProjectStatus = new StatusMasterMongoDB + { + Id = status!.Id.ToString(), + Status = status.Status + }; + + // Use fast in-memory lookups instead of .Where() in loops. + var floorsByBuildingId = allFloors.ToLookup(f => f.BuildingId); + var workAreasByFloorId = allWorkAreas.ToLookup(wa => wa.FloorId); + + double totalPlannedWork = 0, totalCompletedWork = 0; + var buildingMongoList = new List(); + + foreach (var building in allBuildings) + { + double buildingPlanned = 0, buildingCompleted = 0; + var floorMongoList = new List(); + + foreach (var floor in floorsByBuildingId[building.Id]) // Fast lookup + { + double floorPlanned = 0, floorCompleted = 0; + var workAreaMongoList = new List(); + + foreach (var wa in workAreasByFloorId[floor.Id]) // Fast lookup + { + // Get the pre-calculated summary from the dictionary. O(1) operation. + workSummariesByWorkAreaId.TryGetValue(wa.Id, out var summary); + var waPlanned = summary?.PlannedWork ?? 0; + var waCompleted = summary?.CompletedWork ?? 0; + + workAreaMongoList.Add(new WorkAreaMongoDB + { + Id = wa.Id.ToString(), + FloorId = wa.FloorId.ToString(), + AreaName = wa.AreaName, + PlannedWork = waPlanned, + CompletedWork = waCompleted + }); + + floorPlanned += waPlanned; + floorCompleted += waCompleted; + } + + floorMongoList.Add(new FloorMongoDB + { + Id = floor.Id.ToString(), + BuildingId = floor.BuildingId.ToString(), + FloorName = floor.FloorName, + PlannedWork = floorPlanned, + CompletedWork = floorCompleted, + WorkAreas = workAreaMongoList + }); + + buildingPlanned += floorPlanned; + buildingCompleted += floorCompleted; + } + + buildingMongoList.Add(new BuildingMongoDB + { + Id = building.Id.ToString(), + ProjectId = building.ProjectId.ToString(), + BuildingName = building.Name, + Description = building.Description, + PlannedWork = buildingPlanned, + CompletedWork = buildingCompleted, + Floors = floorMongoList + }); + + totalPlannedWork += buildingPlanned; + totalCompletedWork += buildingCompleted; + } + + projectDetails.Buildings = buildingMongoList; + projectDetails.PlannedWork = totalPlannedWork; + projectDetails.CompletedWork = totalCompletedWork; + try { - await _projectCache.AddProjectDetailsToCache(project); + await _projectCache.AddProjectDetailsToCache(projectDetails); } catch (Exception ex) { - _logger.LogWarning("Error occured while adding project {ProjectId} to Cache : {Error}", project.Id, ex.Message); + _logger.LogError(ex, "Error occurred while adding project {ProjectId} to Cache", project.Id); + } + } + public async Task AddProjectDetailsList(List projects) + { + var projectIds = projects.Select(p => p.Id).ToList(); + if (!projectIds.Any()) + { + return; // Nothing to do + } + var projectStatusIds = projects.Select(p => p.ProjectStatusId).Distinct().ToList(); + + // --- Step 1: Fetch all required data in maximum parallel --- + // Each task uses its own DbContext and selects only the required columns (projection). + + var statusTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.StatusMasters + .AsNoTracking() + .Where(s => projectStatusIds.Contains(s.Id)) + .Select(s => new { s.Id, s.Status }) // Projection + .ToDictionaryAsync(s => s.Id); + }); + + var teamSizeTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + // Server-side aggregation and projection into a dictionary + return await context.ProjectAllocations + .AsNoTracking() + .Where(pa => projectIds.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); + }); + + var buildingsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Buildings + .AsNoTracking() + .Where(b => projectIds.Contains(b.ProjectId)) + .Select(b => new { b.Id, b.ProjectId, b.Name, b.Description }) // Projection + .ToListAsync(); + }); + + // We need the building IDs for the next level, so we must await this one first. + var allBuildings = await buildingsTask; + var buildingIds = allBuildings.Select(b => b.Id).ToList(); + + var floorsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.Floor + .AsNoTracking() + .Where(f => buildingIds.Contains(f.BuildingId)) + .Select(f => new { f.Id, f.BuildingId, f.FloorName }) // Projection + .ToListAsync(); + }); + + // We need floor IDs for the next level. + var allFloors = await floorsTask; + var floorIds = allFloors.Select(f => f.Id).ToList(); + + var workAreasTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + return await context.WorkAreas + .AsNoTracking() + .Where(wa => floorIds.Contains(wa.FloorId)) + .Select(wa => new { wa.Id, wa.FloorId, wa.AreaName }) // Projection + .ToListAsync(); + }); + + // The most powerful optimization: Aggregate work items in the database. + var workSummaryTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + var workAreaIds = await context.WorkAreas + .Where(wa => floorIds.Contains(wa.FloorId)) + .Select(wa => wa.Id) + .ToListAsync(); + + // Let the DB do the SUM. This is much faster and transfers less data. + return await context.WorkItems + .AsNoTracking() + .Where(wi => workAreaIds.Contains(wi.WorkAreaId)) + .GroupBy(wi => wi.WorkAreaId) + .Select(g => new + { + WorkAreaId = g.Key, + PlannedWork = g.Sum(wi => wi.PlannedWork), + CompletedWork = g.Sum(wi => wi.CompletedWork) + }) + .ToDictionaryAsync(x => x.WorkAreaId); + }); + + // Await the remaining parallel tasks. + await Task.WhenAll(statusTask, teamSizeTask, workAreasTask, workSummaryTask); + + // --- 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; + + // 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) + { + var projectDetails = new ProjectMongoDB + { + Id = project.Id.ToString(), + Name = project.Name, + ShortName = project.ShortName, + ProjectAddress = project.ProjectAddress, + StartDate = project.StartDate, + EndDate = project.EndDate, + ContactPerson = project.ContactPerson, + TeamSize = teamSizesByProjectId.GetValueOrDefault(project.Id, 0) + }; + + if (allStatuses.TryGetValue(project.ProjectStatusId, out var status)) + { + projectDetails.ProjectStatus = new StatusMasterMongoDB + { + Id = status.Id.ToString(), + Status = status.Status + }; + } + + double totalPlannedWork = 0, totalCompletedWork = 0; + var buildingMongoList = new List(); + + foreach (var building in buildingsByProjectId[project.Id]) + { + double buildingPlanned = 0, buildingCompleted = 0; + var floorMongoList = new List(); + + foreach (var floor in floorsByBuildingId[building.Id]) + { + double floorPlanned = 0, floorCompleted = 0; + var workAreaMongoList = new List(); + + foreach (var wa in workAreasByFloorId[floor.Id]) + { + double waPlanned = 0, waCompleted = 0; + if (workSummariesByWorkAreaId.TryGetValue(wa.Id, out var summary)) + { + waPlanned = summary.PlannedWork; + waCompleted = summary.CompletedWork; + } + + workAreaMongoList.Add(new WorkAreaMongoDB + { + Id = wa.Id.ToString(), + FloorId = wa.FloorId.ToString(), + AreaName = wa.AreaName, + PlannedWork = waPlanned, + CompletedWork = waCompleted + }); + + floorPlanned += waPlanned; + floorCompleted += waCompleted; + } + + floorMongoList.Add(new FloorMongoDB + { + Id = floor.Id.ToString(), + BuildingId = floor.BuildingId.ToString(), + FloorName = floor.FloorName, + PlannedWork = floorPlanned, + CompletedWork = floorCompleted, + WorkAreas = workAreaMongoList + }); + + buildingPlanned += floorPlanned; + buildingCompleted += floorCompleted; + } + + buildingMongoList.Add(new BuildingMongoDB + { + Id = building.Id.ToString(), + ProjectId = building.ProjectId.ToString(), + BuildingName = building.Name, + Description = building.Description, + PlannedWork = buildingPlanned, + CompletedWork = buildingCompleted, + Floors = floorMongoList + }); + + totalPlannedWork += buildingPlanned; + totalCompletedWork += buildingCompleted; + } + + projectDetails.Buildings = buildingMongoList; + projectDetails.PlannedWork = totalPlannedWork; + projectDetails.CompletedWork = totalCompletedWork; + + projectDetailsList.Add(projectDetails); + } + + // --- Step 3: Update the cache --- + try + { + await _projectCache.AddProjectDetailsListToCache(projectDetailsList); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while adding project list to Cache"); } } public async Task UpdateProjectDetailsOnly(Project project) { + StatusMaster projectStatus = await _context.StatusMasters + .FirstOrDefaultAsync(s => s.Id == project.ProjectStatusId) ?? new StatusMaster(); try { - bool response = await _projectCache.UpdateProjectDetailsOnlyToCache(project); + bool response = await _projectCache.UpdateProjectDetailsOnlyToCache(project, projectStatus); return response; } catch (Exception ex) { - _logger.LogWarning("Error occured while updating project {ProjectId} to Cache: {Error}", project.Id, ex.Message); + _logger.LogError(ex, "Error occured while updating project {ProjectId} to Cache", project.Id); return false; } } @@ -53,7 +442,20 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogWarning("Error occured while getting project {ProjectId} to Cache: {Error}", ex.Message); + _logger.LogError(ex, "Error occured while getting project {ProjectId} to Cache"); + return null; + } + } + public async Task GetProjectDetailsWithBuildings(Guid projectId) + { + try + { + var response = await _projectCache.GetProjectDetailsWithBuildingsFromCache(projectId); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while getting project {ProjectId} to Cache"); return null; } } @@ -62,14 +464,48 @@ namespace Marco.Pms.Services.Helpers try { var response = await _projectCache.GetProjectDetailsListFromCache(projectIds); - return response; + if (response.Any()) + { + return response; + } + else + { + return null; + } } catch (Exception ex) { - _logger.LogWarning("Error occured while getting list od project details from to Cache: {Error}", ex.Message); + _logger.LogError(ex, "Error occured while getting list of project details from to Cache"); return null; } } + public async Task DeleteProjectByIdAsync(Guid projectId) + { + try + { + var response = await _projectCache.DeleteProjectByIdFromCacheAsync(projectId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while deleting project from to Cache"); + + } + } + public async Task RemoveProjectsAsync(List projectIds) + { + try + { + var response = await _projectCache.RemoveProjectsFromCacheAsync(projectIds); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while deleting project list from to Cache"); + + } + } + + // ------------------------------------ Project Infrastructure Cache --------------------------------------- + public async Task AddBuildngInfra(Guid projectId, Building? building = null, Floor? floor = null, WorkArea? workArea = null, Guid? buildingId = null) { try @@ -133,6 +569,9 @@ namespace Marco.Pms.Services.Helpers return null; } } + + // ------------------------------------------------------- WorkItem ------------------------------------------------------- + public async Task?> GetWorkItemsByWorkAreaIds(List workAreaIds) { try @@ -150,10 +589,20 @@ namespace Marco.Pms.Services.Helpers return null; } } - - // ------------------------------------------------------- WorkItem ------------------------------------------------------- - public async Task ManageWorkItemDetails(List workItems) + { + try + { + var workAreaId = workItems.First().WorkAreaId; + var workItemDB = await _generalHelper.GetWorkItemsListFromDB(workAreaId); + await _projectCache.ManageWorkItemDetailsToCache(workItemDB); + } + catch (Exception ex) + { + _logger.LogWarning("Error occured while saving workItems form Cache: {Error}", ex.Message); + } + } + public async Task ManageWorkItemDetailsByVM(List workItems) { try { @@ -215,14 +664,47 @@ namespace Marco.Pms.Services.Helpers _logger.LogWarning("Error occured while updating planned work, completed work, and today's assigned work in workItems in Cache: {Error}", ex.Message); } } + public async Task DeleteWorkItemByIdAsync(Guid workItemId) + { + try + { + var response = await _projectCache.DeleteWorkItemByIdFromCacheAsync(workItemId); + } + catch (Exception ex) + { + _logger.LogWarning("Error occured while deleting work item from to Cache: {Error}", ex.Message); + + } + } // ------------------------------------ Employee Profile Cache --------------------------------------- public async Task AddApplicationRole(Guid employeeId, List roleIds) { + // 1. Guard Clause: Avoid unnecessary database work if there are no roles to add. + if (roleIds == null || !roleIds.Any()) + { + return; // Nothing to add, so the operation did not result in a change. + } + Task> getPermissionIdsTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + + return await context.RolePermissionMappings + .Where(rp => roleIds.Contains(rp.ApplicationRoleId)) + .Select(p => p.FeaturePermissionId.ToString()) + .Distinct() + .ToListAsync(); + }); + + // 3. Prepare role IDs in parallel with the database query. + var newRoleIds = roleIds.Select(r => r.ToString()).ToList(); + + // 4. Await the database query result. + var newPermissionIds = await getPermissionIdsTask; try { - var response = await _employeeCache.AddApplicationRoleToCache(employeeId, roleIds); + var response = await _employeeCache.AddApplicationRoleToCache(employeeId, newRoleIds, newPermissionIds); } catch (Exception ex) { @@ -342,5 +824,44 @@ namespace Marco.Pms.Services.Helpers _logger.LogWarning("Error occured while deleting Application role {RoleId} from Cache for employee {EmployeeId}: {Error}", roleId, employeeId, ex.Message); } } + public async Task ClearAllEmployees() + { + try + { + var response = await _employeeCache.ClearAllEmployeesFromCache(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while deleting all employees from Cache"); + } + } + + + // ------------------------------------ Report Cache --------------------------------------- + + public async Task?> GetProjectReportMail(bool IsSend) + { + try + { + var response = await _reportCache.GetProjectReportMailFromCache(IsSend); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while fetching project report mail bodys"); + return null; + } + } + public async Task AddProjectReportMail(ProjectReportEmailMongoDB report) + { + try + { + await _reportCache.AddProjectReportMailToCache(report); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occured while adding project report mail bodys"); + } + } } } diff --git a/Marco.Pms.Services/Helpers/DirectoryHelper.cs b/Marco.Pms.Services/Helpers/DirectoryHelper.cs index 199a410..3dd578e 100644 --- a/Marco.Pms.Services/Helpers/DirectoryHelper.cs +++ b/Marco.Pms.Services/Helpers/DirectoryHelper.cs @@ -52,7 +52,7 @@ namespace Marco.Pms.Services.Helpers } else { - _logger.LogError("Employee {EmployeeId} attemped to access a contacts, but do not have permission", LoggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} attemped to access a contacts, but do not have permission", LoggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -202,7 +202,7 @@ namespace Marco.Pms.Services.Helpers } else { - _logger.LogError("Employee {EmployeeId} attemped to access a contacts with in bucket {BucketId}, but do not have permission", LoggedInEmployee.Id, id); + _logger.LogWarning("Employee {EmployeeId} attemped to access a contacts with in bucket {BucketId}, but do not have permission", LoggedInEmployee.Id, id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -490,7 +490,7 @@ namespace Marco.Pms.Services.Helpers } else { - _logger.LogError("Employee {EmployeeId} attemped to update a contact, but do not have permission", LoggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} attemped to update a contact, but do not have permission", LoggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -1157,11 +1157,12 @@ namespace Marco.Pms.Services.Helpers List employeeBuckets = await _context.EmployeeBucketMappings.Where(b => b.EmployeeId == LoggedInEmployee.Id).ToListAsync(); var bucketIds = employeeBuckets.Select(b => b.BucketId).ToList(); - List employeeBucketVM = await _context.EmployeeBucketMappings.Where(b => bucketIds.Contains(b.BucketId)).ToListAsync(); + List bucketList = new List(); if (permissionIds.Contains(PermissionsMaster.DirectoryAdmin)) { bucketList = await _context.Buckets.Include(b => b.CreatedBy).Where(b => b.TenantId == tenantId).ToListAsync(); + bucketIds = bucketList.Select(b => b.Id).ToList(); } else if (permissionIds.Contains(PermissionsMaster.DirectoryAdmin) || permissionIds.Contains(PermissionsMaster.DirectoryUser)) { @@ -1169,10 +1170,12 @@ namespace Marco.Pms.Services.Helpers } else { - _logger.LogError("Employee {EmployeeId} attemped to access a buckets list, but do not have permission", LoggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} attemped to access a buckets list, but do not have permission", LoggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } + List employeeBucketVM = await _context.EmployeeBucketMappings.Where(b => bucketIds.Contains(b.BucketId)).ToListAsync(); + List bucketVMs = new List(); if (bucketList.Any()) { @@ -1184,7 +1187,11 @@ namespace Marco.Pms.Services.Helpers var emplyeeIds = employeeBucketMappings.Select(eb => eb.EmployeeId).ToList(); List? contactBuckets = contactBucketMappings.Where(cb => cb.BucketId == bucket.Id).ToList(); AssignBucketVM bucketVM = bucket.ToAssignBucketVMFromBucket(); - bucketVM.EmployeeIds = emplyeeIds; + if (bucketVM.CreatedBy != null) + { + emplyeeIds.Add(bucketVM.CreatedBy.Id); + } + bucketVM.EmployeeIds = emplyeeIds.Distinct().ToList(); bucketVM.NumberOfContacts = contactBuckets.Count; bucketVMs.Add(bucketVM); } @@ -1204,7 +1211,7 @@ namespace Marco.Pms.Services.Helpers var demo = !permissionIds.Contains(PermissionsMaster.DirectoryUser); if (!permissionIds.Contains(PermissionsMaster.DirectoryAdmin) && !permissionIds.Contains(PermissionsMaster.DirectoryAdmin) && !permissionIds.Contains(PermissionsMaster.DirectoryUser)) { - _logger.LogError("Employee {EmployeeId} attemped to create a bucket, but do not have permission", LoggedInEmployee.Id); + _logger.LogWarning("Employee {EmployeeId} attemped to create a bucket, but do not have permission", LoggedInEmployee.Id); return ApiResponse.ErrorResponse("You don't have permission", "You don't have permission", 401); } @@ -1276,7 +1283,7 @@ namespace Marco.Pms.Services.Helpers } if (accessableBucket == null) { - _logger.LogError("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); + _logger.LogWarning("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); return ApiResponse.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 401); } @@ -1342,7 +1349,7 @@ namespace Marco.Pms.Services.Helpers } if (accessableBucket == null) { - _logger.LogError("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); + _logger.LogWarning("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); return ApiResponse.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 401); } var employeeIds = await _context.Employees.Where(e => e.TenantId == tenantId && e.IsActive).Select(e => e.Id).ToListAsync(); @@ -1362,7 +1369,7 @@ namespace Marco.Pms.Services.Helpers _context.EmployeeBucketMappings.Add(employeeBucketMapping); assignedEmployee += 1; } - else + else if (!assignBucket.IsActive) { EmployeeBucketMapping? employeeBucketMapping = employeeBuckets.FirstOrDefault(eb => eb.BucketId == bucketId && eb.EmployeeId == assignBucket.EmployeeId); if (employeeBucketMapping != null) @@ -1396,7 +1403,7 @@ namespace Marco.Pms.Services.Helpers } if (removededEmployee > 0) { - _logger.LogError("Employee {EmployeeId} removed {conut} number of employees from bucket {BucketId}", LoggedInEmployee.Id, removededEmployee, bucketId); + _logger.LogWarning("Employee {EmployeeId} removed {conut} number of employees from bucket {BucketId}", LoggedInEmployee.Id, removededEmployee, bucketId); } return ApiResponse.SuccessResponse(bucketVM, "Details updated successfully", 200); } @@ -1443,7 +1450,7 @@ namespace Marco.Pms.Services.Helpers } if (accessableBucket == null) { - _logger.LogError("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); + _logger.LogWarning("Employee {EmployeeId} attempted to access bucket {BucketId} without the necessary permissions.", LoggedInEmployee.Id, bucket.Id); return ApiResponse.ErrorResponse("You don't have permission to access this bucket", "You don't have permission to access this bucket", 401); } diff --git a/Marco.Pms.Services/Helpers/EmployeeHelper.cs b/Marco.Pms.Services/Helpers/EmployeeHelper.cs index 343144a..09dcbe2 100644 --- a/Marco.Pms.Services/Helpers/EmployeeHelper.cs +++ b/Marco.Pms.Services/Helpers/EmployeeHelper.cs @@ -33,7 +33,7 @@ namespace MarcoBMS.Services.Helpers } catch (Exception ex) { - _logger.LogError("{Error}", ex.Message); + _logger.LogError(ex, "Error occured while fetching employee by application user ID {ApplicationUserId}", ApplicationUserID); return new Employee(); } } @@ -66,7 +66,7 @@ namespace MarcoBMS.Services.Helpers } catch (Exception ex) { - _logger.LogError("{Error}", ex.Message); + _logger.LogError(ex, "Error occoured while filtering employees by string {SearchString} or project {ProjectId}", searchString, ProjectId ?? Guid.Empty); return new List(); } } @@ -102,7 +102,7 @@ namespace MarcoBMS.Services.Helpers } catch (Exception ex) { - _logger.LogError("{Error}", ex.Message); + _logger.LogError(ex, "Error occured while featching list of employee by project ID {ProjectId}", ProjectId ?? Guid.Empty); return new List(); } } diff --git a/Marco.Pms.Services/Helpers/GeneralHelper.cs b/Marco.Pms.Services/Helpers/GeneralHelper.cs new file mode 100644 index 0000000..c2f8fe4 --- /dev/null +++ b/Marco.Pms.Services/Helpers/GeneralHelper.cs @@ -0,0 +1,214 @@ +using Marco.Pms.DataAccess.Data; +using Marco.Pms.Model.MongoDBModels; +using MarcoBMS.Services.Service; +using Microsoft.EntityFrameworkCore; + +namespace Marco.Pms.Services.Helpers +{ + public class GeneralHelper + { + private readonly IDbContextFactory _dbContextFactory; + private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate + private readonly ILoggingService _logger; + public GeneralHelper(IDbContextFactory dbContextFactory, + ApplicationDbContext context, + ILoggingService logger) + { + _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + public async Task> GetProjectInfraFromDB(Guid projectId) + { + // Each task uses its own DbContext instance for thread safety. Projections are used for efficiency. + + // Task to fetch Buildings, Floors, and WorkAreas using projections + var hierarchyTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + var buildings = await context.Buildings.AsNoTracking().Where(b => b.ProjectId == projectId).Select(b => new { b.Id, b.Name, b.Description }).ToListAsync(); + var buildingIds = buildings.Select(b => b.Id).ToList(); + var floors = await context.Floor.AsNoTracking().Where(f => buildingIds.Contains(f.BuildingId)).Select(f => new { f.Id, f.BuildingId, f.FloorName }).ToListAsync(); + var floorIds = floors.Select(f => f.Id).ToList(); + var workAreas = await context.WorkAreas.AsNoTracking().Where(wa => floorIds.Contains(wa.FloorId)).Select(wa => new { wa.Id, wa.FloorId, wa.AreaName }).ToListAsync(); + return (buildings, floors, workAreas); + }); + + // Task to get work summaries, AGGREGATED ON THE DATABASE SERVER + var workSummaryTask = Task.Run(async () => + { + using var context = _dbContextFactory.CreateDbContext(); + // This is the most powerful optimization. It avoids pulling all WorkItem rows. + return await context.WorkItems.AsNoTracking() + .Where(wi => wi.WorkArea != null && wi.WorkArea.Floor != null && wi.WorkArea.Floor.Building != null && wi.WorkArea.Floor.Building.ProjectId == projectId) + .GroupBy(wi => wi.WorkAreaId) // Group by the parent WorkArea + .Select(g => new + { + WorkAreaId = g.Key, + PlannedWork = g.Sum(i => i.PlannedWork), + CompletedWork = g.Sum(i => i.CompletedWork) + }) + .ToDictionaryAsync(x => x.WorkAreaId); // Return a ready-to-use dictionary for fast lookups + }); + + await Task.WhenAll(hierarchyTask, workSummaryTask); + + var (buildings, floors, workAreas) = await hierarchyTask; + var workSummariesByWorkAreaId = await workSummaryTask; + + // --- Step 4: Build the hierarchy efficiently using Lookups --- + // Using lookups is much faster (O(1)) than repeated .Where() calls (O(n)). + var floorsByBuildingId = floors.ToLookup(f => f.BuildingId); + var workAreasByFloorId = workAreas.ToLookup(wa => wa.FloorId); + + var buildingMongoList = new List(); + foreach (var building in buildings) + { + double buildingPlanned = 0, buildingCompleted = 0; + var floorMongoList = new List(); + + foreach (var floor in floorsByBuildingId[building.Id]) // Fast lookup + { + double floorPlanned = 0, floorCompleted = 0; + var workAreaMongoList = new List(); + + foreach (var workArea in workAreasByFloorId[floor.Id]) // Fast lookup + { + // Get the pre-calculated summary from the dictionary. O(1) operation. + workSummariesByWorkAreaId.TryGetValue(workArea.Id, out var summary); + var waPlanned = summary?.PlannedWork ?? 0; + var waCompleted = summary?.CompletedWork ?? 0; + + workAreaMongoList.Add(new WorkAreaMongoDB + { + Id = workArea.Id.ToString(), + AreaName = workArea.AreaName, + PlannedWork = waPlanned, + CompletedWork = waCompleted + }); + + floorPlanned += waPlanned; + floorCompleted += waCompleted; + } + + floorMongoList.Add(new FloorMongoDB + { + Id = floor.Id.ToString(), + FloorName = floor.FloorName, + PlannedWork = floorPlanned, + CompletedWork = floorCompleted, + WorkAreas = workAreaMongoList + }); + + buildingPlanned += floorPlanned; + buildingCompleted += floorCompleted; + } + + buildingMongoList.Add(new BuildingMongoDB + { + Id = building.Id.ToString(), + BuildingName = building.Name, + Description = building.Description, + PlannedWork = buildingPlanned, + CompletedWork = buildingCompleted, + Floors = floorMongoList + }); + } + return buildingMongoList; + } + + /// + /// Retrieves a list of work items for a specific work area, including a summary of tasks assigned for the current day. + /// This method is highly optimized to run database operations in parallel and perform aggregations on the server. + /// + /// The ID of the work area. + /// A list of WorkItemMongoDB objects with calculated daily assignments. + public async Task> GetWorkItemsListFromDB(Guid workAreaId) + { + _logger.LogInfo("Fetching DB work items for WorkAreaId: {WorkAreaId}", workAreaId); + + try + { + // --- Step 1: Run independent database queries in PARALLEL --- + // We can fetch the WorkItems and the aggregated TaskAllocations at the same time. + + // Task 1: Fetch the WorkItem entities and their related data. + var workItemsTask = _context.WorkItems + .Include(wi => wi.ActivityMaster) + .Include(wi => wi.WorkCategoryMaster) + .Where(wi => wi.WorkAreaId == workAreaId) + .AsNoTracking() + .ToListAsync(); + + // Task 2: Fetch and AGGREGATE today's task allocations ON THE DATABASE SERVER. + var todaysAssignmentsTask = Task.Run(async () => + { + // Correctly define "today's" date range to avoid precision issues. + var today = DateTime.UtcNow.Date; + var tomorrow = today.AddDays(1); + + using var context = _dbContextFactory.CreateDbContext(); // Use a factory for thread safety + + // This is the most powerful optimization: + // 1. It filters by WorkAreaId directly, making it independent of the first query. + // 2. It filters by a correct date range. + // 3. It groups and sums on the DB server, returning only a small summary. + return await context.TaskAllocations + .Where(t => t.WorkItem != null && t.WorkItem.WorkAreaId == workAreaId && + t.AssignmentDate >= today && t.AssignmentDate < tomorrow) + .GroupBy(t => t.WorkItemId) + .Select(g => new + { + WorkItemId = g.Key, + TodaysAssigned = g.Sum(x => x.PlannedTask) + }) + // Return a dictionary for instant O(1) lookups later. + .ToDictionaryAsync(x => x.WorkItemId, x => x.TodaysAssigned); + }); + + // Await both parallel database operations to complete. + await Task.WhenAll(workItemsTask, todaysAssignmentsTask); + + // Retrieve the results from the completed tasks. + var workItemsFromDb = await workItemsTask; + var todaysAssignments = await todaysAssignmentsTask; + + // --- Step 2: Map to the ViewModel/MongoDB model efficiently --- + var workItemVMs = workItemsFromDb.Select(wi => new WorkItemMongoDB + { + Id = wi.Id.ToString(), + WorkAreaId = wi.WorkAreaId.ToString(), + ParentTaskId = wi.ParentTaskId.ToString(), + ActivityMaster = wi.ActivityMaster != null ? new ActivityMasterMongoDB + { + Id = wi.ActivityMaster.Id.ToString(), + ActivityName = wi.ActivityMaster.ActivityName, + UnitOfMeasurement = wi.ActivityMaster.UnitOfMeasurement + } : null, + WorkCategoryMaster = wi.WorkCategoryMaster != null ? new WorkCategoryMasterMongoDB + { + Id = wi.WorkCategoryMaster.Id.ToString(), + Name = wi.WorkCategoryMaster.Name, + Description = wi.WorkCategoryMaster.Description + } : null, + PlannedWork = wi.PlannedWork, + CompletedWork = wi.CompletedWork, + Description = wi.Description, + TaskDate = wi.TaskDate, + // Use the fast dictionary lookup instead of the slow in-memory Where/Sum. + TodaysAssigned = todaysAssignments.GetValueOrDefault(wi.Id, 0) + }).ToList(); + + _logger.LogInfo("Successfully processed {WorkItemCount} work items for WorkAreaId: {WorkAreaId}", workItemVMs.Count, workAreaId); + + return workItemVMs; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while fetching DB work items for WorkAreaId: {WorkAreaId}", workAreaId); + // Return an empty list or re-throw, depending on your application's error handling strategy. + return new List(); + } + } + } +} diff --git a/Marco.Pms.Services/Helpers/MasterHelper.cs b/Marco.Pms.Services/Helpers/MasterHelper.cs index f994639..83bc007 100644 --- a/Marco.Pms.Services/Helpers/MasterHelper.cs +++ b/Marco.Pms.Services/Helpers/MasterHelper.cs @@ -218,7 +218,7 @@ namespace Marco.Pms.Services.Helpers _logger.LogInfo("Contact tag master {ConatctTagId} updated successfully by employee {EmployeeId}", contactTagVm.Id, LoggedInEmployee.Id); return ApiResponse.SuccessResponse(contactTagVm, "Contact Tag master updated successfully", 200); } - _logger.LogError("Contact Tag master {ContactTagId} not found in database", id); + _logger.LogWarning("Contact Tag master {ContactTagId} not found in database", id); return ApiResponse.ErrorResponse("Contact Tag master not found", "Contact tag master not found", 404); } _logger.LogWarning("Employee with ID {LoggedInEmployeeId} sended empty payload", LoggedInEmployee.Id); @@ -294,7 +294,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occurred while fetching work status list : {Error}", ex.Message); + _logger.LogWarning("Error occurred while fetching work status list : {Error}", ex.Message); return ApiResponse.ErrorResponse("An error occurred", "Unable to fetch work status list", 500); } } @@ -343,7 +343,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occurred while creating work status : {Error}", ex.Message); + _logger.LogWarning("Error occurred while creating work status : {Error}", ex.Message); return ApiResponse.ErrorResponse("An error occurred", "Unable to create work status", 500); } } @@ -403,7 +403,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occurred while updating work status ID: {Id} : {Error}", id, ex.Message); + _logger.LogError(ex, "Error occurred while updating work status ID: {Id}", id); return ApiResponse.ErrorResponse("An error occurred", "Unable to update the work status at this time", 500); } } @@ -458,7 +458,7 @@ namespace Marco.Pms.Services.Helpers } catch (Exception ex) { - _logger.LogError("Error occurred while deleting WorkStatus Id: {Id} : {Error}", id, ex.Message); + _logger.LogError(ex, "Error occurred while deleting WorkStatus Id: {Id}", id); return ApiResponse.ErrorResponse("An error occurred", "Unable to delete work status", 500); } } diff --git a/Marco.Pms.Services/Helpers/ProjectHelper.cs b/Marco.Pms.Services/Helpers/ProjectHelper.cs deleted file mode 100644 index f1b688e..0000000 --- a/Marco.Pms.Services/Helpers/ProjectHelper.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.Projects; -using Microsoft.CodeAnalysis; -using Microsoft.EntityFrameworkCore; - - -namespace ModelServices.Helpers -{ - public class ProjectHelper - { - private readonly ApplicationDbContext _context; - public ProjectHelper(ApplicationDbContext context) - { - _context = context; - } - - public async Task> GetTeamByProject(Guid TenantId, Guid ProjectId, bool IncludeInactive) - { - if (IncludeInactive) - { - - var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId).Include(e => e.Employee).ToListAsync(); - - return employees; - } - else - { - var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId && c.IsActive == true).Include(e => e.Employee).ToListAsync(); - - return employees; - } - } - - - - } -} diff --git a/Marco.Pms.Services/Helpers/ProjectsHelper.cs b/Marco.Pms.Services/Helpers/ProjectsHelper.cs deleted file mode 100644 index 85003ae..0000000 --- a/Marco.Pms.Services/Helpers/ProjectsHelper.cs +++ /dev/null @@ -1,130 +0,0 @@ -using Marco.Pms.DataAccess.Data; -using Marco.Pms.Model.Employees; -using Marco.Pms.Model.Entitlements; -using Marco.Pms.Model.MongoDBModels; -using Marco.Pms.Model.Projects; -using Marco.Pms.Services.Helpers; -using Microsoft.EntityFrameworkCore; - -namespace MarcoBMS.Services.Helpers -{ - public class ProjectsHelper - { - private readonly ApplicationDbContext _context; - private readonly RolesHelper _rolesHelper; - private readonly CacheUpdateHelper _cache; - - - public ProjectsHelper(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache) - { - _context = context; - _rolesHelper = rolesHelper; - _cache = cache; - } - - public async Task> GetAllProjectByTanentID(Guid tanentID) - { - List alloc = await _context.Projects.Where(c => c.TenantId == tanentID).ToListAsync(); - return alloc; - } - - public async Task> GetProjectByEmployeeID(Guid employeeID) - { - List alloc = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeID && c.IsActive == true).Include(c => c.Project).ToListAsync(); - return alloc; - } - - public async Task> GetTeamByProject(Guid TenantId, Guid ProjectId, bool IncludeInactive) - { - if (IncludeInactive) - { - - var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId).Include(e => e.Employee).ToListAsync(); - - return employees; - } - else - { - var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId && c.IsActive == true).Include(e => e.Employee).ToListAsync(); - - return employees; - } - } - - public async Task> GetMyProjects(Guid tenantId, Employee LoggedInEmployee) - { - string[] projectsId = []; - List projects = new List(); - - var projectIds = await _cache.GetProjects(LoggedInEmployee.Id); - - if (projectIds != null) - { - - List projectdetails = await _cache.GetProjectDetailsList(projectIds) ?? new List(); - projects = projectdetails.Select(p => new Project - { - Id = Guid.Parse(p.Id), - Name = p.Name, - ShortName = p.ShortName, - ProjectAddress = p.ProjectAddress, - ProjectStatusId = Guid.Parse(p.ProjectStatus?.Id ?? ""), - ContactPerson = p.ContactPerson, - StartDate = p.StartDate, - EndDate = p.EndDate, - TenantId = tenantId - }).ToList(); - - if (projects.Count != projectIds.Count) - { - projects = await _context.Projects.Where(p => projectIds.Contains(p.Id)).ToListAsync(); - } - } - else - { - var featurePermissionIds = await _cache.GetPermissions(LoggedInEmployee.Id); - if (featurePermissionIds == null) - { - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(LoggedInEmployee.Id); - featurePermissionIds = featurePermission.Select(fp => fp.Id).ToList(); - } - // Define a common queryable base for projects - IQueryable projectQuery = _context.Projects.Where(c => c.TenantId == tenantId); - - // 2. Optimized Project Retrieval Logic - // User with permission 'manage project' can see all projects - if (featurePermissionIds != null && featurePermissionIds.Contains(Guid.Parse("172fc9b6-755b-4f62-ab26-55c34a330614"))) - { - // If GetAllProjectByTanentID is already optimized and directly returns IQueryable or - // directly executes with ToListAsync(), keep it. - // If it does more complex logic or extra trips, consider inlining here. - projects = await projectQuery.ToListAsync(); // Directly query the context - } - else - { - // 3. Efficiently get project allocations and then filter projects - // Load allocations only once - var allocation = await GetProjectByEmployeeID(LoggedInEmployee.Id); - - // If there are no allocations, return an empty list early - if (allocation == null || !allocation.Any()) - { - return new List(); - } - - // Use LINQ's Contains for efficient filtering by ProjectId - projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); // Get distinct Guids - - // Filter projects based on the retrieved ProjectIds - projects = await projectQuery.Where(c => projectIds.Contains(c.Id)).ToListAsync(); - - } - projectIds = projects.Select(p => p.Id).ToList(); - await _cache.AddProjects(LoggedInEmployee.Id, projectIds); - } - - return projects; - } - - } -} diff --git a/Marco.Pms.Services/Helpers/ReportHelper.cs b/Marco.Pms.Services/Helpers/ReportHelper.cs index e7632fd..35dcf8b 100644 --- a/Marco.Pms.Services/Helpers/ReportHelper.cs +++ b/Marco.Pms.Services/Helpers/ReportHelper.cs @@ -1,26 +1,34 @@ -using System.Globalization; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Dtos.Attendance; +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Mail; using Marco.Pms.Model.MongoDBModels; +using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Report; +using MarcoBMS.Services.Service; using Microsoft.EntityFrameworkCore; +using System.Globalization; namespace Marco.Pms.Services.Helpers { public class ReportHelper { private readonly ApplicationDbContext _context; + private readonly IEmailSender _emailSender; + private readonly ILoggingService _logger; private readonly CacheUpdateHelper _cache; - public ReportHelper(CacheUpdateHelper cache, ApplicationDbContext context) + public ReportHelper(ApplicationDbContext context, IEmailSender emailSender, ILoggingService logger, CacheUpdateHelper cache) { - _cache = cache; _context = context; + _emailSender = emailSender; + _logger = logger; + _cache = cache; } public async Task GetDailyProjectReport(Guid projectId, Guid tenantId) { // await _cache.GetBuildingAndFloorByWorkAreaId(); DateTime reportDate = DateTime.UtcNow.AddDays(-1).Date; - var project = await _cache.GetProjectDetails(projectId); + var project = await _cache.GetProjectDetailsWithBuildings(projectId); if (project == null) { var projectSQL = await _context.Projects @@ -83,7 +91,7 @@ namespace Marco.Pms.Services.Helpers BuildingName = b.BuildingName, Description = b.Description }).ToList(); - if (buildings == null) + if (!buildings.Any()) { buildings = await _context.Buildings .Where(b => b.ProjectId == projectId) @@ -105,7 +113,7 @@ namespace Marco.Pms.Services.Helpers BuildingId = f.BuildingId, FloorName = f.FloorName })).ToList(); - if (floors == null) + if (!floors.Any()) { var buildingIds = buildings.Select(b => Guid.Parse(b.Id)).ToList(); floors = await _context.Floor @@ -123,7 +131,7 @@ namespace Marco.Pms.Services.Helpers areas = project.Buildings .SelectMany(b => b.Floors) .SelectMany(f => f.WorkAreas).ToList(); - if (areas == null) + if (!areas.Any()) { var floorIds = floors.Select(f => Guid.Parse(f.Id)).ToList(); areas = await _context.WorkAreas @@ -141,7 +149,7 @@ namespace Marco.Pms.Services.Helpers // fetch Work Items workItems = await _cache.GetWorkItemsByWorkAreaIds(areaIds); - if (workItems == null) + if (workItems == null || !workItems.Any()) { workItems = await _context.WorkItems .Include(w => w.ActivityMaster) @@ -270,5 +278,88 @@ namespace Marco.Pms.Services.Helpers } return null; } + /// + /// Retrieves project statistics for a given project ID and sends an email report. + /// + /// The ID of the project. + /// The email address of the recipient. + /// An ApiResponse indicating the success or failure of retrieving statistics and sending the email. + public async Task> GetProjectStatistics(Guid projectId, List recipientEmails, string body, string subject, Guid tenantId) + { + // --- Input Validation --- + if (projectId == Guid.Empty) + { + _logger.LogWarning("Validation Error: Provided empty project ID while fetching project report."); + return ApiResponse.ErrorResponse("Provided empty Project ID.", "Provided empty Project ID.", 400); + } + + if (recipientEmails == null || !recipientEmails.Any()) + { + _logger.LogWarning("Validation Error: No recipient emails provided for project ID {ProjectId}.", projectId); + return ApiResponse.ErrorResponse("No recipient emails provided.", "No recipient emails provided.", 400); + } + + // --- Fetch Project Statistics --- + var statisticReport = await GetDailyProjectReport(projectId, tenantId); + + if (statisticReport == null) + { + _logger.LogWarning("Project Data Not Found: User attempted to fetch project progress for project ID {ProjectId} but it was not found.", projectId); + return ApiResponse.ErrorResponse("Project not found.", "Project not found.", 404); + } + + // --- Send Email & Log --- + string emailBody; + try + { + emailBody = await _emailSender.SendProjectStatisticsEmail(recipientEmails, body, subject, statisticReport); + } + catch (Exception ex) + { + _logger.LogError(ex, "Email Sending Error: Failed to send project statistics email for project ID {ProjectId}.", projectId); + return ApiResponse.ErrorResponse("Failed to send email.", "An error occurred while sending the email.", 500); + } + + // Find a relevant employee. Use AsNoTracking() for read-only query if the entity won't be modified. + // Consider if you need *any* employee from the recipients or a specific one (e.g., the sender). + var employee = await _context.Employees + .AsNoTracking() // Optimize for read-only + .FirstOrDefaultAsync(e => e.Email != null && recipientEmails.Contains(e.Email)) ?? new Employee(); + + // Initialize Employee to a default or null, based on whether an employee is always expected. + // If employee.Id is a non-nullable type, ensure proper handling if employee is null. + Guid employeeId = employee.Id; // Default to Guid.Empty if no employee found + + var mailLogs = recipientEmails.Select(recipientEmail => new MailLog + { + ProjectId = projectId, + EmailId = recipientEmail, + Body = emailBody, + EmployeeId = employeeId, // Use the determined employeeId + TimeStamp = DateTime.UtcNow, + TenantId = tenantId + }).ToList(); + + _context.MailLogs.AddRange(mailLogs); + + try + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully sent and logged project statistics email for Project ID {ProjectId} to {RecipientCount} recipients.", projectId, recipientEmails.Count); + return ApiResponse.SuccessResponse(statisticReport, "Email sent successfully", 200); + } + catch (DbUpdateException dbEx) + { + _logger.LogError(dbEx, "Database Error: Failed to save mail logs for project ID {ProjectId}.", projectId); + // Depending on your requirements, you might still return success here as the email was sent. + // Or return an error indicating the logging failed. + return ApiResponse.ErrorResponse("Email sent, but failed to log activity.", "Email sent, but an error occurred while logging.", 500); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected Error: An unhandled exception occurred while processing project statistics for project ID {ProjectId}.", projectId); + return ApiResponse.ErrorResponse("An unexpected error occurred.", "An unexpected error occurred.", 500); + } + } } } diff --git a/Marco.Pms.Services/Helpers/RolesHelper.cs b/Marco.Pms.Services/Helpers/RolesHelper.cs index 15bf0b1..ef9f824 100644 --- a/Marco.Pms.Services/Helpers/RolesHelper.cs +++ b/Marco.Pms.Services/Helpers/RolesHelper.cs @@ -3,41 +3,93 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Entitlements; using Marco.Pms.Services.Helpers; +using MarcoBMS.Services.Service; using Microsoft.EntityFrameworkCore; namespace MarcoBMS.Services.Helpers { public class RolesHelper { + private readonly IDbContextFactory _dbContextFactory; private readonly ApplicationDbContext _context; private readonly CacheUpdateHelper _cache; - public RolesHelper(ApplicationDbContext context, CacheUpdateHelper cache) + private readonly ILoggingService _logger; + public RolesHelper(ApplicationDbContext context, CacheUpdateHelper cache, ILoggingService logger, IDbContextFactory dbContextFactory) { _context = context; _cache = cache; + _logger = logger; + _dbContextFactory = dbContextFactory; } - public async Task> GetFeaturePermissionByEmployeeID(Guid EmployeeID) + /// + /// Retrieves a unique list of enabled feature permissions for a given employee. + /// This method is optimized to use a single, composed database query. + /// + /// The ID of the employee. + /// A distinct list of FeaturePermission objects the employee is granted. + public async Task> GetFeaturePermissionByEmployeeId(Guid EmployeeId) { - List roleMappings = await _context.EmployeeRoleMappings.Where(c => c.EmployeeId == EmployeeID && c.IsEnabled == true).Select(c => c.RoleId).ToListAsync(); + _logger.LogInfo("Fetching feature permissions for EmployeeId: {EmployeeId}", EmployeeId); - await _cache.AddApplicationRole(EmployeeID, roleMappings); + try + { + // --- Step 1: Define the subquery using the main thread's context --- + // This is safe because the query is not executed yet. + var employeeRoleIdsQuery = _context.EmployeeRoleMappings + .Where(erm => erm.EmployeeId == EmployeeId && erm.IsEnabled) + .Select(erm => erm.RoleId); - // _context.RolePermissionMappings + // --- Step 2: Asynchronously update the cache using the DbContextFactory --- + _ = Task.Run(async () => + { + try + { + // Create a NEW, short-lived DbContext instance for this background task. + await using var contextForCache = await _dbContextFactory.CreateDbContextAsync(); - var result = await (from rpm in _context.RolePermissionMappings - join fp in _context.FeaturePermissions.Where(c => c.IsEnabled == true).Include(fp => fp.Feature) // Include Feature - on rpm.FeaturePermissionId equals fp.Id - where roleMappings.Contains(rpm.ApplicationRoleId) - select fp) - .ToListAsync(); + // Now, re-create and execute the query using this new, isolated context. + var roleIds = await contextForCache.EmployeeRoleMappings + .Where(erm => erm.EmployeeId == EmployeeId && erm.IsEnabled) + .Select(erm => erm.RoleId) + .ToListAsync(); - return result; + if (roleIds.Any()) + { + // The cache service might also need its own context, or you can pass the data directly. + // Assuming AddApplicationRole takes the data, not a context. + await _cache.AddApplicationRole(EmployeeId, roleIds); + _logger.LogInfo("Successfully queued cache update for EmployeeId: {EmployeeId}", EmployeeId); + } + } + catch (Exception ex) + { + _logger.LogWarning("Background cache update failed for EmployeeId {EmployeeId} : {Error}", EmployeeId, ex.Message); + } + }); - // return null; + // --- Step 3: Execute the main query on the main thread using its original context --- + // This is now safe because the background task is using a different DbContext instance. + var permissions = await ( + from rpm in _context.RolePermissionMappings + join fp in _context.FeaturePermissions.Include(f => f.Feature) + on rpm.FeaturePermissionId equals fp.Id + where employeeRoleIdsQuery.Contains(rpm.ApplicationRoleId) && fp.IsEnabled == true + select fp) + .Distinct() + .ToListAsync(); + + _logger.LogInfo("Successfully retrieved {PermissionCount} unique permissions for EmployeeId: {EmployeeId}", permissions.Count, EmployeeId); + return permissions; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while fetching permissions for EmployeeId {EmployeeId}", EmployeeId); + return new List(); + } } - public async Task> GetFeaturePermissionByRoleID(Guid roleId) + public async Task> GetFeaturePermissionByRoleID1(Guid roleId) { List roleMappings = await _context.RolePermissionMappings.Where(c => c.ApplicationRoleId == roleId).Select(c => c.ApplicationRoleId).ToListAsync(); @@ -54,5 +106,49 @@ namespace MarcoBMS.Services.Helpers // return null; } + /// + /// Retrieves a unique list of enabled feature permissions for a given role. + /// This method is optimized to fetch all data in a single, efficient database query. + /// + /// The ID of the role. + /// A distinct list of FeaturePermission objects granted to the role. + public async Task> GetFeaturePermissionByRoleID(Guid roleId) + { + _logger.LogInfo("Fetching feature permissions for RoleID: {RoleId}", roleId); + + try + { + // This single, efficient query gets all the required data at once. + // It joins the mapping table to the permissions table and filters by the given roleId. + var permissions = await ( + // 1. Start with the linking table. + from rpm in _context.RolePermissionMappings + + // 2. Join to the FeaturePermissions table on the foreign key. + join fp in _context.FeaturePermissions on rpm.FeaturePermissionId equals fp.Id + + // 3. Apply all filters in one 'where' clause for clarity and efficiency. + where + rpm.ApplicationRoleId == roleId // Filter by the specific role + && fp.IsEnabled == true // And only get enabled permissions + + // 4. Select the final FeaturePermission object. + select fp) + .Include(fp => fp.Feature) + .Distinct() + .ToListAsync(); + + _logger.LogInfo("Successfully retrieved {PermissionCount} unique permissions for RoleID: {RoleId}", permissions.Count, roleId); + + return permissions; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while fetching permissions for RoleId {RoleId}", roleId); + // Return an empty list as a safe default to prevent downstream failures. + return new List(); + } + } + } } diff --git a/Marco.Pms.Services/MappingProfiles/MappingProfile.cs b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs new file mode 100644 index 0000000..bf3777c --- /dev/null +++ b/Marco.Pms.Services/MappingProfiles/MappingProfile.cs @@ -0,0 +1,68 @@ +using AutoMapper; +using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Master; +using Marco.Pms.Model.MongoDBModels; +using Marco.Pms.Model.Projects; +using Marco.Pms.Model.ViewModels.Employee; +using Marco.Pms.Model.ViewModels.Projects; + +namespace Marco.Pms.Services.MappingProfiles +{ + public class MappingProfile : Profile + { + public MappingProfile() + { + #region ======================================================= Projects ======================================================= + // Your mappings + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap() + .ForMember( + dest => dest.Id, + // Explicitly and safely convert string Id to Guid Id + opt => opt.MapFrom(src => new Guid(src.Id)) + ); + + CreateMap() + .ForMember( + dest => dest.Id, + // Explicitly and safely convert string Id to Guid Id + opt => opt.MapFrom(src => new Guid(src.Id)) + ).ForMember( + dest => dest.ProjectStatusId, + // Explicitly and safely convert string ProjectStatusId to Guid ProjectStatusId + opt => opt.MapFrom(src => src.ProjectStatus == null ? Guid.Empty : new Guid(src.ProjectStatus.Id)) + ); + + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap() + .ForMember( + dest => dest.EmployeeId, + // Explicitly and safely convert string ProjectStatusId to Guid ProjectStatusId + opt => opt.MapFrom(src => src.EmpID)); + CreateMap(); + CreateMap(); + + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap() + .ForMember( + dest => dest.Description, + opt => opt.MapFrom(src => src.Comment)); + #endregion + + #region ======================================================= Projects ======================================================= + CreateMap(); + #endregion + } + } +} diff --git a/Marco.Pms.Services/Marco.Pms.Services.csproj b/Marco.Pms.Services/Marco.Pms.Services.csproj index a235e6a..2feafaf 100644 --- a/Marco.Pms.Services/Marco.Pms.Services.csproj +++ b/Marco.Pms.Services/Marco.Pms.Services.csproj @@ -11,6 +11,7 @@ + diff --git a/Marco.Pms.Services/Program.cs b/Marco.Pms.Services/Program.cs index 30831c6..5549702 100644 --- a/Marco.Pms.Services/Program.cs +++ b/Marco.Pms.Services/Program.cs @@ -1,4 +1,3 @@ -using System.Text; using Marco.Pms.CacheHelper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Authentication; @@ -7,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; @@ -16,47 +16,35 @@ using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using Serilog; - +using System.Text; var builder = WebApplication.CreateBuilder(args); +#region ======================= Service Configuration (Dependency Injection) ======================= + +#region Logging + // Add Serilog Configuration string? mongoConn = builder.Configuration["MongoDB:SerilogDatabaseUrl"]; string timeString = "00:00:30"; TimeSpan.TryParse(timeString, out TimeSpan timeSpan); -// Add Serilog Configuration builder.Host.UseSerilog((context, config) => { - config.ReadFrom.Configuration(context.Configuration) // Taking all configuration from appsetting.json - .WriteTo.MongoDB( + config.ReadFrom.Configuration(context.Configuration) + .WriteTo.MongoDB( databaseUrl: mongoConn ?? string.Empty, collectionName: "api-logs", batchPostingLimit: 100, period: timeSpan ); - }); +#endregion -// Add services -var corsSettings = builder.Configuration.GetSection("Cors"); -var allowedOrigins = corsSettings.GetValue("AllowedOrigins")?.Split(','); -var allowedMethods = corsSettings.GetValue("AllowedMethods")?.Split(','); -var allowedHeaders = corsSettings.GetValue("AllowedHeaders")?.Split(','); - +#region CORS (Cross-Origin Resource Sharing) builder.Services.AddCors(options => { - options.AddPolicy("Policy", policy => - { - if (allowedOrigins != null && allowedMethods != null && allowedHeaders != null) - { - policy.WithOrigins(allowedOrigins) - .WithMethods(allowedMethods) - .WithHeaders(allowedHeaders); - } - }); -}).AddCors(options => -{ + // A more permissive policy for development options.AddPolicy("DevCorsPolicy", policy => { policy.AllowAnyOrigin() @@ -64,93 +52,51 @@ builder.Services.AddCors(options => .AllowAnyHeader() .WithExposedHeaders("Authorization"); }); -}); -// Add services to the container. -builder.Services.AddHostedService(); + // A stricter policy for production (loaded from config) + var corsSettings = builder.Configuration.GetSection("Cors"); + var allowedOrigins = corsSettings.GetValue("AllowedOrigins")?.Split(',') ?? Array.Empty(); + options.AddPolicy("ProdCorsPolicy", policy => + { + policy.WithOrigins(allowedOrigins) + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); +#endregion + +#region Core Web & Framework Services builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddSignalR(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); -builder.Services.AddSwaggerGen(option => -{ - option.SwaggerDoc("v1", new OpenApiInfo { Title = "Demo API", Version = "v1" }); - option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme - { - In = ParameterLocation.Header, - Description = "Please enter a valid token", - Name = "Authorization", - Type = SecuritySchemeType.Http, - BearerFormat = "JWT", - Scheme = "Bearer" - }); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddMemoryCache(); +builder.Services.AddAutoMapper(typeof(Program)); +builder.Services.AddHostedService(); +#endregion - option.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type=ReferenceType.SecurityScheme, - Id="Bearer" - } - }, - new string[]{} - } - }); -}); +#region Database & Identity +string? connString = builder.Configuration.GetConnectionString("DefaultConnectionString") + ?? throw new InvalidOperationException("Database connection string 'DefaultConnectionString' not found."); -builder.Services.Configure(builder.Configuration.GetSection("SmtpSettings")); -builder.Services.AddTransient(); - -builder.Services.Configure(builder.Configuration.GetSection("AWS")); // For uploading images to aws s3 -builder.Services.AddTransient(); - -builder.Services.AddIdentity().AddEntityFrameworkStores().AddDefaultTokenProviders(); - - -string? connString = builder.Configuration.GetConnectionString("DefaultConnectionString"); +// This single call correctly registers BOTH the DbContext (scoped) AND the IDbContextFactory (singleton). +builder.Services.AddDbContextFactory(options => + options.UseMySql(connString, ServerVersion.AutoDetect(connString))); builder.Services.AddDbContext(options => -{ - options.UseMySql(connString, ServerVersion.AutoDetect(connString)); -}); + options.UseMySql(connString, ServerVersion.AutoDetect(connString))); +builder.Services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); +#endregion -builder.Services.AddMemoryCache(); - - -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); -//builder.Services.AddScoped(); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); - - -builder.Services.AddHttpContextAccessor(); - +#region Authentication (JWT) var jwtSettings = builder.Configuration.GetSection("Jwt").Get() ?? throw new InvalidOperationException("JwtSettings section is missing or invalid."); - if (jwtSettings != null && jwtSettings.Key != null) { + builder.Services.AddSingleton(jwtSettings); builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -168,71 +114,139 @@ if (jwtSettings != null && jwtSettings.Key != null) ValidAudience = jwtSettings.Audience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Key)) }; - + // This event allows SignalR to get the token from the query string options.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = context.Request.Query["access_token"]; - var path = context.HttpContext.Request.Path; - - // Match your hub route here - if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs/marco")) + if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs/marco")) { context.Token = accessToken; } - return Task.CompletedTask; } }; }); - builder.Services.AddSingleton(jwtSettings); } +#endregion -builder.Services.AddSignalR(); +#region API Documentation (Swagger) +builder.Services.AddSwaggerGen(option => +{ + option.SwaggerDoc("v1", new OpenApiInfo { Title = "Marco PMS API", Version = "v1" }); + option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Please enter a valid token", + Name = "Authorization", + Type = SecuritySchemeType.Http, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + option.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } + }, + Array.Empty() + } + }); +}); +#endregion + +#region Application-Specific Services +// Configuration-bound services +builder.Services.Configure(builder.Configuration.GetSection("SmtpSettings")); +builder.Services.Configure(builder.Configuration.GetSection("AWS")); + +// Transient services (lightweight, created each time) +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(); +builder.Services.AddScoped(); +#endregion + +#region Helpers +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +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(); +#endregion + +#region Web Server (Kestrel) builder.WebHost.ConfigureKestrel(options => { - options.AddServerHeader = false; // Disable the "Server" header + options.AddServerHeader = false; // Disable the "Server" header for security }); +#endregion + +#endregion var app = builder.Build(); +#region ===================== HTTP Request Pipeline Configuration ===================== + +// The order of middleware registration is critical for correct application behavior. + +#region Global Middleware (Run First) +// These custom middleware components run at the beginning of the pipeline to handle cross-cutting concerns. app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); +#endregion - - -// Configure the HTTP request pipeline. +#region Development Environment Configuration +// These tools are only enabled in the Development environment for debugging and API testing. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); - // Use CORS in the pipeline - app.UseCors("DevCorsPolicy"); } -else -{ - //if (app.Environment.IsProduction()) - //{ - // app.UseCors("ProdCorsPolicy"); - //} +#endregion - //app.UseCors("AllowAll"); - app.UseCors("DevCorsPolicy"); -} +#region Standard Middleware +// Common middleware for handling static content, security, and routing. +app.UseStaticFiles(); // Enables serving static files (e.g., from wwwroot) +app.UseHttpsRedirection(); // Redirects HTTP requests to HTTPS +#endregion -app.UseStaticFiles(); // Enables serving static files +#region Security (CORS, Authentication & Authorization) +// Security-related middleware must be in the correct order. +var corsPolicy = app.Environment.IsDevelopment() ? "DevCorsPolicy" : "ProdCorsPolicy"; +app.UseCors(corsPolicy); // CORS must be applied before Authentication/Authorization. -//app.UseSerilogRequestLogging(); // This is Default Serilog Logging Middleware we are not using this because we're using custom logging middleware +app.UseAuthentication(); // 1. Identifies who the user is. +app.UseAuthorization(); // 2. Determines what the identified user is allowed to do. +#endregion - -app.UseHttpsRedirection(); - - -app.UseAuthentication(); -app.UseAuthorization(); -app.MapHub("/hubs/marco"); +#region Endpoint Routing (Run Last) +// These map incoming requests to the correct controller actions or SignalR hubs. app.MapControllers(); +app.MapHub("/hubs/marco"); +#endregion -app.Run(); +#endregion + +app.Run(); \ No newline at end of file diff --git a/Marco.Pms.Services/Service/EmailSender.cs b/Marco.Pms.Services/Service/EmailSender.cs index 568510a..4d66a4f 100644 --- a/Marco.Pms.Services/Service/EmailSender.cs +++ b/Marco.Pms.Services/Service/EmailSender.cs @@ -150,18 +150,24 @@ namespace MarcoBMS.Services.Service emailBody = emailBody.Replace("{{TEAM_ON_SITE}}", BuildTeamOnSiteHtml(report.TeamOnSite)); emailBody = emailBody.Replace("{{PERFORMED_TASK}}", BuildPerformedTaskHtml(report.PerformedTasks, report.Date)); emailBody = emailBody.Replace("{{PERFORMED_ATTENDANCE}}", BuildPerformedAttendanceHtml(report.PerformedAttendance)); - var subjectReplacements = new Dictionary + if (!string.IsNullOrWhiteSpace(subject)) { - {"DATE", date }, - {"PROJECT_NAME", report.ProjectName} - }; - foreach (var item in subjectReplacements) - { - subject = subject.Replace($"{{{{{item.Key}}}}}", item.Value); + var subjectReplacements = new Dictionary + { + {"DATE", date }, + {"PROJECT_NAME", report.ProjectName} + }; + foreach (var item in subjectReplacements) + { + subject = subject.Replace($"{{{{{item.Key}}}}}", item.Value); + } + string env = _configuration["environment:Title"] ?? string.Empty; + subject = CheckSubject(subject); + } + if (toEmails.Count > 0) + { + await SendEmailAsync(toEmails, subject, emailBody); } - string env = _configuration["environment:Title"] ?? string.Empty; - subject = CheckSubject(subject); - await SendEmailAsync(toEmails, subject, emailBody); return emailBody; } public async Task SendOTP(List toEmails, string emailBody, string name, string otp, string subject) diff --git a/Marco.Pms.Services/Service/ILoggingService.cs b/Marco.Pms.Services/Service/ILoggingService.cs index 39dbb00..6d795cd 100644 --- a/Marco.Pms.Services/Service/ILoggingService.cs +++ b/Marco.Pms.Services/Service/ILoggingService.cs @@ -1,12 +1,11 @@ -using Serilog.Context; - -namespace MarcoBMS.Services.Service +namespace MarcoBMS.Services.Service { public interface ILoggingService { void LogInfo(string? message, params object[]? args); + void LogDebug(string? message, params object[]? args); void LogWarning(string? message, params object[]? args); - void LogError(string? message, params object[]? args); + void LogError(Exception? ex, string? message, params object[]? args); } } diff --git a/Marco.Pms.Services/Service/LoggingServices.cs b/Marco.Pms.Services/Service/LoggingServices.cs index 4328a2a..751f22c 100644 --- a/Marco.Pms.Services/Service/LoggingServices.cs +++ b/Marco.Pms.Services/Service/LoggingServices.cs @@ -11,17 +11,18 @@ namespace MarcoBMS.Services.Service _logger = logger; } - public void LogError(string? message, params object[]? args) + public void LogError(Exception? ex, string? message, params object[]? args) { using (LogContext.PushProperty("LogLevel", "Error")) if (args != null) { - _logger.LogError(message, args); + _logger.LogError(ex, message, args); } - else { - _logger.LogError(message); + else + { + _logger.LogError(ex, message); } - } + } public void LogInfo(string? message, params object[]? args) { @@ -35,6 +36,18 @@ namespace MarcoBMS.Services.Service _logger.LogInformation(message); } } + public void LogDebug(string? message, params object[]? args) + { + using (LogContext.PushProperty("LogLevel", "Information")) + if (args != null) + { + _logger.LogDebug(message, args); + } + else + { + _logger.LogDebug(message); + } + } public void LogWarning(string? message, params object[]? args) { @@ -49,6 +62,5 @@ namespace MarcoBMS.Services.Service } } } - } diff --git a/Marco.Pms.Services/Service/PermissionServices.cs b/Marco.Pms.Services/Service/PermissionServices.cs index ce7476b..9758a5f 100644 --- a/Marco.Pms.Services/Service/PermissionServices.cs +++ b/Marco.Pms.Services/Service/PermissionServices.cs @@ -1,7 +1,6 @@ using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; -using Marco.Pms.Model.Projects; using Marco.Pms.Services.Helpers; using MarcoBMS.Services.Helpers; using Microsoft.EntityFrameworkCore; @@ -12,13 +11,11 @@ namespace Marco.Pms.Services.Service { private readonly ApplicationDbContext _context; private readonly RolesHelper _rolesHelper; - private readonly ProjectsHelper _projectsHelper; private readonly CacheUpdateHelper _cache; - public PermissionServices(ApplicationDbContext context, RolesHelper rolesHelper, ProjectsHelper projectsHelper, CacheUpdateHelper cache) + public PermissionServices(ApplicationDbContext context, RolesHelper rolesHelper, CacheUpdateHelper cache) { _context = context; _rolesHelper = rolesHelper; - _projectsHelper = projectsHelper; _cache = cache; } @@ -27,30 +24,37 @@ namespace Marco.Pms.Services.Service var featurePermissionIds = await _cache.GetPermissions(employeeId); if (featurePermissionIds == null) { - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(employeeId); + List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeId(employeeId); featurePermissionIds = featurePermission.Select(fp => fp.Id).ToList(); } var hasPermission = featurePermissionIds.Contains(featurePermissionId); return hasPermission; } - public async Task HasProjectPermission(Employee emp, string projectId) + public async Task HasProjectPermission(Employee LoggedInEmployee, Guid projectId) { - List featurePermission = await _rolesHelper.GetFeaturePermissionByEmployeeID(emp.Id); - string[] projectsId = []; + var employeeId = LoggedInEmployee.Id; + var projectIds = await _cache.GetProjects(employeeId); - /* User with permission manage project can see all projects */ - if (featurePermission != null && featurePermission.Exists(c => c.Id.ToString() == "172fc9b6-755b-4f62-ab26-55c34a330614")) + if (projectIds == null) { - List projects = await _projectsHelper.GetAllProjectByTanentID(emp.TenantId); - projectsId = projects.Select(c => c.Id.ToString()).ToArray(); + var hasPermission = await HasPermission(PermissionsMaster.ManageProject, employeeId); + if (hasPermission) + { + var projects = await _context.Projects.Where(c => c.TenantId == LoggedInEmployee.TenantId).ToListAsync(); + projectIds = projects.Select(p => p.Id).ToList(); + } + else + { + var allocation = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.IsActive).ToListAsync(); + if (!allocation.Any()) + { + return false; + } + projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); + } + await _cache.AddProjects(LoggedInEmployee.Id, projectIds); } - else - { - List allocation = await _projectsHelper.GetProjectByEmployeeID(emp.Id); - projectsId = allocation.Select(c => c.ProjectId.ToString()).ToArray(); - } - bool response = projectsId.Contains(projectId); - return response; + return projectIds.Contains(projectId); } } } diff --git a/Marco.Pms.Services/Service/ProjectServices.cs b/Marco.Pms.Services/Service/ProjectServices.cs new file mode 100644 index 0000000..9406ec9 --- /dev/null +++ b/Marco.Pms.Services/Service/ProjectServices.cs @@ -0,0 +1,1751 @@ +using AutoMapper; +using AutoMapper.QueryableExtensions; +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.Employee; +using Marco.Pms.Model.ViewModels.Projects; +using Marco.Pms.Services.Helpers; +using Marco.Pms.Services.Service.ServiceInterfaces; +using MarcoBMS.Services.Service; +using Microsoft.CodeAnalysis; +using Microsoft.EntityFrameworkCore; +using Project = Marco.Pms.Model.Projects.Project; + +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 PermissionServices _permission; + private readonly CacheUpdateHelper _cache; + private readonly IMapper _mapper; + private readonly GeneralHelper _generalHelper; + public ProjectServices( + IDbContextFactory dbContextFactory, + ApplicationDbContext context, + ILoggingService logger, + PermissionServices permission, + CacheUpdateHelper cache, + IMapper mapper, + GeneralHelper generalHelper) + { + _dbContextFactory = dbContextFactory ?? throw new ArgumentNullException(nameof(dbContextFactory)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _permission = permission ?? throw new ArgumentNullException(nameof(permission)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + _generalHelper = generalHelper ?? throw new ArgumentNullException(nameof(generalHelper)); + } + #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 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(ex, "An unexpected error occurred in GetAllProjectsBasic for tenant {TenantId}.", tenantId); + 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 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(ex, "An unexpected error occurred in GetAllProjects for tenant {TenantId}.", tenantId); + 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(ex, "An unexpected error occurred while getting project {ProjectId}", id); + 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(ex, "An unexpected error occurred in Get Project Details for project {ProjectId} for tenant {TenantId}. ", id, tenantId); + 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(ex, "Failed to create project in database. Rolling back transaction."); + // 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.LogError(ex, "Project {ProjectId} was created, but a post-creation side-effect (caching/notification) failed. ", 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); + } + + /// + /// 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.LogError(ex, "Concurrency conflict while updating project {ProjectId} ", id); + return ApiResponse.ErrorResponse("Conflict occurred.", "This project has been modified by someone else. Please refresh and try again.", 409); + } + + // --- Step 4: Perform Side-Effects (Fire and Forget) --- + // 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(projectDto, "Project updated successfully.", 200); + } + catch (Exception ex) + { + // --- Step 6: Graceful Error Handling for Unexpected Errors --- + _logger.LogError(ex, "An unexpected error occurred while updating project {ProjectId} ", id); + return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); + } + } + + #endregion + + #region =================================================================== Project Allocation APIs =================================================================== + + /// + /// Retrieves a list of employees for a specific project. + /// This method is optimized to perform all filtering and mapping on the database server. + /// + /// The ID of the project. + /// Whether to include employees from inactive allocations. + /// The ID of the current tenant. + /// The current authenticated employee (used for permission checks). + /// An ApiResponse containing a list of employees or an error. + public async Task> GetEmployeeByProjectIdAsync(Guid? projectId, bool includeInactive, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (projectId == null) + { + _logger.LogWarning("GetEmployeeByProjectID called with a null projectId."); + // 400 Bad Request is more appropriate for invalid input than 404 Not Found. + return ApiResponse.ErrorResponse("Project ID is required.", "Invalid Input Parameter", 400); + } + + _logger.LogInfo("Fetching employees for ProjectID: {ProjectId}, IncludeInactive: {IncludeInactive}", projectId, includeInactive); + + try + { + // --- CRITICAL: Security Check --- + // Before fetching data, you MUST verify the user has permission to see it. + // This is a placeholder for your actual permission logic. + var hasProjectPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); + var hasAllEmployeePermission = await _permission.HasPermission(PermissionsMaster.ViewAllEmployees, loggedInEmployee.Id); + var hasviewTeamPermission = await _permission.HasPermission(PermissionsMaster.ViewTeamMembers, loggedInEmployee.Id); + + if (!(hasProjectPermission && (hasAllEmployeePermission || hasviewTeamPermission))) + { + _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to view this project's team.", 403); + } + + // --- Step 2: Build a Single, Efficient IQueryable --- + // We start with the base query and conditionally add filters before executing it. + // This avoids code duplication and is highly performant. + var employeeQuery = _context.ProjectAllocations + .Where(pa => pa.ProjectId == projectId && pa.TenantId == tenantId); + + // Conditionally apply the filter for active allocations. + if (!includeInactive) + { + employeeQuery = employeeQuery.Where(pa => pa.IsActive); + } + + // --- Step 3: Project Directly to the ViewModel on the Database Server --- + // This is the most significant performance optimization. + // Instead of fetching full Employee entities, we select only the data needed for the EmployeeVM. + // AutoMapper's ProjectTo is perfect for this, as it translates the mapping configuration into an efficient SQL SELECT statement. + var resultVM = await employeeQuery + .Where(pa => pa.Employee != null) // Safety check for data integrity + .Select(pa => pa.Employee) // Navigate to the Employee entity + .ProjectTo(_mapper.ConfigurationProvider) // Let AutoMapper generate the SELECT + .ToListAsync(); + + _logger.LogInfo("Successfully fetched {EmployeeCount} employees for project {ProjectId}.", resultVM.Count, projectId); + + // Note: The original mapping loop is now completely gone, replaced by the single efficient query above. + + return ApiResponse.SuccessResponse(resultVM, "Successfully fetched the list of employees for the selected project.", 200); + } + catch (Exception ex) + { + // --- Step 4: Graceful Error Handling --- + _logger.LogError(ex, "An error occurred while fetching employees for project {ProjectId}. ", projectId); + return ApiResponse.ErrorResponse("An internal server error occurred.", "Database Query Failed", 500); + } + } + + /// + /// Retrieves project allocation details for a specific project. + /// This method is optimized for performance and includes security checks. + /// + /// The ID of the project. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing allocation details or an appropriate error. + public async Task> GetProjectAllocationAsync(Guid? projectId, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (projectId == null) + { + _logger.LogWarning("GetProjectAllocation called with a null projectId."); + return ApiResponse.ErrorResponse("Project ID is required.", "Invalid Input Parameter", 400); + } + + _logger.LogInfo("Fetching allocations for ProjectID: {ProjectId} for user {UserId}", projectId, loggedInEmployee.Id); + + try + { + // --- Step 2: Security and Existence Checks --- + // Before fetching data, you MUST verify the user has permission to see it. + // This is a placeholder for your actual permission logic. + var hasPermission = await _permission.HasProjectPermission(loggedInEmployee, projectId.Value); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, projectId); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to view this project's team.", 403); + } + + // --- Step 3: Execute a Single, Optimized Database Query --- + // This query projects directly to a new object on the database server, which is highly efficient. + var allocations = await _context.ProjectAllocations + // Filter down to the relevant records first. + .Where(pa => pa.ProjectId == projectId && pa.TenantId == tenantId && pa.Employee != null) + // Project directly to the final shape. This tells EF Core which columns to select. + // The redundant .Include() is removed as EF Core infers the JOIN from this Select. + .Select(pa => new + { + // Fields from ProjectAllocation + ID = pa.Id, + pa.EmployeeId, + pa.ProjectId, + pa.AllocationDate, + pa.ReAllocationDate, + pa.IsActive, + + // Fields from the joined Employee table (no null checks needed due to the 'Where' clause) + FirstName = pa.Employee!.FirstName, + LastName = pa.Employee.LastName, + MiddleName = pa.Employee.MiddleName, + + // Simplified JobRoleId logic: Use the allocation's role if it exists, otherwise fall back to the employee's default role. + JobRoleId = pa.JobRoleId ?? pa.Employee.JobRoleId + }) + .ToListAsync(); + + _logger.LogInfo("Successfully fetched {AllocationCount} allocations for project {ProjectId}.", allocations.Count, projectId); + + return ApiResponse.SuccessResponse(allocations, "Project allocations retrieved successfully.", 200); + } + catch (Exception ex) + { + // --- Step 4: Graceful Error Handling --- + // Log the full exception for debugging, but return a generic, safe error message. + _logger.LogError(ex, "An error occurred while fetching allocations for project {ProjectId}.", projectId); + return ApiResponse.ErrorResponse("An internal server error occurred.", "Database query failed.", 500); + } + } + + /// + /// Manages project allocations for a list of employees, either adding new allocations or deactivating existing ones. + /// This method is optimized to perform all database operations in a single transaction. + /// + /// The list of allocation changes to process. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing the list of processed allocations. + public async Task>> ManageAllocationAsync(List allocationsDto, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (allocationsDto == null || !allocationsDto.Any()) + { + return ApiResponse>.ErrorResponse("Invalid details.", "Allocation details list cannot be null or empty.", 400); + } + + _logger.LogInfo("Starting to manage {AllocationCount} allocations for user {UserId}.", allocationsDto.Count, loggedInEmployee.Id); + + // --- (Placeholder) Security Check --- + // In a real application, you would check if the loggedInEmployee has permission + // to manage allocations for ALL projects involved in this batch. + var projectIdsInBatch = allocationsDto.Select(a => a.ProjectId).Distinct().ToList(); + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageTeam, loggedInEmployee.Id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to manage allocations for projects.", loggedInEmployee.Id); + return ApiResponse>.ErrorResponse("Access Denied.", "You do not have permission to manage one or more projects in this request.", 403); + } + + // --- Step 2: Fetch all relevant existing data in ONE database call --- + var employeeProjectPairs = allocationsDto.Select(a => new { a.EmpID, a.ProjectId }).ToList(); + List employeeIds = allocationsDto.Select(a => a.EmpID).Distinct().ToList(); + + // Fetch all currently active allocations for the employees and projects in this batch. + // We use a dictionary for fast O(1) lookups inside the loop. + var existingAllocations = await _context.ProjectAllocations + .Where(pa => pa.TenantId == tenantId && + employeeIds.Contains(pa.EmployeeId) && + pa.ReAllocationDate == null) + .ToDictionaryAsync(pa => (pa.EmployeeId, pa.ProjectId)); + + var processedAllocations = new List(); + + // --- Step 3: Process logic IN MEMORY --- + foreach (var dto in allocationsDto) + { + var key = (dto.EmpID, dto.ProjectId); + existingAllocations.TryGetValue(key, out var existingAllocation); + + if (dto.Status == false) // User wants to DEACTIVATE the allocation + { + if (existingAllocation != null) + { + // Mark the existing allocation for deactivation + existingAllocation.ReAllocationDate = DateTime.UtcNow; // Use UtcNow for servers + existingAllocation.IsActive = false; + _context.ProjectAllocations.Update(existingAllocation); + processedAllocations.Add(existingAllocation); + } + // If it doesn't exist, we do nothing. The desired state is "not allocated". + } + else // User wants to ACTIVATE the allocation + { + if (existingAllocation == null) + { + // Create a new allocation because one doesn't exist + var newAllocation = _mapper.Map(dto); + newAllocation.TenantId = tenantId; + newAllocation.AllocationDate = DateTime.UtcNow; + newAllocation.IsActive = true; + _context.ProjectAllocations.Add(newAllocation); + processedAllocations.Add(newAllocation); + } + // If it already exists and is active, we do nothing. The state is already correct. + } + try + { + await _cache.ClearAllProjectIds(dto.EmpID); + _logger.LogInfo("Successfully completed cache invalidation for employee {EmployeeId}.", dto.EmpID); + } + catch (Exception ex) + { + // Log the error but don't fail the entire request, as the primary DB operation succeeded. + _logger.LogError(ex, "Cache invalidation failed for employees after a successful database update."); + } + } + + try + { + // --- Step 4: Save all changes in a SINGLE TRANSACTION --- + // All Adds and Updates are sent to the database in one batch. + // If any part fails, the entire transaction is rolled back. + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully saved {ChangeCount} allocation changes to the database.", processedAllocations.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save allocation changes to the database."); + return ApiResponse>.ErrorResponse("Database Error.", "An error occurred while saving the changes.", 500); + } + + + // --- Step 5: Map results and return success --- + var resultVm = _mapper.Map>(processedAllocations); + return ApiResponse>.SuccessResponse(resultVm, "Allocations managed successfully.", 200); + } + + /// + /// Retrieves a list of active projects assigned to a specific employee. + /// + /// The ID of the employee whose projects are being requested. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing a list of basic project details or an error. + public async Task> GetProjectsByEmployeeAsync(Guid employeeId, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (employeeId == Guid.Empty) + { + return ApiResponse.ErrorResponse("Invalid details.", "A valid employee ID is required.", 400); + } + + _logger.LogInfo("Fetching projects for Employee {EmployeeId} by User {UserId}", employeeId, loggedInEmployee.Id); + + try + { + // --- Step 2: Clarified Security Check --- + // The permission should be about viewing another employee's assignments, not a generic "Manage Team". + // This is a placeholder for your actual, more specific permission logic. + // It should also handle the case where a user is requesting their own projects (employeeId == loggedInEmployee.Id). + var hasPermission = await _permission.HasPermission(PermissionsMaster.ViewProject, loggedInEmployee.Id); + var projectIds = await GetMyProjects(tenantId, loggedInEmployee); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to view projects for employee {TargetEmployeeId}.", loggedInEmployee.Id, employeeId); + return ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to view this employee's projects.", 403); + } + + // --- Step 3: Execute a Single, Highly Efficient Database Query --- + // This query projects directly to the ViewModel on the database server. + var projects = await _context.ProjectAllocations + // 1. Filter the linking table down to the relevant records. + .Where(pa => + pa.TenantId == tenantId && + pa.EmployeeId == employeeId && // Target the specified employee + pa.IsActive && // Only active assignments + projectIds.Contains(pa.ProjectId) && + pa.Project != null) // Safety check for data integrity + + // 2. Navigate to the Project entity. + .Select(pa => pa.Project) + + // 3. Ensure the final result set is unique (in case of multiple active allocations to the same project). + .Distinct() + + // 4. Project directly to the ViewModel using AutoMapper's IQueryable Extensions. + // This generates an efficient SQL "SELECT Id, Name, Code FROM..." statement. + .ProjectTo(_mapper.ConfigurationProvider) + + // 5. Execute the query. + .ToListAsync(); + + _logger.LogInfo("Successfully retrieved {ProjectCount} projects for employee {EmployeeId}.", projects.Count, employeeId); + + // The original check for an empty list is still good practice. + if (!projects.Any()) + { + return ApiResponse.SuccessResponse(new List(), "No active projects found for this employee.", 200); + } + + return ApiResponse.SuccessResponse(projects, "Projects retrieved successfully.", 200); + } + catch (Exception ex) + { + // --- Step 4: Graceful Error Handling --- + _logger.LogError(ex, "An error occurred while fetching projects for employee {EmployeeId}.", employeeId); + return ApiResponse.ErrorResponse("An internal server error occurred.", "Database query failed.", 500); + } + } + + /// + /// Manages project assignments for a single employee, processing a batch of projects to activate or deactivate. + /// This method is optimized to perform all database operations in a single, atomic transaction. + /// + /// A list of projects to assign or un-assign. + /// The ID of the employee whose assignments are being managed. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing the list of processed allocations. + public async Task>> AssigneProjectsToEmployeeAsync(List allocationsDto, Guid employeeId, Guid tenantId, Employee loggedInEmployee) + { + // --- Step 1: Input Validation --- + if (allocationsDto == null || !allocationsDto.Any() || employeeId == Guid.Empty) + { + return ApiResponse>.ErrorResponse("Invalid details.", "A valid employee ID and a list of projects are required.", 400); + } + + _logger.LogInfo("Starting to manage {AllocationCount} project assignments for Employee {EmployeeId}.", allocationsDto.Count, employeeId); + + // --- (Placeholder) Security Check --- + // You MUST verify that the loggedInEmployee has permission to modify the assignments for the target employeeId. + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageTeam, loggedInEmployee.Id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to manage assignments for employee {TargetEmployeeId}.", loggedInEmployee.Id, employeeId); + return ApiResponse>.ErrorResponse("Access Denied.", "You do not have permission to manage this employee's assignments.", 403); + } + + // --- Step 2: Fetch all relevant existing data in ONE database call --- + var projectIdsInDto = allocationsDto.Select(p => p.ProjectId).ToList(); + + // Fetch all currently active allocations for this employee for the projects in the request. + // We use a dictionary keyed by ProjectId for fast O(1) lookups inside the loop. + var existingActiveAllocations = await _context.ProjectAllocations + .Where(pa => pa.TenantId == tenantId && + pa.EmployeeId == employeeId && + projectIdsInDto.Contains(pa.ProjectId) && + pa.ReAllocationDate == null) // Only fetch active ones + .ToDictionaryAsync(pa => pa.ProjectId); + + var processedAllocations = new List(); + + // --- Step 3: Process all logic IN MEMORY, tracking changes --- + foreach (var dto in allocationsDto) + { + existingActiveAllocations.TryGetValue(dto.ProjectId, out var existingAllocation); + + if (dto.Status == false) // DEACTIVATE this project assignment + { + if (existingAllocation != null) + { + // Correct Update Pattern: Modify the fetched entity directly. + existingAllocation.ReAllocationDate = DateTime.UtcNow; // Use UTC for servers + existingAllocation.IsActive = false; + _context.ProjectAllocations.Update(existingAllocation); + processedAllocations.Add(existingAllocation); + } + // If it's not in our dictionary, it's already inactive. Do nothing. + } + else // ACTIVATE this project assignment + { + if (existingAllocation == null) + { + // Create a new allocation because an active one doesn't exist. + var newAllocation = _mapper.Map(dto); + newAllocation.EmployeeId = employeeId; + newAllocation.TenantId = tenantId; + newAllocation.AllocationDate = DateTime.UtcNow; + newAllocation.IsActive = true; + _context.ProjectAllocations.Add(newAllocation); + processedAllocations.Add(newAllocation); + } + // If it already exists in our dictionary, it's already active. Do nothing. + } + } + + try + { + // --- Step 4: Save all Adds and Updates in a SINGLE ATOMIC TRANSACTION --- + if (processedAllocations.Any()) + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully saved {ChangeCount} assignment changes for employee {EmployeeId}.", processedAllocations.Count, employeeId); + } + } + catch (DbUpdateException ex) + { + _logger.LogError(ex, "Failed to save assignment changes for employee {EmployeeId}.", employeeId); + return ApiResponse>.ErrorResponse("Database Error.", "An error occurred while saving the changes.", 500); + } + + // --- Step 5: Invalidate Cache ONCE after successful save --- + try + { + await _cache.ClearAllProjectIds(employeeId); + _logger.LogInfo("Successfully queued cache invalidation for employee {EmployeeId}.", employeeId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background cache invalidation failed for employee {EmployeeId}", employeeId); + } + + // --- Step 6: Map results using AutoMapper and return success --- + var resultVm = _mapper.Map>(processedAllocations); + return ApiResponse>.SuccessResponse(resultVm, "Assignments managed successfully.", 200); + } + + #endregion + + #region =================================================================== Project InfraStructure Get APIs =================================================================== + + /// + /// Retrieves the full infrastructure hierarchy (Buildings, Floors, Work Areas) for a project, + /// including aggregated work summaries. + /// + public async Task> GetInfraDetailsAsync(Guid projectId, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("GetInfraDetails called for ProjectId: {ProjectId}", projectId); + + try + { + // --- Step 1: Run independent permission checks in PARALLEL --- + var projectPermissionTask = _permission.HasProjectPermission(loggedInEmployee, projectId); + var viewInfraPermissionTask = _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); + + await Task.WhenAll(projectPermissionTask, viewInfraPermissionTask); + + if (!await projectPermissionTask) + { + _logger.LogWarning("Project access denied for EmployeeId: {EmployeeId} on ProjectId: {ProjectId}", loggedInEmployee.Id, projectId); + return ApiResponse.ErrorResponse("Access denied", "You don't have access to this project", 403); + } + if (!await viewInfraPermissionTask) + { + _logger.LogWarning("ViewInfra permission denied for EmployeeId: {EmployeeId}", loggedInEmployee.Id); + return ApiResponse.ErrorResponse("Access denied", "You don't have access to view this project's infrastructure", 403); + } + + // --- Step 2: Cache-First Strategy --- + var cachedResult = await _cache.GetBuildingInfra(projectId); + if (cachedResult != null) + { + _logger.LogInfo("Cache HIT for infra details for ProjectId: {ProjectId}", projectId); + return ApiResponse.SuccessResponse(cachedResult, "Infra details fetched successfully from cache.", 200); + } + + _logger.LogInfo("Cache MISS for infra details for ProjectId: {ProjectId}. Fetching from database.", projectId); + + // --- Step 3: Fetch all required data from the database --- + + var buildingMongoList = await _generalHelper.GetProjectInfraFromDB(projectId); + // --- Step 5: Proactively update the cache --- + //await _cache.SetBuildingInfra(projectId, buildingMongoList); + + _logger.LogInfo("Infra details fetched successfully for ProjectId: {ProjectId}, Buildings: {Count}", projectId, buildingMongoList.Count); + return ApiResponse.SuccessResponse(buildingMongoList, "Infra details fetched successfully", 200); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while fetching infra details for ProjectId: {ProjectId}", projectId); + return ApiResponse.ErrorResponse("An internal server error occurred.", "An error occurred while processing your request.", 500); + } + } + + /// + /// Retrieves a list of work items for a specific work area, ensuring the user has appropriate permissions. + /// + /// The ID of the work area. + /// The ID of the current tenant. + /// The current authenticated employee for permission checks. + /// An ApiResponse containing a list of work items or an error. + public async Task> GetWorkItemsAsync(Guid workAreaId, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("GetWorkItems called for WorkAreaId: {WorkAreaId} by User: {UserId}", workAreaId, loggedInEmployee.Id); + + try + { + // --- Step 1: Cache-First Strategy --- + var cachedWorkItems = await _cache.GetWorkItemDetailsByWorkArea(workAreaId); + if (cachedWorkItems != null) + { + _logger.LogInfo("Cache HIT for WorkAreaId: {WorkAreaId}. Returning {Count} items from cache.", workAreaId, cachedWorkItems.Count); + return ApiResponse.SuccessResponse(cachedWorkItems, $"{cachedWorkItems.Count} tasks retrieved successfully from cache.", 200); + } + + _logger.LogInfo("Cache MISS for WorkAreaId: {WorkAreaId}. Fetching from database.", workAreaId); + + // --- Step 2: Security Check First --- + // This pattern remains the most robust: verify permissions before fetching a large list. + var projectInfo = await _context.WorkAreas + .Where(wa => wa.Id == workAreaId && wa.TenantId == tenantId && wa.Floor != null && wa.Floor.Building != null) + .Select(wa => new { wa.Floor!.Building!.ProjectId }) + .FirstOrDefaultAsync(); + + if (projectInfo == null) + { + _logger.LogWarning("Work Area not found for WorkAreaId: {WorkAreaId}", workAreaId); + return ApiResponse.ErrorResponse("Not Found", $"Work Area with ID {workAreaId} not found.", 404); + } + + var hasProjectAccess = await _permission.HasProjectPermission(loggedInEmployee, projectInfo.ProjectId); + var hasGenericViewInfraPermission = await _permission.HasPermission(PermissionsMaster.ViewProjectInfra, loggedInEmployee.Id); + + if (!hasProjectAccess || !hasGenericViewInfraPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} on WorkAreaId {WorkAreaId}.", loggedInEmployee.Id, workAreaId); + return ApiResponse.ErrorResponse("Access Denied", "You do not have sufficient permissions to view these work items.", 403); + } + + // --- Step 3: Fetch Full Entities for Caching and Mapping --- + var workItemVMs = await _generalHelper.GetWorkItemsListFromDB(workAreaId); + + // --- Step 5: Proactively Update the Cache with the Correct Object Type --- + // We now pass the 'workItemsFromDb' list, which is the required List. + + try + { + await _cache.ManageWorkItemDetailsByVM(workItemVMs); + _logger.LogInfo("Successfully queued cache update for WorkAreaId: {WorkAreaId}", workAreaId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background cache update failed for WorkAreaId: {WorkAreaId}", workAreaId); + } + + + _logger.LogInfo("{Count} work items fetched successfully for WorkAreaId: {WorkAreaId}", workItemVMs.Count, workAreaId); + return ApiResponse.SuccessResponse(workItemVMs, $"{workItemVMs.Count} tasks fetched successfully.", 200); + } + catch (Exception ex) + { + // --- Step 6: Graceful Error Handling --- + _logger.LogError(ex, "An unexpected error occurred while getting work items for WorkAreaId: {WorkAreaId}", workAreaId); + return ApiResponse.ErrorResponse("An internal server error occurred.", null, 500); + } + } + + #endregion + + #region =================================================================== Project Infrastructre Manage APIs =================================================================== + + public async Task ManageProjectInfraAsync(List infraDtos, Guid tenantId, Employee loggedInEmployee) + { + // 1. Guard Clause: Handle null or empty input gracefully. + if (infraDtos == null || !infraDtos.Any()) + { + return new ServiceResponse + { + Response = ApiResponse.ErrorResponse("Invalid details.", "No infrastructure details were provided.", 400) + }; + } + + var responseData = new InfraVM(); + var messages = new List(); + var projectIds = new HashSet(); // Use HashSet for automatic duplicate handling. + var cacheUpdateTasks = new List(); + + // --- Pre-fetch parent entities to avoid N+1 query problem --- + // 2. Gather all parent IDs needed for validation and context. + var requiredBuildingIds = infraDtos + .Where(i => i.Floor?.BuildingId != null) + .Select(i => i.Floor!.BuildingId) + .Distinct() + .ToList(); + + var requiredFloorIds = infraDtos + .Where(i => i.WorkArea?.FloorId != null) + .Select(i => i.WorkArea!.FloorId) + .Distinct() + .ToList(); + + // 3. Fetch all required parent entities in single batch queries. + var buildingsDict = await _context.Buildings + .Where(b => requiredBuildingIds.Contains(b.Id)) + .ToDictionaryAsync(b => b.Id); + + var floorsDict = await _context.Floor + .Include(f => f.Building) // Eagerly load Building for later use + .Where(f => requiredFloorIds.Contains(f.Id)) + .ToDictionaryAsync(f => f.Id); + // --- End Pre-fetching --- + + // 4. Process all entities and add them to the context's change tracker. + foreach (var item in infraDtos) + { + if (item.Building != null) + { + ProcessBuilding(item.Building, tenantId, responseData, messages, projectIds, cacheUpdateTasks); + } + if (item.Floor != null) + { + ProcessFloor(item.Floor, tenantId, responseData, messages, projectIds, cacheUpdateTasks, buildingsDict); + } + if (item.WorkArea != null) + { + ProcessWorkArea(item.WorkArea, tenantId, responseData, messages, projectIds, cacheUpdateTasks, floorsDict); + } + } + + // 5. Save all changes to the database in a single transaction. + var changedRecordCount = await _context.SaveChangesAsync(); + + // If no changes were actually made, we can exit early. + if (changedRecordCount == 0) + { + return new ServiceResponse + { + Response = ApiResponse.SuccessResponse(responseData, "No changes detected in the provided infrastructure details.", 200) + }; + } + + // 6. Execute all cache updates concurrently after the DB save is successful. + await Task.WhenAll(cacheUpdateTasks); + + // 7. Consolidate messages and create notification payload. + string finalResponseMessage = messages.LastOrDefault() ?? "Infrastructure managed successfully."; + string logMessage = $"{string.Join(", ", messages)} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Infra", ProjectIds = projectIds.ToList(), Message = logMessage }; + + // TODO: Dispatch the 'notification' object to your notification service. + + return new ServiceResponse + { + Notification = notification, + Response = ApiResponse.SuccessResponse(responseData, finalResponseMessage, 200) + }; + } + + /// + /// Creates or updates a batch of work items. + /// This method is optimized to perform all database operations in a single, atomic transaction. + /// + public async Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee) + { + _logger.LogInfo("CreateProjectTask called with {Count} items by user {UserId}", workItemDtos?.Count ?? 0, loggedInEmployee.Id); + + // --- Step 1: Input Validation --- + if (workItemDtos == null || !workItemDtos.Any()) + { + _logger.LogWarning("No work items provided in the request."); + return ApiResponse>.ErrorResponse("Invalid details.", "Work Item details list cannot be empty.", 400); + } + + // --- Step 2: Fetch all required existing data in bulk --- + var workAreaIds = workItemDtos.Select(d => d.WorkAreaID).Distinct().ToList(); + var workItemIdsToUpdate = workItemDtos.Where(d => d.Id.HasValue).Select(d => d.Id!.Value).ToList(); + + // Fetch all relevant WorkAreas and their parent hierarchy in ONE query + var workAreasFromDb = await _context.WorkAreas + .Where(wa => wa.Floor != null && wa.Floor.Building != null && workAreaIds.Contains(wa.Id) && wa.TenantId == tenantId) + .Include(wa => wa.Floor!.Building) // Eagerly load the entire path + .ToDictionaryAsync(wa => wa.Id); // Dictionary for fast lookups + + // Fetch all existing WorkItems that need updating in ONE query + var existingWorkItemsToUpdate = await _context.WorkItems + .Where(wi => workItemIdsToUpdate.Contains(wi.Id) && wi.TenantId == tenantId) + .ToDictionaryAsync(wi => wi.Id); // Dictionary for fast lookups + + // --- (Placeholder) Security Check --- + // You MUST verify the user has permission to modify ALL WorkAreas in the batch. + var projectIdsInBatch = workAreasFromDb.Values.Select(wa => wa.Floor!.Building!.ProjectId).Distinct(); + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProjectInfra, loggedInEmployee.Id); + if (!hasPermission) + { + _logger.LogWarning("Access DENIED for user {UserId} trying to create/update tasks.", loggedInEmployee.Id); + return ApiResponse>.ErrorResponse("Access Denied.", "You do not have permission to modify tasks in one or more of the specified work areas.", 403); + } + + var workItemsToCreate = new List(); + var workItemsToModify = new List(); + var workDeltaForCache = new Dictionary(); // WorkAreaId -> (Delta) + string message = ""; + + // --- Step 3: Process all logic IN MEMORY, tracking changes --- + foreach (var dto in workItemDtos) + { + if (!workAreasFromDb.TryGetValue(dto.WorkAreaID, out var workArea)) + { + _logger.LogWarning("Skipping item because WorkAreaId {WorkAreaId} was not found or is invalid.", dto.WorkAreaID); + continue; // Skip this item as its parent WorkArea is invalid + } + + if (dto.Id.HasValue && existingWorkItemsToUpdate.TryGetValue(dto.Id.Value, out var existingWorkItem)) + { + // --- UPDATE Logic --- + var plannedDelta = dto.PlannedWork - existingWorkItem.PlannedWork; + var completedDelta = dto.CompletedWork - existingWorkItem.CompletedWork; + + // Apply changes from DTO to the fetched entity to prevent data loss + _mapper.Map(dto, existingWorkItem); + workItemsToModify.Add(existingWorkItem); + + // Track the change in work for cache update + workDeltaForCache[workArea.Id] = ( + workDeltaForCache.GetValueOrDefault(workArea.Id).Planned + plannedDelta, + workDeltaForCache.GetValueOrDefault(workArea.Id).Completed + completedDelta + ); + message = $"Task Updated in Building: {workArea.Floor?.Building?.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + } + else + { + // --- CREATE Logic --- + var newWorkItem = _mapper.Map(dto); + newWorkItem.Id = Guid.NewGuid(); // Ensure new GUID is set + newWorkItem.TenantId = tenantId; + workItemsToCreate.Add(newWorkItem); + + // Track the change in work for cache update + workDeltaForCache[workArea.Id] = ( + workDeltaForCache.GetValueOrDefault(workArea.Id).Planned + newWorkItem.PlannedWork, + workDeltaForCache.GetValueOrDefault(workArea.Id).Completed + newWorkItem.CompletedWork + ); + message = $"Task Added in Building: {workArea.Floor?.Building?.Name}, on Floor: {workArea.Floor?.FloorName}, in Area: {workArea.AreaName} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}"; + } + } + + try + { + // --- Step 4: Save all database changes in a SINGLE TRANSACTION --- + if (workItemsToCreate.Any()) _context.WorkItems.AddRange(workItemsToCreate); + if (workItemsToModify.Any()) _context.WorkItems.UpdateRange(workItemsToModify); // EF Core handles individual updates correctly here + + if (workItemsToCreate.Any() || workItemsToModify.Any()) + { + await _context.SaveChangesAsync(); + _logger.LogInfo("Successfully saved {CreatedCount} new and {UpdatedCount} updated work items.", workItemsToCreate.Count, workItemsToModify.Count); + + // --- Step 5: Update Cache and SignalR AFTER successful DB save --- + var allAffectedItems = workItemsToCreate.Concat(workItemsToModify).ToList(); + + await UpdateCacheAndNotify(workDeltaForCache, allAffectedItems); + } + } + catch (DbUpdateException ex) + { + _logger.LogError(ex, "A database error occurred while creating/updating tasks."); + return ApiResponse>.ErrorResponse("Database Error", "Failed to save changes.", 500); + } + + // --- Step 6: Prepare and return the response --- + var allProcessedItems = workItemsToCreate.Concat(workItemsToModify).ToList(); + var responseList = allProcessedItems.Select(wi => new WorkItemVM + { + WorkItemId = wi.Id, + WorkItem = wi + }).ToList(); + + + return ApiResponse>.SuccessResponse(responseList, message, 200); + } + + public async Task DeleteProjectTaskAsync(Guid id, Guid tenantId, Employee loggedInEmployee) + { + // 1. Fetch the task and its parent data in a single query. + // This is still a major optimization, avoiding a separate query for the floor/building. + WorkItem? task = await _context.WorkItems + .AsNoTracking() // Use AsNoTracking because we will re-attach for deletion later. + .Include(t => t.WorkArea) + .ThenInclude(wa => wa!.Floor) + .ThenInclude(f => f!.Building) + .FirstOrDefaultAsync(t => t.Id == id && t.TenantId == tenantId); + + // 2. Guard Clause: Handle non-existent task. + if (task == null) + { + _logger.LogWarning("Attempted to delete a non-existent task with ID {WorkItemId}", id); + return new ServiceResponse + { + Response = ApiResponse.ErrorResponse("Task not found.", $"A task with ID {id} was not found.", 404) + }; + } + + // 3. Guard Clause: Prevent deletion if work has started. + if (task.CompletedWork > 0) + { + double percentage = Math.Round((task.CompletedWork / task.PlannedWork) * 100, 2); + _logger.LogWarning("Task with ID {WorkItemId} is {CompletionPercentage}% complete and cannot be deleted.", task.Id, percentage); + return new ServiceResponse + { + Response = ApiResponse.ErrorResponse($"Task is {percentage}% complete and cannot be deleted.", "Deletion failed because the task has progress.", 400) + }; + } + + // 4. Guard Clause: Efficiently check if the task is assigned in a separate, optimized query. + // AnyAsync() is highly efficient and translates to a `SELECT TOP 1` or `EXISTS` in SQL. + bool isAssigned = await _context.TaskAllocations.AnyAsync(t => t.WorkItemId == id); + if (isAssigned) + { + _logger.LogWarning("Task with ID {WorkItemId} is currently assigned and cannot be deleted.", task.Id); + return new ServiceResponse + { + Response = ApiResponse.ErrorResponse("Task is currently assigned and cannot be deleted.", "Deletion failed because the task is assigned to an employee.", 400) + }; + } + + // --- Success Path: All checks passed, proceed with deletion --- + + var building = task.WorkArea?.Floor?.Building; + var notification = new + { + LoggedInUserId = loggedInEmployee.Id, + Keyword = "WorkItem", + WorkAreaIds = new[] { task.WorkAreaId }, + Message = $"Task Deleted in Building: {building?.Name ?? "N/A"}, on Floor: {task.WorkArea?.Floor?.FloorName ?? "N/A"}, in Area: {task.WorkArea?.AreaName ?? "N/A"} by {loggedInEmployee.FirstName} {loggedInEmployee.LastName}" + }; + + // 5. Perform the database deletion. + // We must attach a new instance or the original one without AsNoTracking. + // Since we used AsNoTracking, we create a 'stub' entity for deletion. + // This is more efficient than re-querying. + _context.WorkItems.Remove(new WorkItem { Id = task.Id }); + await _context.SaveChangesAsync(); + _logger.LogInfo("Task with ID {WorkItemId} has been successfully deleted.", task.Id); + + // 6. Perform cache operations concurrently. + var cacheTasks = new List + { + _cache.DeleteWorkItemByIdAsync(task.Id) + }; + + if (building?.ProjectId != null) + { + cacheTasks.Add(_cache.DeleteProjectByIdAsync(building.ProjectId)); + } + await Task.WhenAll(cacheTasks); + + // 7. Return the final success response. + return new ServiceResponse + { + Notification = notification, + Response = ApiResponse.SuccessResponse(new { id = task.Id }, "Task deleted successfully.", 200) + }; + } + #endregion + + #region =================================================================== Helper Functions =================================================================== + + public async Task> GetAllProjectByTanentID(Guid tanentId) + { + List alloc = await _context.Projects.Where(c => c.TenantId == tanentId).ToListAsync(); + return alloc; + } + + public async Task> GetProjectByEmployeeID(Guid employeeId) + { + List alloc = await _context.ProjectAllocations.Where(c => c.EmployeeId == employeeId && c.IsActive == true).Include(c => c.Project).ToListAsync(); + return alloc; + } + + public async Task> GetTeamByProject(Guid TenantId, Guid ProjectId, bool IncludeInactive) + { + if (IncludeInactive) + { + + var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId).Include(e => e.Employee).ToListAsync(); + + return employees; + } + else + { + var employees = await _context.ProjectAllocations.Where(c => c.TenantId == TenantId && c.ProjectId == ProjectId && c.IsActive == true).Include(e => e.Employee).ToListAsync(); + + return employees; + } + } + + public async Task> GetMyProjects(Guid tenantId, Employee LoggedInEmployee) + { + var projectIds = await _cache.GetProjects(LoggedInEmployee.Id); + + if (projectIds == null) + { + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProject, LoggedInEmployee.Id); + if (hasPermission) + { + var projects = await _context.Projects.Where(c => c.TenantId == tenantId).ToListAsync(); + projectIds = projects.Select(p => p.Id).ToList(); + } + else + { + var allocation = await GetProjectByEmployeeID(LoggedInEmployee.Id); + if (!allocation.Any()) + { + return new List(); + } + projectIds = allocation.Select(c => c.ProjectId).Distinct().ToList(); + } + await _cache.AddProjects(LoggedInEmployee.Id, projectIds); + } + return projectIds; + } + + public async Task> GetMyProjectIdsAsync(Guid tenantId, Employee loggedInEmployee) + { + // 1. Attempt to retrieve the list of project IDs from the cache first. + // This is the "happy path" and should be as fast as possible. + List? projectIds = await _cache.GetProjects(loggedInEmployee.Id); + + if (projectIds != null) + { + // Cache Hit: Return the cached list immediately. + return projectIds; + } + + // 2. Cache Miss: The list was not in the cache, so we must fetch it from the database. + List newProjectIds; + + // Check for the specific permission. + var hasPermission = await _permission.HasPermission(PermissionsMaster.ManageProject, loggedInEmployee.Id); + + if (hasPermission) + { + // 3a. OPTIMIZATION: User has permission to see all projects. + // Fetch *only* the Ids directly from the database. This is far more efficient + // than fetching full Project objects and then selecting the Ids in memory. + newProjectIds = await _context.Projects + .Where(p => p.TenantId == tenantId) + .Select(p => p.Id) // This translates to `SELECT Id FROM Projects...` in SQL. + .ToListAsync(); + } + else + { + // 3b. OPTIMIZATION: User can only see projects they are allocated to. + // We go directly to the source (ProjectAllocations) and ask the database + // for a distinct list of ProjectIds. This is much better than calling a + // helper function that might return full allocation objects. + newProjectIds = await _context.ProjectAllocations + .Where(a => a.EmployeeId == loggedInEmployee.Id && a.ProjectId != Guid.Empty) + .Select(a => a.ProjectId) + .Distinct() // Pushes the DISTINCT operation to the database. + .ToListAsync(); + } + + // 4. Populate the cache with the newly fetched list (even if it's empty). + // This prevents repeated database queries for employees with no projects. + await _cache.AddProjects(loggedInEmployee.Id, newProjectIds); + + return newProjectIds; + } + + + /// + /// 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.LogError(ex, "Failed to update cache for project {ProjectId} : ", projectId); + } + + // Map from the database entity to the response ViewModel. + return dbProject; + } + + private async Task UpdateCacheInBackground(Project project) + { + try + { + // This logic can be more complex, but the idea is to update or add. + var demo = await _cache.UpdateProjectDetailsOnly(project); + if (!demo) + { + await _cache.AddProjectDetails(project); + } + _logger.LogInfo("Background cache update succeeded for project {ProjectId}.", project.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Background cache update failed for project {ProjectId} ", project.Id); + } + } + + private async Task UpdateCacheAndNotify(Dictionary workDelta, List affectedItems) + { + try + { + // Update planned/completed work totals + var cacheUpdateTasks = workDelta.Select(kvp => + _cache.UpdatePlannedAndCompleteWorksInBuilding(kvp.Key, kvp.Value.Planned, kvp.Value.Completed)); + await Task.WhenAll(cacheUpdateTasks); + _logger.LogInfo("Background cache work totals update completed for {AreaCount} areas.", workDelta.Count); + + // Update the details of the individual work items in the cache + await _cache.ManageWorkItemDetails(affectedItems); + _logger.LogInfo("Background cache work item details update completed for {ItemCount} items.", affectedItems.Count); + + // Add SignalR notification logic here if needed + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred during background cache update/notification."); + } + } + + private void ProcessBuilding(BuildingDto dto, Guid tenantId, InfraVM responseData, List messages, ISet projectIds, List cacheTasks) + { + Building building = _mapper.Map(dto); + building.TenantId = tenantId; + + bool isNew = dto.Id == null; + if (isNew) + { + _context.Buildings.Add(building); + messages.Add("Building Added"); + cacheTasks.Add(_cache.AddBuildngInfra(building.ProjectId, building)); + } + else + { + _context.Buildings.Update(building); + messages.Add("Building Updated"); + cacheTasks.Add(_cache.UpdateBuildngInfra(building.ProjectId, building)); + } + + responseData.building = building; + projectIds.Add(building.ProjectId); + } + + private void ProcessFloor(FloorDto dto, Guid tenantId, InfraVM responseData, List messages, ISet projectIds, List cacheTasks, IDictionary buildings) + { + Floor floor = _mapper.Map(dto); + floor.TenantId = tenantId; + + // Use the pre-fetched dictionary for parent lookup. + Building? parentBuilding = buildings.TryGetValue(dto.BuildingId, out var b) ? b : null; + + bool isNew = dto.Id == null; + if (isNew) + { + _context.Floor.Add(floor); + messages.Add($"Floor Added in Building: {parentBuilding?.Name ?? "Unknown"}"); + cacheTasks.Add(_cache.AddBuildngInfra(parentBuilding?.ProjectId ?? Guid.Empty, floor: floor)); + } + else + { + _context.Floor.Update(floor); + messages.Add($"Floor Updated in Building: {parentBuilding?.Name ?? "Unknown"}"); + cacheTasks.Add(_cache.UpdateBuildngInfra(parentBuilding?.ProjectId ?? Guid.Empty, floor: floor)); + } + + responseData.floor = floor; + if (parentBuilding != null) projectIds.Add(parentBuilding.ProjectId); + } + + private void ProcessWorkArea(WorkAreaDto dto, Guid tenantId, InfraVM responseData, List messages, ISet projectIds, List cacheTasks, IDictionary floors) + { + WorkArea workArea = _mapper.Map(dto); + workArea.TenantId = tenantId; + + // Use the pre-fetched dictionary for parent lookup. + Floor? parentFloor = floors.TryGetValue(dto.FloorId, out var f) ? f : null; + var parentBuilding = parentFloor?.Building; + + bool isNew = dto.Id == null; + if (isNew) + { + _context.WorkAreas.Add(workArea); + messages.Add($"Work Area Added in Building: {parentBuilding?.Name ?? "Unknown"}, on Floor: {parentFloor?.FloorName ?? "Unknown"}"); + cacheTasks.Add(_cache.AddBuildngInfra(parentBuilding?.ProjectId ?? Guid.Empty, workArea: workArea, buildingId: parentBuilding?.Id)); + } + else + { + _context.WorkAreas.Update(workArea); + messages.Add($"Work Area Updated in Building: {parentBuilding?.Name ?? "Unknown"}, on Floor: {parentFloor?.FloorName ?? "Unknown"}"); + cacheTasks.Add(_cache.UpdateBuildngInfra(parentBuilding?.ProjectId ?? Guid.Empty, workArea: workArea, buildingId: parentBuilding?.Id)); + } + + responseData.workArea = workArea; + if (parentBuilding != null) projectIds.Add(parentBuilding.ProjectId); + } + + #endregion + } +} diff --git a/Marco.Pms.Services/Service/RefreshTokenService.cs b/Marco.Pms.Services/Service/RefreshTokenService.cs index 231e27c..84ef3fd 100644 --- a/Marco.Pms.Services/Service/RefreshTokenService.cs +++ b/Marco.Pms.Services/Service/RefreshTokenService.cs @@ -1,11 +1,11 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; -using Marco.Pms.DataAccess.Data; +using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; #nullable disable namespace MarcoBMS.Services.Service @@ -94,7 +94,7 @@ namespace MarcoBMS.Services.Service } catch (Exception ex) { - _logger.LogError("{Error}", ex.Message); + _logger.LogError(ex, "Error occured while creating new JWT token for user {UserId}", userId); throw; } } @@ -132,7 +132,7 @@ namespace MarcoBMS.Services.Service } catch (Exception ex) { - _logger.LogError("Error creating MPIN token for userId: {UserId}, tenantId: {TenantId}, error : {Error}", userId, tenantId, ex.Message); + _logger.LogError(ex, "Error creating MPIN token for userId: {UserId}, tenantId: {TenantId}", userId, tenantId); throw; } } @@ -218,7 +218,7 @@ namespace MarcoBMS.Services.Service catch (Exception ex) { // Token is invalid - _logger.LogError($"Token validation failed: {ex.Message}"); + _logger.LogError(ex, "Token validation failed"); return null; } } diff --git a/Marco.Pms.Services/Service/S3UploadService.cs b/Marco.Pms.Services/Service/S3UploadService.cs index c29cfdd..4ce7a4b 100644 --- a/Marco.Pms.Services/Service/S3UploadService.cs +++ b/Marco.Pms.Services/Service/S3UploadService.cs @@ -64,7 +64,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogError("{error} while uploading file to S3", ex.Message); + _logger.LogError(ex, "error occured while uploading file to S3"); } @@ -87,7 +87,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogError("{error} while requesting presigned url from Amazon S3", ex.Message); + _logger.LogError(ex, "error occured while requesting presigned url from Amazon S3", ex.Message); return string.Empty; } } @@ -107,7 +107,7 @@ namespace Marco.Pms.Services.Service } catch (Exception ex) { - _logger.LogError("{error} while deleting from Amazon S3", ex.Message); + _logger.LogError(ex, "error ocured while deleting from Amazon S3"); return false; } } @@ -202,7 +202,7 @@ namespace Marco.Pms.Services.Service } else { - _logger.LogError("Warning: Could not find MimeType, Type, or ContentType property in Definition."); + _logger.LogWarning("Warning: Could not find MimeType, Type, or ContentType property in Definition."); return "application/octet-stream"; } } @@ -211,16 +211,16 @@ namespace Marco.Pms.Services.Service return "application/octet-stream"; // Default if type cannot be determined } } - catch (FormatException) + catch (FormatException fEx) { // Handle cases where the input string is not valid Base64 - _logger.LogError("Invalid Base64 string."); + _logger.LogError(fEx, "Invalid Base64 string."); return string.Empty; } catch (Exception ex) { // Handle other potential errors during decoding or inspection - _logger.LogError($"An error occurred: {ex.Message}"); + _logger.LogError(ex, "errors during decoding or inspection"); return string.Empty; } } diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs new file mode 100644 index 0000000..b5acccc --- /dev/null +++ b/Marco.Pms.Services/Service/ServiceInterfaces/IProjectServices.cs @@ -0,0 +1,35 @@ +using Marco.Pms.Model.Dtos.Project; +using Marco.Pms.Model.Employees; +using Marco.Pms.Model.Projects; +using Marco.Pms.Model.Utilities; +using Marco.Pms.Model.ViewModels.Projects; + +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); + Task> GetEmployeeByProjectIdAsync(Guid? projectId, bool includeInactive, Guid tenantId, Employee loggedInEmployee); + Task> GetProjectAllocationAsync(Guid? projectId, Guid tenantId, Employee loggedInEmployee); + Task>> ManageAllocationAsync(List projectAllocationDots, Guid tenantId, Employee loggedInEmployee); + Task> GetProjectsByEmployeeAsync(Guid employeeId, Guid tenantId, Employee loggedInEmployee); + Task>> AssigneProjectsToEmployeeAsync(List projectAllocationDtos, Guid employeeId, Guid tenantId, Employee loggedInEmployee); + Task> GetInfraDetailsAsync(Guid projectId, Guid tenantId, Employee loggedInEmployee); + Task> GetWorkItemsAsync(Guid workAreaId, Guid tenantId, Employee loggedInEmployee); + Task ManageProjectInfraAsync(List infraDtos, Guid tenantId, Employee loggedInEmployee); + Task>> CreateProjectTaskAsync(List workItemDtos, Guid tenantId, Employee loggedInEmployee); + Task DeleteProjectTaskAsync(Guid id, Guid tenantId, Employee loggedInEmployee); + + Task> GetAllProjectByTanentID(Guid tanentId); + Task> GetProjectByEmployeeID(Guid employeeId); + Task> GetTeamByProject(Guid TenantId, Guid ProjectId, bool IncludeInactive); + Task> GetMyProjectIdsAsync(Guid tenantId, Employee LoggedInEmployee); + + } +} diff --git a/Marco.Pms.Services/Service/ServiceInterfaces/ISignalRService.cs b/Marco.Pms.Services/Service/ServiceInterfaces/ISignalRService.cs new file mode 100644 index 0000000..c37322b --- /dev/null +++ b/Marco.Pms.Services/Service/ServiceInterfaces/ISignalRService.cs @@ -0,0 +1,7 @@ +namespace Marco.Pms.Services.Service.ServiceInterfaces +{ + public interface ISignalRService + { + Task SendNotificationAsync(object notification); + } +} diff --git a/Marco.Pms.Services/Service/SignalRService.cs b/Marco.Pms.Services/Service/SignalRService.cs new file mode 100644 index 0000000..fecc9b0 --- /dev/null +++ b/Marco.Pms.Services/Service/SignalRService.cs @@ -0,0 +1,29 @@ +using Marco.Pms.Services.Hubs; +using Marco.Pms.Services.Service.ServiceInterfaces; +using MarcoBMS.Services.Service; +using Microsoft.AspNetCore.SignalR; + +namespace Marco.Pms.Services.Service +{ + public class SignalRService : ISignalRService + { + private readonly IHubContext _signalR; + private readonly ILoggingService _logger; + public SignalRService(IHubContext signalR, ILoggingService logger) + { + _signalR = signalR ?? throw new ArgumentNullException(nameof(signalR)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + public async Task SendNotificationAsync(object notification) + { + try + { + await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception occured during sending notification through signalR"); + } + } + } +}