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.Services/Controllers/ProjectController.cs b/Marco.Pms.Services/Controllers/ProjectController.cs index adb5887..acc97d2 100644 --- a/Marco.Pms.Services/Controllers/ProjectController.cs +++ b/Marco.Pms.Services/Controllers/ProjectController.cs @@ -40,8 +40,8 @@ namespace MarcoBMS.Services.Controllers private readonly Guid tenantId; - public ProjectController(IDbContextFactory dbContextFactory, ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, RolesHelper rolesHelper, - ProjectsHelper projectHelper, IHubContext signalR, PermissionServices permission, CacheUpdateHelper cache, IMapper mapper) + public ProjectController(IDbContextFactory dbContextFactory, ApplicationDbContext context, UserHelper userHelper, ILoggingService logger, + ProjectsHelper projectHelper, IHubContext signalR, CacheUpdateHelper cache, PermissionServices permission, IMapper mapper) { _dbContextFactory = dbContextFactory; _context = context; @@ -52,7 +52,7 @@ namespace MarcoBMS.Services.Controllers _cache = cache; _permission = permission; _mapper = mapper; - tenantId = _userHelper.GetTenantId(); + tenantId = userHelper.GetTenantId(); } #region =================================================================== Project Get APIs =================================================================== @@ -161,29 +161,74 @@ namespace MarcoBMS.Services.Controllers catch (Exception ex) { // --- Step 6: Graceful Error Handling --- - _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. : {Error}", tenantId, ex.Message); + _logger.LogError("An unexpected error occurred in GetAllProjects for tenant {TenantId}. \n {Error}", tenantId, ex.Message); return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred. Please try again later.", null, 500)); } } + /// + /// 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) { + // --- 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)); + try + { + var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); + + // --- Step 2: Run independent operations in PARALLEL --- + // We can check permissions and fetch data at the same time to reduce latency. + var permissionTask = _permission.HasProjectPermission(loggedInEmployee, id); + + // This helper method encapsulates the "cache-first, then database" logic. + var projectDataTask = GetProjectDataAsync(id); + + // Await both tasks to complete. + await Task.WhenAll(permissionTask, projectDataTask); + + var hasPermission = await permissionTask; + var projectVm = await projectDataTask; + + // --- Step 3: Process results sequentially --- + + // 3a. Check for permission first. Forbid() is the idiomatic way to return 403. + if (!hasPermission) + { + _logger.LogWarning("Access denied for user {UserId} on project {ProjectId}.", loggedInEmployee.Id, id); + return StatusCode(403, (ApiResponse.ErrorResponse("Access Denied.", "You do not have permission to access this project.", 403))); + } + + // 3b. Check if the project was found (either in cache or DB). + if (projectVm == null) + { + _logger.LogInfo("Project with ID {ProjectId} not found.", id); + return NotFound(ApiResponse.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404)); + } + + // 3c. Success. Return the consistent ViewModel. + _logger.LogInfo("Successfully retrieved project {ProjectId}.", id); + return Ok(ApiResponse.SuccessResponse(projectVm, "Project retrieved successfully.", 200)); + } + catch (Exception ex) + { + _logger.LogError("An unexpected error occurred while getting project {ProjectId} : \n {Error}", id, ex.Message); + return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.", null, 500)); + } } + [HttpGet("details/{id}")] public async Task Details([FromRoute] Guid id) { @@ -1331,7 +1376,6 @@ namespace MarcoBMS.Services.Controllers 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. @@ -1409,6 +1453,51 @@ namespace MarcoBMS.Services.Controllers return mongoDetailsList; } + /// + /// Private helper to encapsulate the cache-first data retrieval logic. + /// + /// A ProjectDetailVM if found, otherwise null. + private async Task GetProjectDataAsync(Guid projectId) + { + // --- Cache First --- + _logger.LogDebug("Attempting to fetch project {ProjectId} from cache.", projectId); + var cachedProject = await _cache.GetProjectDetails(projectId); + if (cachedProject != null) + { + _logger.LogInfo("Cache HIT for project {ProjectId}.", projectId); + // Map from the cache model (e.g., ProjectMongoDB) to the response ViewModel. + return _mapper.Map(cachedProject); + } + + // --- Database Second (on Cache Miss) --- + _logger.LogInfo("Cache MISS for project {ProjectId}. Fetching from database.", projectId); + var dbProject = await _context.Projects + .AsNoTracking() // Use AsNoTracking for read-only queries. + .Where(p => p.Id == projectId && p.TenantId == tenantId) + .SingleOrDefaultAsync(); + + if (dbProject == null) + { + return null; // The project doesn't exist. + } + + // --- Proactively Update Cache --- + // The next request for this project will now be a cache hit. + try + { + // Map the DB entity to the cache model (e.g., ProjectMongoDB) before caching. + await _cache.AddProjectDetails(dbProject); + _logger.LogInfo("Updated cache with project {ProjectId}.", projectId); + } + catch (Exception ex) + { + _logger.LogWarning("Failed to update cache for project {ProjectId} : \n {Error}", projectId, ex.Message); + } + + // Map from the database entity to the response ViewModel. + return dbProject; + } + #endregion } } \ No newline at end of file diff --git a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs index c7ec4af..f527f67 100644 --- a/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs +++ b/Marco.Pms.Services/MappingProfiles/ProjectMappingProfile.cs @@ -20,7 +20,18 @@ namespace Marco.Pms.Services.MappingProfiles .ForMember( dest => dest.Id, // Explicitly and safely convert string Id to Guid Id - opt => opt.MapFrom(src => src.Id == null ? Guid.Empty : new Guid(src.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();