marco.pms.api/Marco.Pms.Services/Service/ServiceProjectService.cs

2141 lines
113 KiB
C#

using AutoMapper;
using Marco.Pms.DataAccess.Data;
using Marco.Pms.Helpers.Utility;
using Marco.Pms.Model.DocumentManager;
using Marco.Pms.Model.Dtos.ServiceProject;
using Marco.Pms.Model.Employees;
using Marco.Pms.Model.Master;
using Marco.Pms.Model.MongoDBModels.Utility;
using Marco.Pms.Model.ServiceProject;
using Marco.Pms.Model.Utilities;
using Marco.Pms.Model.ViewModels.Activities;
using Marco.Pms.Model.ViewModels.DocumentManager;
using Marco.Pms.Model.ViewModels.Master;
using Marco.Pms.Model.ViewModels.Organization;
using Marco.Pms.Model.ViewModels.ServiceProject;
using Marco.Pms.Services.Service.ServiceInterfaces;
using MarcoBMS.Services.Service;
using Microsoft.EntityFrameworkCore;
using MongoDB.Bson;
namespace Marco.Pms.Services.Service
{
public class ServiceProjectService : IServiceProject
{
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ApplicationDbContext _context; // Keeping this for direct scoped context use where appropriate
private readonly ILoggingService _logger;
private readonly S3UploadService _s3Service;
private readonly IMapper _mapper;
private readonly Guid NewStatus = Guid.Parse("32d76a02-8f44-4aa0-9b66-c3716c45a918");
private readonly Guid AssignedStatus = Guid.Parse("cfa1886d-055f-4ded-84c6-42a2a8a14a66");
public ServiceProjectService(IDbContextFactory<ApplicationDbContext> dbContextFactory,
IServiceScopeFactory serviceScopeFactory,
ApplicationDbContext context,
ILoggingService logger,
S3UploadService s3Service,
IMapper mapper)
{
_serviceScopeFactory = serviceScopeFactory;
_context = context;
_logger = logger;
_s3Service = s3Service;
_mapper = mapper;
_dbContextFactory = dbContextFactory;
}
#region =================================================================== Service Project Functions ===================================================================
/// <summary>
/// Retrieves a paginated list of active service projects including their clients, status, creators, and related services for a given tenant.
/// </summary>
/// <param name="pageNumber">Page number (1-based) for pagination.</param>
/// <param name="pageSize">Number of records per page.</param>
/// <param name="loggedInEmployee">Employee making the request (for logging).</param>
/// <param name="tenantId">Tenant identifier for multi-tenant isolation.</param>
/// <returns>ApiResponse containing paged service projects with related data or error information.</returns>
public async Task<ApiResponse<object>> GetServiceProjectListAsync(int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("GetServiceProjectListAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
if (pageNumber < 1 || pageSize < 1)
{
_logger.LogInfo("Invalid pagination parameters: PageNumber={PageNumber}, PageSize={PageSize}", pageNumber, pageSize);
return ApiResponse<object>.ErrorResponse("Bad Request", "Page number and size must be greater than zero.", 400);
}
try
{
// Base query for active projects scoped by tenant including necessary related entities
var serviceProjectQuery = _context.ServiceProjects
.Include(sp => sp.Client)
.Include(sp => sp.Status)
.Include(sp => sp.CreatedBy).ThenInclude(e => e!.JobRole)
.Where(sp => sp.TenantId == tenantId && sp.IsActive);
var totalEntities = await serviceProjectQuery.CountAsync();
var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize);
// Fetch paged projects ordered by creation date descending
var serviceProjects = await serviceProjectQuery
.OrderByDescending(sp => sp.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var serviceProjectIds = serviceProjects.Select(sp => sp.Id).ToList();
// Load related service mappings with services for current page projects (avoid N+1)
var serviceProjectServiceMappings = await _context.ServiceProjectServiceMapping
.Include(sps => sps.Service)
.Where(sps => serviceProjectIds.Contains(sps.ProjectId) &&
sps.Service != null &&
sps.TenantId == tenantId)
.ToListAsync();
// Map each project with its related services into the view models
var serviceProjectVMs = serviceProjects.Select(sp =>
{
var relatedServices = serviceProjectServiceMappings
.Where(sps => sps.ProjectId == sp.Id)
.Select(sps => sps.Service!)
.ToList();
var projectVm = _mapper.Map<ServiceProjectVM>(sp);
projectVm.Services = _mapper.Map<List<ServiceMasterVM>>(relatedServices);
return projectVm;
}).ToList();
var response = new
{
CurrentPage = pageNumber,
TotalPages = totalPages,
TotalEntities = totalEntities,
Data = serviceProjectVMs,
};
_logger.LogInfo("Retrieved {Count} service projects for tenant {TenantId} by employee {EmployeeId}. Page {PageNumber}/{TotalPages}",
serviceProjectVMs.Count, tenantId, loggedInEmployee.Id, pageNumber, totalPages);
return ApiResponse<object>.SuccessResponse(response, "Projects retrieved successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unexpected error occurred in GetServiceProjectListAsync for tenant {TenantId} by employee {EmployeeId}",
tenantId, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("An internal server error occurred. Please try again later.", null, 500);
}
}
/// <summary>
/// Retrieves detailed information for a specific service project, including related client, status, services, and audit information.
/// </summary>
/// <param name="id">The unique identifier of the service project.</param>
/// <param name="loggedInEmployee">The employee requesting the details (for audit logging).</param>
/// <param name="tenantId">Tenant identifier ensuring multi-tenant data isolation.</param>
/// <returns>ApiResponse containing detailed service project information or error details.</returns>
public async Task<ApiResponse<object>> GetServiceProjectDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("GetServiceProjectDetailsAsync called with missing tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
try
{
// Load service project with related client, status, and creator/updater roles
var serviceProject = await _context.ServiceProjects
.Include(sp => sp.Client)
.Include(sp => sp.Status)
.Include(sp => sp.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(sp => sp.UpdatedBy).ThenInclude(e => e!.JobRole)
.AsNoTracking()
.FirstOrDefaultAsync(sp => sp.Id == id && sp.TenantId == tenantId);
if (serviceProject == null)
{
_logger.LogWarning("Service project {ServiceProjectId} not found for tenant {TenantId}", id, tenantId);
return ApiResponse<object>.ErrorResponse("Service Project Not Found", "Service project not found for the specified tenant.", 404);
}
// Retrieve related services for the project
var services = await _context.ServiceProjectServiceMapping
.Include(sps => sps.Service)
.Where(sps => sps.ProjectId == serviceProject.Id &&
sps.Service != null &&
sps.TenantId == tenantId)
.Select(sps => sps.Service!)
.ToListAsync();
// Optional: Count number of job tickets associated with the project (commented out)
// var numberOfJobs = await _context.JobTickets.CountAsync(jt => jt.ProjectId == serviceProject.Id && jt.TenantId == tenantId);
var response = _mapper.Map<ServiceProjectDetailsVM>(serviceProject);
response.Services = _mapper.Map<List<ServiceMasterVM>>(services);
response.NumberOfJobs = await _context.JobTickets.CountAsync(jt => jt.ProjectId == id && jt.IsActive && jt.TenantId == tenantId);
_logger.LogInfo("Fetched details for service project {ServiceProjectId} for tenant {TenantId} requested by employee {EmployeeId}",
id, tenantId, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(response, "Service Project details fetched successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch service project details for project {ServiceProjectId} by employee {EmployeeId} in tenant {TenantId}",
id, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "Unable to fetch service project details. Please try again later.", 500);
}
}
/// <summary>
/// Creates a new service project along with its associated active services, and returns the created project details.
/// </summary>
/// <param name="model">DTO containing service project information and list of active services.</param>
/// <param name="loggedInEmployee">Employee creating the service project (for auditing).</param>
/// <param name="tenantId">Tenant context for multi-tenancy.</param>
/// <returns>ApiResponse containing created service project details or error info.</returns>
public async Task<ApiResponse<object>> CreateServiceProjectAsync(ServiceProjectDto model, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("CreateServiceProjectAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
if (model == null)
{
_logger.LogInfo("CreateServiceProjectAsync called with null model by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Bad Request", "Input model cannot be null.", 400);
}
try
{
// Extract active service IDs for validation and association
var serviceIds = model.Services?.Where(s => s.IsActive).Select(s => s.ServiceId).ToList() ?? new List<Guid>();
// Concurrently load dependent entities: client, services, status
var clientTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.Organizations.FirstOrDefaultAsync(o => o.Id == model.ClientId && o.IsActive);
});
var serviceTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ServiceMasters.Where(s => serviceIds.Contains(s.Id) && s.TenantId == tenantId && s.IsActive).ToListAsync();
});
var statusTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.StatusMasters.FirstOrDefaultAsync(s => s.Id == model.StatusId);
});
await Task.WhenAll(clientTask, serviceTask, statusTask);
var client = clientTask.Result;
var services = serviceTask.Result;
var status = statusTask.Result;
// Validate existence of critical foreign entities
if (client == null)
{
_logger.LogWarning("Client with ID {ClientId} not found or inactive in tenant {TenantId}", model.ClientId, tenantId);
return ApiResponse<object>.ErrorResponse("Client Not Found", "Client not found or inactive.", 404);
}
if (status == null)
{
_logger.LogWarning("Status with ID {StatusId} not found in tenant {TenantId}", model.StatusId, tenantId);
return ApiResponse<object>.ErrorResponse("Project Status Not Found", "Project status not found.", 404);
}
// Map DTO to entity and enhance with auditing and tenant info
var serviceProject = _mapper.Map<ServiceProject>(model);
serviceProject.Id = Guid.NewGuid();
serviceProject.CreatedById = loggedInEmployee.Id;
serviceProject.CreatedAt = DateTime.UtcNow;
serviceProject.IsActive = true;
serviceProject.TenantId = tenantId;
// Create mappings only for active services validated by database
var projectServiceMappings = model.Services?
.Where(sdto => services.Any(s => s.Id == sdto.ServiceId) && sdto.IsActive)
.Select(sdto => new ServiceProjectServiceMapping
{
ServiceId = sdto.ServiceId,
ProjectId = serviceProject.Id,
TenantId = tenantId
}).ToList() ?? new List<ServiceProjectServiceMapping>();
// Add new project and its service mappings to context
_context.ServiceProjects.Add(serviceProject);
_context.ServiceProjectServiceMapping.AddRange(projectServiceMappings);
// Persist data
await _context.SaveChangesAsync();
_logger.LogInfo("Service Project {ProjectId} created successfully for Tenant {TenantId} by Employee {EmployeeId}", serviceProject.Id, tenantId, loggedInEmployee.Id);
// Prepare view model to return, mapping related created entities
var serviceProjectVM = _mapper.Map<ServiceProjectVM>(serviceProject);
serviceProjectVM.Client = _mapper.Map<BasicOrganizationVm>(client);
serviceProjectVM.Status = status;
serviceProjectVM.Services = services.Select(s => _mapper.Map<ServiceMasterVM>(s)).ToList();
serviceProjectVM.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
return ApiResponse<object>.SuccessResponse(serviceProjectVM, "Service project created successfully.", 201);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create service project for tenant {TenantId} by employee {EmployeeId}", tenantId, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "An error occurred while creating the service project. Please try again later.", 500);
}
}
/// <summary>
/// Updates an existing active service project including its related services, and returns updated project details.
/// </summary>
/// <param name="id">The ID of the service project to update.</param>
/// <param name="model">DTO containing updated service project data and service list.</param>
/// <param name="loggedInEmployee">Employee performing the update (for audit/logging).</param>
/// <param name="tenantId">Tenant identifier for multi-tenant isolation.</param>
/// <returns>ApiResponse containing updated service project details or error info.</returns>
public async Task<ApiResponse<object>> UpdateServiceProjectAsync(Guid id, ServiceProjectDto model, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("UpdateServiceProjectAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
if (model == null)
{
_logger.LogInfo("UpdateServiceProjectAsync called with null model by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Bad Request", "Input model cannot be null.", 400);
}
try
{
// Extract all service IDs from input for validation
var serviceIds = model.Services?.Select(s => s.ServiceId).ToList() ?? new List<Guid>();
// Concurrently fetch related entities for validation
var clientTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.Organizations.FirstOrDefaultAsync(o => o.Id == model.ClientId && o.IsActive);
});
var serviceTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ServiceMasters.Where(s => serviceIds.Contains(s.Id) && s.TenantId == tenantId && s.IsActive).ToListAsync();
});
var statusTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.StatusMasters.FirstOrDefaultAsync(s => s.Id == model.StatusId);
});
await Task.WhenAll(clientTask, serviceTask, statusTask);
var client = clientTask.Result;
var services = serviceTask.Result;
var status = statusTask.Result;
// Validate client and status existence
if (client == null)
{
_logger.LogWarning("Client with ID {ClientId} not found or inactive in tenant {TenantId}", model.ClientId, tenantId);
return ApiResponse<object>.ErrorResponse("Client Not Found", "Client not found or inactive.", 404);
}
if (status == null)
{
_logger.LogWarning("Project status with ID {StatusId} not found in tenant {TenantId}", model.StatusId, tenantId);
return ApiResponse<object>.ErrorResponse("Project Status Not Found", "Project status not found.", 404);
}
// Fetch existing service project for update, ensuring active and tenant scope
var serviceProject = await _context.ServiceProjects
.Where(sp => sp.Id == id && sp.TenantId == tenantId && sp.IsActive)
.FirstOrDefaultAsync();
if (serviceProject == null)
{
_logger.LogWarning("Attempt to update non-existent service project with ID {ProjectId} by employee {EmployeeId}", id, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Project Not Found", $"No active project found with ID {id}.", 404);
}
// Create BSON snapshot of existing entity for audit logging (MongoDB)
using var scope = _serviceScopeFactory.CreateScope();
var updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
BsonDocument existingEntityBson = updateLogHelper.EntityToBsonDocument(serviceProject);
// Map incoming DTO to the tracked entity
_mapper.Map(model, serviceProject);
serviceProject.UpdatedAt = DateTime.UtcNow;
serviceProject.UpdatedById = loggedInEmployee.Id;
// Get existing service mappings for the project (no tracking as we perform add/remove explicitly)
var existingMappings = await _context.ServiceProjectServiceMapping
.AsNoTracking()
.Where(sps => sps.ProjectId == serviceProject.Id && sps.TenantId == tenantId)
.ToListAsync();
// Determine service mappings to add or remove based on input DTO state
var newMappings = new List<ServiceProjectServiceMapping>();
var removedMappings = new List<ServiceProjectServiceMapping>();
if (model.Services != null)
{
foreach (var dto in model.Services)
{
var existingMapping = existingMappings.FirstOrDefault(sps => sps.ServiceId == dto.ServiceId);
if (dto.IsActive && existingMapping == null)
{
newMappings.Add(new ServiceProjectServiceMapping
{
Id = Guid.NewGuid(),
ServiceId = dto.ServiceId,
ProjectId = serviceProject.Id,
TenantId = tenantId,
});
}
else if (!dto.IsActive && existingMapping != null)
{
removedMappings.Add(existingMapping);
}
}
}
// Apply added and removed mappings
if (newMappings.Any()) _context.ServiceProjectServiceMapping.AddRange(newMappings);
if (removedMappings.Any()) _context.ServiceProjectServiceMapping.RemoveRange(removedMappings);
// Persist all changes within a single transaction flow
await _context.SaveChangesAsync();
// Reload updated project and related entities for comprehensive response
var serviceProjectTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ServiceProjects
.Include(sp => sp.Client)
.Include(sp => sp.Status)
.Include(sp => sp.CreatedBy).ThenInclude(e => e!.JobRole)
.Where(sp => sp.Id == id && sp.TenantId == tenantId && sp.IsActive)
.FirstOrDefaultAsync();
});
var servicesTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ServiceProjectServiceMapping
.Include(sps => sps.Service)
.Where(sps => sps.ProjectId == serviceProject.Id && sps.Service != null && sps.TenantId == tenantId)
.Select(sps => sps.Service!)
.ToListAsync();
});
// Push update log asynchronously to MongoDB for audit
var updateLogTask = updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
OldObject = existingEntityBson,
UpdatedAt = DateTime.UtcNow
}, "JobTicketModificationLog");
await Task.WhenAll(serviceProjectTask, servicesTask, updateLogTask);
serviceProject = serviceProjectTask.Result;
services = servicesTask.Result;
var projectVm = _mapper.Map<ServiceProjectVM>(serviceProject);
projectVm.Services = _mapper.Map<List<ServiceMasterVM>>(services);
_logger.LogInfo("Service project {ProjectId} updated successfully by employee {EmployeeId}", id, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(projectVm, "Service project updated successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error updating service project {ProjectId} for tenant {TenantId} by employee {EmployeeId}", id, tenantId, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "An error occurred while updating the service project. Please try again later.", 500);
}
}
/// <summary>
/// Activates or deactivates a service project based on the specified 'isActive' flag, scoped by tenant.
/// </summary>
/// <param name="id">The unique identifier of the service project to update.</param>
/// <param name="isActive">True to activate, false to deactivate the service project.</param>
/// <param name="loggedInEmployee">The employee performing the operation (for audit/logging).</param>
/// <param name="tenantId">Tenant identifier to ensure multi-tenant data isolation.</param>
/// <returns>ApiResponse indicating success or detail of failure.</returns>
public async Task<ApiResponse<object>> DeActivateServiceProjectAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("DeActivateServiceProjectAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
try
{
// Load the service project scoped by tenant and ID
var serviceProject = await _context.ServiceProjects
.FirstOrDefaultAsync(sp => sp.Id == id && sp.TenantId == tenantId);
if (serviceProject == null)
{
_logger.LogWarning("Attempt to {(Action)} non-existent service project {ProjectId} by employee {EmployeeId} in tenant {TenantId}",
isActive ? "activate" : "deactivate", id, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Project Not Found", $"No project found with ID {id}.", 404);
}
// Create BSON snapshot of existing entity for audit logging (MongoDB)
using var scope = _serviceScopeFactory.CreateScope();
var updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
BsonDocument existingEntityBson = updateLogHelper.EntityToBsonDocument(serviceProject);
// Update active status as requested by the client
serviceProject.IsActive = isActive;
await _context.SaveChangesAsync();
// Push update log asynchronously to MongoDB for audit
await updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
OldObject = existingEntityBson,
UpdatedAt = DateTime.UtcNow
}, "JobTicketModificationLog");
_logger.LogInfo("Service project {ProjectId} {(Action)}d successfully by employee {EmployeeId} in tenant {TenantId}",
id, isActive ? "activate" : "deactivate", loggedInEmployee.Id, tenantId);
return ApiResponse<object>.SuccessResponse(new { }, $"Project {(isActive ? "activated" : "deactivated")} successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred while {(Action)} service project {ProjectId} by employee {EmployeeId} in tenant {TenantId}",
isActive ? "activating" : "deactivating", id, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "An internal error occurred, please try again later.", 500);
}
}
#endregion
#region =================================================================== Service Project Allocation Functions ===================================================================
/// <summary>
/// Retrieves a list of service project allocations filtered by project ID or employee ID and active status within the tenant context.
/// </summary>
/// <param name="projectId">Optional project ID filter.</param>
/// <param name="employeeId">Optional employee ID filter.</param>
/// <param name="isActive">Filter for active/inactive allocations.</param>
/// <param name="loggedInEmployee">Employee making the request (for audit/logging).</param>
/// <param name="tenantId">Tenant identifier for multi-tenant data isolation.</param>
/// <returns>ApiResponse with the list of matching service project allocations or error details.</returns>
public async Task<ApiResponse<object>> GetServiceProjectAllocationListAsync(Guid? projectId, Guid? employeeId, bool isActive, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("GetServiceProjectAllocationListAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
if (!projectId.HasValue && !employeeId.HasValue)
{
_logger.LogInfo("GetServiceProjectAllocationListAsync missing required filters by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Bad Request", "Provide at least one of (ProjectId or EmployeeId).", 400);
}
try
{
_logger.LogInfo("Fetching service project allocations for tenant {TenantId} by employee {EmployeeId} " +
"with filters - ProjectId: {ProjectId}, EmployeeId: {EmployeeId}, IsActive: {IsActive}",
tenantId, loggedInEmployee.Id, projectId ?? Guid.Empty, employeeId ?? Guid.Empty, isActive);
// Base query with includes for related navigation properties
var allocationQuery = _context.ServiceProjectAllocations
.Include(spa => spa.Project)
.Include(spa => spa.TeamRole)
.Include(spa => spa.Employee).ThenInclude(e => e!.JobRole)
.Include(spa => spa.AssignedBy).ThenInclude(e => e!.JobRole)
.Include(spa => spa.ReAssignedBy).ThenInclude(e => e!.JobRole)
.Where(spa => spa.IsActive == isActive && spa.TenantId == tenantId);
// Apply filtering by either project or employee (mutually exclusive)
if (projectId.HasValue)
{
allocationQuery = allocationQuery.Where(spa => spa.ProjectId == projectId.Value);
}
else if (employeeId.HasValue)
{
allocationQuery = allocationQuery.Where(spa => spa.EmployeeId == employeeId.Value);
}
// Execute query and sort results by most recent assignment
var allocations = await allocationQuery
.OrderByDescending(spa => spa.AssignedAt)
.ToListAsync();
var response = _mapper.Map<List<ServiceProjectAllocationVM>>(allocations);
_logger.LogInfo("{Count} service project allocations fetched successfully for tenant {TenantId}", response.Count, tenantId);
return ApiResponse<object>.SuccessResponse(response, "Service project allocations fetched successfully", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching service project allocations for tenant {TenantId} by employee {EmployeeId}", tenantId, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "Failed to fetch service project allocations. Please try again later.", 500);
}
}
/// <summary>
/// Manages service project allocations by adding new active allocations and removing inactive ones.
/// Validates projects, employees, and team roles exist before applying changes.
/// </summary>
/// <param name="model">List of allocation DTOs specifying project, employee, team role, and active status.</param>
/// <param name="loggedInEmployee">Employee performing the allocation management (for audit).</param>
/// <param name="tenantId">Tenant identifier for multi-tenant data isolation.</param>
/// <returns>ApiResponse containing the updated list of active allocations or error details.</returns>
public async Task<ApiResponse<object>> ManageServiceProjectAllocationAsync(List<ServiceProjectAllocationDto> model, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("ManageServiceProjectAllocationAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
if (model == null || !model.Any())
{
_logger.LogInfo("Empty allocation model provided by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Bad Request", "No allocation data provided.", 400);
}
try
{
// Extract distinct IDs from input for efficient bulk queries
var projectIds = model.Select(spa => spa.ProjectId).Distinct().ToList();
var employeeIds = model.Select(spa => spa.EmployeeId).Distinct().ToList();
var teamRoleIds = model.Select(spa => spa.TeamRoleId).Distinct().ToList();
// Load required reference data concurrently using separate DbContexts
var projectTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ServiceProjects.AsNoTracking().Where(sp => projectIds.Contains(sp.Id) && sp.TenantId == tenantId && sp.IsActive).ToListAsync();
});
var employeeTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.Employees.AsNoTracking().Where(e => employeeIds.Contains(e.Id) && e.IsActive).ToListAsync();
});
var teamRoleTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.TeamRoleMasters.AsNoTracking().Where(trm => teamRoleIds.Contains(trm.Id)).ToListAsync();
});
var allocationTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ServiceProjectAllocations.AsNoTracking().Where(spa => projectIds.Contains(spa.ProjectId) && spa.TenantId == tenantId && spa.IsActive).ToListAsync();
});
await Task.WhenAll(projectTask, employeeTask, teamRoleTask, allocationTask);
var projects = projectTask.Result;
var employees = employeeTask.Result;
var teamRoles = teamRoleTask.Result;
var allocations = allocationTask.Result;
var newAllocations = new List<ServiceProjectAllocation>();
var allocationsToRemove = new List<ServiceProjectAllocation>();
// Process each input allocation DTO
foreach (var dto in model)
{
// Validate referenced entities exist
var project = projects.FirstOrDefault(sp => sp.Id == dto.ProjectId);
var employee = employees.FirstOrDefault(e => e.Id == dto.EmployeeId);
var teamRole = teamRoles.FirstOrDefault(tr => tr.Id == dto.TeamRoleId);
if (project == null || employee == null || teamRole == null)
{
_logger.LogWarning("Skipping allocation with invalid references: ProjectId={ProjectId}, EmployeeId={EmployeeId}, TeamRoleId={TeamRoleId}", dto.ProjectId, dto.EmployeeId, dto.TeamRoleId);
continue;
}
var existingAllocation = allocations.FirstOrDefault(spa =>
spa.ProjectId == dto.ProjectId &&
spa.EmployeeId == dto.EmployeeId &&
spa.TeamRoleId == dto.TeamRoleId);
if (dto.IsActive && existingAllocation == null)
{
newAllocations.Add(new ServiceProjectAllocation
{
Id = Guid.NewGuid(),
ProjectId = dto.ProjectId,
EmployeeId = dto.EmployeeId,
TeamRoleId = dto.TeamRoleId,
AssignedAt = DateTime.UtcNow,
AssignedById = loggedInEmployee.Id,
IsActive = true,
TenantId = tenantId
});
}
else if (!dto.IsActive && existingAllocation != null)
{
existingAllocation.IsActive = false;
existingAllocation.ReAssignedAt = DateTime.UtcNow;
existingAllocation.ReAssignedById = loggedInEmployee.Id;
allocationsToRemove.Add(existingAllocation);
}
}
// Batch changes for efficiency
if (newAllocations.Any()) _context.ServiceProjectAllocations.AddRange(newAllocations);
if (allocationsToRemove.Any()) _context.ServiceProjectAllocations.UpdateRange(allocationsToRemove);
await _context.SaveChangesAsync();
// Reload current active allocations for response with relevant navigation properties
var updatedAllocations = await _context.ServiceProjectAllocations
.Include(spa => spa.Project)
.Include(spa => spa.TeamRole)
.Include(spa => spa.Employee).ThenInclude(e => e!.JobRole)
.Include(spa => spa.AssignedBy).ThenInclude(e => e!.JobRole)
.Where(spa =>
projectIds.Contains(spa.ProjectId) &&
employeeIds.Contains(spa.EmployeeId) &&
teamRoleIds.Contains(spa.TeamRoleId) &&
spa.IsActive &&
spa.TenantId == tenantId)
.ToListAsync();
var response = _mapper.Map<List<ServiceProjectAllocationVM>>(updatedAllocations);
_logger.LogInfo("Service project allocations managed successfully by employee {EmployeeId} for tenant {TenantId}. Added {AddedCount}, Removed {RemovedCount}",
loggedInEmployee.Id, tenantId, newAllocations.Count, allocationsToRemove.Count);
return ApiResponse<object>.SuccessResponse(response, "Service project allocations managed successfully", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error managing service project allocations by employee {EmployeeId} in tenant {TenantId}", loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "An error occurred while managing service project allocations. Please try again later.", 500);
}
}
#endregion
#region =================================================================== Job Tickets Functions ===================================================================
/// <summary>
/// Retrieves a paginated, filtered list of job tickets for a tenant, including related project, status, assignees, and tags.
/// </summary>
/// <param name="projectId">Optional project filter.</param>
/// <param name="pageNumber">Page index (1-based).</param>
/// <param name="pageSize">Page size.</param>
/// <param name="isActive">Active filter.</param>
/// <param name="tenantId">Tenant context.</param>
/// <param name="loggedInEmployee">Employee requesting data.</param>
/// <returns>Paged list of JobTicketVM plus metadata, or error response.</returns>
public async Task<ApiResponse<object>> GetJobTicketsListAsync(Guid? projectId, int pageNumber, int pageSize, bool isActive, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("TenantId missing for job ticket fetch by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse(
"Access Denied",
"Missing or invalid tenant context.",
403);
}
if (pageNumber < 1 || pageSize < 1)
{
_logger.LogInfo("Invalid paging parameters for job ticket fetch. PageNumber: {PageNumber}, PageSize: {PageSize}", pageNumber, pageSize);
return ApiResponse<object>.ErrorResponse(
"Bad Request",
"Page number and size must be greater than zero.",
400);
}
try
{
// Build filtered query with necessary includes for eager loading
var jobTicketQuery = _context.JobTickets
.Include(jt => jt.Status)
.Include(jt => jt.Project)
.Include(jt => jt.CreatedBy).ThenInclude(e => e!.JobRole)
.AsNoTracking()
.Where(jt =>
jt.TenantId == tenantId &&
jt.IsActive == isActive &&
jt.Project != null &&
jt.Status != null &&
jt.CreatedBy != null &&
jt.CreatedBy.JobRole != null);
// Optionally filter by project
if (projectId.HasValue)
{
var projectExists = await _context.ServiceProjects
.AnyAsync(sp => sp.Id == projectId && sp.TenantId == tenantId && sp.IsActive);
if (!projectExists)
{
_logger.LogWarning("Requested service project not found. ProjectId: {ProjectId}, TenantId: {TenantId}", projectId, tenantId);
return ApiResponse<object>.ErrorResponse("Service project not found", "Service project not found for this tenant.", 404);
}
jobTicketQuery = jobTicketQuery.Where(jt => jt.ProjectId == projectId.Value);
}
// Total results and paging
var totalEntities = await jobTicketQuery.CountAsync();
var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize);
// Fetch filtered/paged tickets
var jobTickets = await jobTicketQuery
.OrderByDescending(e => e.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var jobTicketIds = jobTickets.Select(jt => jt.Id).ToList();
// Fetch assignee and tag mappings concurrently using separate DbContexts for parallel IO
var assigneeTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobEmployeeMappings
.Include(jem => jem.Assignee).ThenInclude(e => e!.JobRole)
.Where(jem => jobTicketIds.Contains(jem.JobTicketId) && jem.Assignee != null && jem.Assignee.JobRole != null && jem.TenantId == tenantId)
.ToListAsync();
});
var tagTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobTagMappings
.Include(jtm => jtm.JobTag)
.Where(jtm => jobTicketIds.Contains(jtm.JobTicketId) && jtm.JobTag != null && jtm.TenantId == tenantId)
.ToListAsync();
});
await Task.WhenAll(assigneeTask, tagTask);
var assigneeMappings = assigneeTask.Result;
var tagMappings = tagTask.Result;
// Map tickets to view models and inject assignees/tags per ticket
var jobTicketVMs = jobTickets.Select(jt =>
{
var vm = _mapper.Map<JobTicketVM>(jt);
vm.Assignees = assigneeMappings
.Where(jem => jem.JobTicketId == jt.Id)
.Select(jem => _mapper.Map<BasicEmployeeVM>(jem.Assignee))
.ToList();
vm.Tags = tagMappings
.Where(jtm => jtm.JobTicketId == jt.Id)
.Select(jtm => _mapper.Map<TagVM>(jtm.JobTag))
.ToList();
return vm;
}).ToList();
var response = new
{
CurrentPage = pageNumber,
TotalPages = totalPages,
TotalEntities = totalEntities,
Data = jobTicketVMs,
};
_logger.LogInfo("Job tickets fetched: {Count} tickets for tenant {TenantId}, page {PageNumber}", jobTicketVMs.Count, tenantId, pageNumber);
return ApiResponse<object>.SuccessResponse(response, $"{jobTicketVMs.Count} job records fetched successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception in fetching job tickets list for tenant {TenantId} by employee {EmployeeId}", tenantId, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "Unable to fetch job tickets list. Please try again later.", 500);
}
}
/// <summary>
/// Retrieves detailed information for a specific job ticket, including assignees, tags, comments, and update logs.
/// </summary>
/// <param name="id">Job ticket identifier.</param>
/// <param name="loggedInEmployee">Employee making the request (for logging/audit).</param>
/// <param name="tenantId">Tenant identifier for multi-tenant scoping.</param>
/// <returns>ApiResponse containing detailed job ticket view or error.</returns>
public async Task<ApiResponse<object>> GetJobTicketDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("TenantId is empty for job ticket details request by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
try
{
_logger.LogInfo("Fetching details for job ticket {JobTicketId} by employee {EmployeeId} in tenant {TenantId}",
id, loggedInEmployee.Id, tenantId);
// Load main job ticket with navigation properties
var jobTicket = await _context.JobTickets
.Include(jt => jt.Status)
.Include(jt => jt.Project)
.Include(jt => jt.CreatedBy).ThenInclude(e => e!.JobRole)
.AsNoTracking()
.FirstOrDefaultAsync(jt =>
jt.Id == id &&
jt.TenantId == tenantId &&
jt.IsActive &&
jt.Project != null &&
jt.Status != null &&
jt.CreatedBy != null &&
jt.CreatedBy.JobRole != null);
if (jobTicket == null)
{
_logger.LogWarning("Job ticket not found or inactive. JobTicketId: {JobTicketId}, TenantId: {TenantId}", id, tenantId);
return ApiResponse<object>.ErrorResponse("Job not found", "Job ticket not found or inactive.", 404);
}
// Load all job statuses for status mappings in logs
var statusList = await _context.JobStatus.ToListAsync();
// Use parallel queries with separate DbContexts to optimize IO
var assigneeTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobEmployeeMappings
.Include(jem => jem.Assignee).ThenInclude(e => e!.JobRole)
.Where(jem => jem.JobTicketId == id && jem.Assignee != null && jem.Assignee.JobRole != null && jem.TenantId == tenantId)
.Select(jem => jem.Assignee!)
.ToListAsync();
});
var tagTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobTagMappings
.Include(jtm => jtm.JobTag)
.Where(jtm => jtm.JobTicketId == id && jtm.JobTag != null && jtm.TenantId == tenantId)
.Select(jtm => jtm.JobTag!)
.ToListAsync();
});
var updateLogTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.StatusUpdateLogs
.Include(ul => ul.UpdatedBy).ThenInclude(e => e!.JobRole)
.Where(ul => ul.EntityId == id && ul.TenantId == tenantId)
.ToListAsync();
});
await Task.WhenAll(assigneeTask, tagTask, updateLogTask);
// Map update logs with status descriptions
var jobUpdateLogVMs = updateLogTask.Result.Select(ul =>
{
var status = statusList.FirstOrDefault(js => js.Id == ul.StatusId);
var nextStatus = statusList.FirstOrDefault(js => js.Id == ul.NextStatusId);
return new JobUpdateLogVM
{
Id = ul.Id,
Status = status,
NextStatus = nextStatus,
Comment = ul.Comment,
UpdatedBy = _mapper.Map<BasicEmployeeVM>(ul.UpdatedBy)
};
}).ToList();
// Map assignees, and tags to their respective viewmodels
var assigneeVMs = _mapper.Map<List<BasicEmployeeVM>>(assigneeTask.Result);
var tagVMs = _mapper.Map<List<TagVM>>(tagTask.Result);
// Map main job ticket DTO and attach related data
var response = _mapper.Map<JobTicketDetailsVM>(jobTicket);
response.Assignees = assigneeVMs;
response.Tags = tagVMs;
response.UpdateLogs = jobUpdateLogVMs;
_logger.LogInfo("Job ticket details assembled successfully for JobTicketId: {JobTicketId}", id);
return ApiResponse<object>.SuccessResponse(response, "Job details fetched successfully", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch job ticket details for JobTicketId: {JobTicketId} in tenant {TenantId}", id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "Failed to retrieve job details. Please try again later.", 500);
}
}
/// <summary>
/// Retrieves all job tags associated with the tenant, ordered alphabetically by name.
/// </summary>
/// <param name="loggedInEmployee">Employee making the request (for audit/logging).</param>
/// <param name="tenantId">Tenant identifier for multi-tenant data scoping.</param>
/// <returns>ApiResponse containing the list of job tag view models or error details.</returns>
public async Task<ApiResponse<object>> GetJobTagListAsync(Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("Attempt to fetch job tags with missing tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
try
{
_logger.LogInfo("Fetching job tags for tenant {TenantId} requested by employee {EmployeeId}", tenantId, loggedInEmployee.Id);
// Query job tags via JobTagMappings ensuring tenant isolation
var tags = await _context.JobTagMappings
.AsNoTracking()
.Include(jtm => jtm.JobTag)
.Where(jtm => jtm.JobTag != null && jtm.TenantId == tenantId)
.Select(jtm => jtm.JobTag!)
.Distinct() // Avoid duplicates if any tag maps multiple times
.OrderBy(jt => jt.Name)
.ToListAsync();
var response = _mapper.Map<List<TagVM>>(tags);
_logger.LogInfo("{Count} job tags fetched successfully for tenant {TenantId}", response.Count, tenantId);
return ApiResponse<object>.SuccessResponse(response, $"{response.Count} job tag record(s) fetched successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch job tags for tenant {TenantId} requested by employee {EmployeeId}", tenantId, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "Failed to retrieve job tags. Please try again later.", 500);
}
}
/// <summary>
/// Creates a new job ticket with optional assignees and tags within a transactional scope.
/// </summary>
/// <param name="model">Data transfer object containing job ticket details.</param>
/// <param name="loggedInEmployee">Employee initiating the creation.</param>
/// <param name="tenantId">Tenant identifier for multi-tenant context.</param>
/// <returns>ApiResponse containing the created job ticket view or error details.</returns>
public async Task<ApiResponse<object>> CreateJobTicketAsync(CreateJobTicketDto model, Employee loggedInEmployee, Guid tenantId)
{
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
_logger.LogInfo("Starting job ticket creation for project {ProjectId} by employee {EmployeeId} in tenant {TenantId}",
model.ProjectId, loggedInEmployee.Id, tenantId);
// Load project and relevant statuses in parallel with separate DbContext instances for concurrency efficiency
var serviceProjectTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ServiceProjects.FirstOrDefaultAsync(sp => sp.Id == model.ProjectId && sp.TenantId == tenantId && sp.IsActive);
});
var statusTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobStatus.Where(js => js.Id == NewStatus || js.Id == AssignedStatus).ToListAsync();
});
await Task.WhenAll(serviceProjectTask, statusTask);
var serviceProject = serviceProjectTask.Result;
var statusList = statusTask.Result;
if (serviceProject == null)
{
_logger.LogWarning("Service project with ID {ProjectId} not found or inactive in tenant {TenantId}", model.ProjectId, tenantId);
return ApiResponse<object>.ErrorResponse("Service project not found", "Service project not found or inactive", 404);
}
var hasAssignees = model.Assignees?.Any(a => a.IsActive) ?? false;
// Map DTO to entity
var jobTicket = _mapper.Map<JobTicket>(model);
jobTicket.Id = Guid.NewGuid();
jobTicket.StatusId = hasAssignees ? AssignedStatus : NewStatus;
jobTicket.CreatedAt = DateTime.UtcNow;
jobTicket.CreatedById = loggedInEmployee.Id;
jobTicket.TenantId = tenantId;
_context.JobTickets.Add(jobTicket);
await _context.SaveChangesAsync();
// Handle assignees if any
List<BasicEmployeeVM> assigneeVMs = new();
if (hasAssignees)
{
var activeAssigneeIds = model.Assignees!.Where(a => a.IsActive).Select(a => a.EmployeeId).ToList();
var assignees = await _context.Employees
.Include(e => e.JobRole)
.Where(e => activeAssigneeIds.Contains(e.Id) && e.IsActive)
.ToListAsync();
var jobEmployeeMappings = assignees.Select(e => new JobEmployeeMapping
{
AssigneeId = e.Id,
JobTicketId = jobTicket.Id,
TenantId = tenantId
}).ToList();
_context.JobEmployeeMappings.AddRange(jobEmployeeMappings);
assigneeVMs = _mapper.Map<List<BasicEmployeeVM>>(assignees);
}
// Handle tags if any
var jobTags = new List<JobTag>();
if (model.Tags?.Any(t => t.IsActive) ?? false)
{
var activeTagNames = model.Tags.Where(t => t.IsActive).Select(t => t.Name).Distinct().ToList();
var existingTags = await _context.JobTags.Where(jt => activeTagNames.Contains(jt.Name) && jt.TenantId == tenantId).ToListAsync();
var newTags = new List<JobTag>();
var tagMappings = new List<JobTagMapping>();
foreach (var tagDto in model.Tags.Where(t => t.IsActive).DistinctBy(t => t.Name))
{
var tag = existingTags.FirstOrDefault(jt => jt.Name == tagDto.Name);
if (tag == null)
{
tag = new JobTag
{
Id = Guid.NewGuid(),
Name = tagDto.Name,
TenantId = tenantId
};
newTags.Add(tag);
}
tagMappings.Add(new JobTagMapping
{
Id = Guid.NewGuid(),
JobTagId = tag.Id,
JobTicketId = jobTicket.Id,
TenantId = tenantId
});
}
if (newTags.Any()) _context.JobTags.AddRange(newTags);
_context.JobTagMappings.AddRange(tagMappings);
jobTags.AddRange(existingTags);
jobTags.AddRange(newTags);
}
// Create job update logs for status change
var updateLogs = new List<StatusUpdateLog>();
const string creationMessage = "A new job has been successfully created and added to the system. It is now ready for assignment and further processing.";
updateLogs.Add(new StatusUpdateLog
{
EntityId = jobTicket.Id,
NextStatusId = NewStatus,
Comment = creationMessage,
UpdatedAt = DateTime.UtcNow,
UpdatedById = loggedInEmployee.Id,
TenantId = tenantId
});
if (hasAssignees)
{
const string assignmentMessage = "The designated assignee(s) have been successfully assigned to the job and are now responsible for managing and completing the associated tasks.";
updateLogs.Add(new StatusUpdateLog
{
EntityId = jobTicket.Id,
StatusId = NewStatus,
NextStatusId = AssignedStatus,
Comment = assignmentMessage,
UpdatedAt = DateTime.UtcNow.AddTicks(10), // Small offset to preserve time order
UpdatedById = loggedInEmployee.Id,
TenantId = tenantId
});
}
_context.StatusUpdateLogs.AddRange(updateLogs);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
// Prepare response VM
var currentStatus = statusList.FirstOrDefault(js => js.Id == jobTicket.StatusId);
var vm = _mapper.Map<JobTicketVM>(jobTicket);
vm.Status = currentStatus;
vm.Project = _mapper.Map<BasicServiceProjectVM>(serviceProject);
vm.Assignees = assigneeVMs;
vm.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
vm.Tags = _mapper.Map<List<TagVM>>(jobTags.Distinct().ToList());
_logger.LogInfo("Job ticket {JobTicketId} successfully created with status {StatusId} by employee {EmployeeId} in tenant {TenantId}",
jobTicket.Id, jobTicket.StatusId, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.SuccessResponse(vm, "Job created successfully", 201);
}
catch (DbUpdateException dbEx)
{
await transaction.RollbackAsync();
_logger.LogError(dbEx, "Database error while creating job ticket for project {ProjectId} by employee {EmployeeId} in tenant {TenantId}",
model.ProjectId, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Database Error", "An error occurred while saving data to the database.", 500);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception while creating job ticket for project {ProjectId} by employee {EmployeeId} in tenant {TenantId}",
model.ProjectId, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500);
}
}
/// <summary>
/// Changes the status of a specified job ticket, recording the change in a status update log.
/// Ensures team-role-aware status transitions if applicable.
/// </summary>
/// <param name="model">DTO containing target status ID, job ticket ID, and optional comment.</param>
/// <param name="loggedInEmployee">Employee performing the status change (for audit and permissions).</param>
/// <param name="tenantId">Tenant context for multi-tenancy.</param>
/// <returns>ApiResponse with updated job ticket or error info.</returns>
public async Task<ApiResponse<object>> ChangeJobsStatusAsync(ChangeJobStatusDto model, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("ChangeJobsStatusAsync: Invalid (empty) tenantId for employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
if (model == null || model.JobTicketId == Guid.Empty || model.StatusId == Guid.Empty)
{
_logger.LogInfo("ChangeJobsStatusAsync: Invalid parameters submitted by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Bad Request", "Job or status ID is missing.", 400);
}
try
{
_logger.LogInfo("Attempting to change status for job {JobTicketId} to {NewStatusId} by employee {EmployeeId}",
model.JobTicketId, model.StatusId, loggedInEmployee.Id);
// Load the job ticket and key navigation properties
var jobTicket = await _context.JobTickets
.Include(jt => jt.Project)
.Include(jt => jt.CreatedBy).ThenInclude(e => e!.JobRole)
.FirstOrDefaultAsync(jt => jt.Id == model.JobTicketId && jt.TenantId == tenantId);
if (jobTicket == null)
{
_logger.LogWarning("Job ticket {JobTicketId} not found for status change in tenant {TenantId}", model.JobTicketId, tenantId);
return ApiResponse<object>.ErrorResponse("Job Not Found", "Job ticket not found.", 404);
}
var jobStatusMapping = await GetJobStatusMappingAsync(jobTicket.StatusId, model.StatusId, jobTicket.ProjectId, loggedInEmployee.Id, tenantId);
if (jobStatusMapping == null)
{
_logger.LogWarning("Invalid status transition requested: current={CurrentStatusId}, desired={DesiredStatusId}, tenant={TenantId}",
jobTicket.StatusId, model.StatusId, tenantId);
return ApiResponse<object>.ErrorResponse("Invalid Status", "Selected status transition is not allowed.", 400);
}
// Apply the new status and metadata
jobTicket.StatusId = model.StatusId;
jobTicket.UpdatedAt = DateTime.UtcNow;
jobTicket.UpdatedById = loggedInEmployee.Id;
// Write status change log
var updateLog = new StatusUpdateLog
{
Id = Guid.NewGuid(),
EntityId = jobTicket.Id,
StatusId = jobStatusMapping.StatusId,
NextStatusId = jobStatusMapping.NextStatusId,
Comment = model.Comment,
UpdatedAt = DateTime.UtcNow,
UpdatedById = loggedInEmployee.Id,
TenantId = tenantId
};
_context.StatusUpdateLogs.Add(updateLog);
await _context.SaveChangesAsync();
// Prepare response VM
var responseVm = _mapper.Map<JobTicketVM>(jobTicket);
responseVm.Status = jobStatusMapping.NextStatus;
_logger.LogInfo("Job {JobTicketId} status changed to {NewStatusId} by employee {EmployeeId}", jobTicket.Id, model.StatusId, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(responseVm, "Job status changed successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception during ChangeJobsStatusAsync for job {JobTicketId} by employee {EmployeeId} in tenant {TenantId}", model.JobTicketId, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "Failed to change job status. Please try again later.", 500);
}
}
/// <summary>
/// Updates a job ticket including its core details, assignees, tags, and manages status transitions with audit logs.
/// </summary>
/// <param name="id">ID of the job ticket to update.</param>
/// <param name="jobTicket">Existing job ticket entity to update.</param>
/// <param name="model">DTO containing updated job ticket values.</param>
/// <param name="loggedInEmployee">Employee performing the update (used for audit and authorization).</param>
/// <param name="tenantId">Tenant identifier for data isolation.</param>
/// <returns>ApiResponse containing updated job ticket data or error details.</returns>
public async Task<ApiResponse<object>> UpdateJobTicketAsync(Guid id, JobTicket jobTicket, UpdateJobTicketDto model, Employee loggedInEmployee, Guid tenantId)
{
// Validate tenant context early
if (tenantId == Guid.Empty)
{
_logger.LogWarning("UpdateJobTicketAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
// Begin database transaction for atomicity
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Concurrently load referenced project and status entities to validate foreign keys
var projectTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ServiceProjects.FirstOrDefaultAsync(sp => sp.Id == model.ProjectId && sp.TenantId == tenantId && sp.IsActive);
});
var statusTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobStatus.FirstOrDefaultAsync(js => js.Id == model.StatusId);
});
await Task.WhenAll(projectTask, statusTask);
// Validate existence of foreign entities
if (projectTask.Result == null)
{
_logger.LogWarning("Service project not found during job ticket update. ProjectId: {ProjectId}, TenantId: {TenantId}", model.ProjectId, tenantId);
return ApiResponse<object>.ErrorResponse("Service project not found", "Service project not found", 404);
}
if (statusTask.Result == null)
{
_logger.LogWarning("Job status not found during job ticket update. StatusId: {StatusId}", model.StatusId);
return ApiResponse<object>.ErrorResponse("Job status not found", "Job status not found", 404);
}
// Handle status change with validation and log creation
if (jobTicket.StatusId != model.StatusId)
{
var jobStatusMapping = await GetJobStatusMappingAsync(jobTicket.StatusId, model.StatusId, jobTicket.ProjectId, loggedInEmployee.Id, tenantId);
if (jobStatusMapping == null)
{
_logger.LogWarning("Invalid status transition requested from {CurrentStatusId} to {NewStatusId} in tenant {TenantId}",
jobTicket.StatusId, model.StatusId, tenantId);
return ApiResponse<object>.ErrorResponse("Invalid Status", "Selected status transition is not allowed.", 400);
}
var comment = $"Status changed from {jobStatusMapping.Status!.Name} to {jobStatusMapping.NextStatus!.Name}";
var updateLog = new StatusUpdateLog
{
Id = Guid.NewGuid(),
EntityId = jobTicket.Id,
StatusId = jobStatusMapping.StatusId,
NextStatusId = jobStatusMapping.NextStatusId,
Comment = comment,
UpdatedAt = DateTime.UtcNow,
UpdatedById = loggedInEmployee.Id,
TenantId = tenantId
};
_context.StatusUpdateLogs.Add(updateLog);
}
// Create BSON snapshot of existing entity for audit logging (MongoDB)
using var scope = _serviceScopeFactory.CreateScope();
var updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
BsonDocument existingEntityBson = updateLogHelper.EntityToBsonDocument(jobTicket);
// Map updated properties from DTO, set audit metadata
_mapper.Map(model, jobTicket);
jobTicket.UpdatedAt = DateTime.UtcNow;
jobTicket.UpdatedById = loggedInEmployee.Id;
_context.JobTickets.Update(jobTicket);
await _context.SaveChangesAsync();
// Handle assignee changes: add new and remove inactive mappings
if (model.Assignees?.Any() == true)
{
var employeeIds = model.Assignees.Select(e => e.EmployeeId).ToList();
var employeeTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.Employees.Where(e => employeeIds.Contains(e.Id)).ToListAsync();
});
var jobEmployeeMappingTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobEmployeeMappings.Where(jem => employeeIds.Contains(jem.AssigneeId) && jem.TenantId == tenantId).ToListAsync();
});
await Task.WhenAll(employeeTask, jobEmployeeMappingTask);
var employees = employeeTask.Result;
var jobEmployeeMappings = jobEmployeeMappingTask.Result;
var newMappings = new List<JobEmployeeMapping>();
var removeMappings = new List<JobEmployeeMapping>();
foreach (var assignee in model.Assignees)
{
var employee = employees.FirstOrDefault(e => e.Id == assignee.EmployeeId);
var mapping = jobEmployeeMappings.FirstOrDefault(jem => jem.AssigneeId == assignee.EmployeeId);
if (assignee.IsActive && mapping == null && employee != null)
{
newMappings.Add(new JobEmployeeMapping
{
Id = Guid.NewGuid(),
AssigneeId = assignee.EmployeeId,
JobTicketId = jobTicket.Id,
TenantId = tenantId,
});
}
else if (!assignee.IsActive && mapping != null)
{
removeMappings.Add(mapping);
}
}
if (newMappings.Any()) _context.JobEmployeeMappings.AddRange(newMappings);
if (removeMappings.Any()) _context.JobEmployeeMappings.RemoveRange(removeMappings);
}
await _context.SaveChangesAsync();
// Handle tag changes: add new tags/mappings and remove inactive mappings
if (model.Tags?.Any() == true)
{
var tagNames = model.Tags.Select(jt => jt.Name).ToList();
var tagTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobTags.Where(jt => tagNames.Contains(jt.Name) && jt.TenantId == tenantId).ToListAsync();
});
var tagMappingTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobTagMappings.Where(jtm => jtm.JobTicketId == jobTicket.Id && jtm.TenantId == tenantId).ToListAsync();
});
await Task.WhenAll(tagTask, tagMappingTask);
var existingTags = tagTask.Result;
var existingTagMappings = tagMappingTask.Result;
var newJobTags = new List<JobTag>();
var newJobTagMappings = new List<JobTagMapping>();
var removeJobTagMappings = new List<JobTagMapping>();
foreach (var tagDto in model.Tags)
{
var tag = existingTags.FirstOrDefault(jt => jt.Name == tagDto.Name);
if (tag == null)
{
tag = new JobTag
{
Id = Guid.NewGuid(),
Name = tagDto.Name,
TenantId = tenantId
};
newJobTags.Add(tag);
}
var tagMapping = existingTagMappings.FirstOrDefault(jtm => jtm.JobTagId == tag.Id);
if (tagDto.IsActive && tagMapping == null)
{
newJobTagMappings.Add(new JobTagMapping
{
Id = Guid.NewGuid(),
JobTagId = tag.Id,
JobTicketId = jobTicket.Id,
TenantId = tenantId
});
}
else if (!tagDto.IsActive && tagMapping != null)
{
removeJobTagMappings.Add(tagMapping);
}
}
if (newJobTags.Any()) _context.JobTags.AddRange(newJobTags);
if (newJobTagMappings.Any()) _context.JobTagMappings.AddRange(newJobTagMappings);
if (removeJobTagMappings.Any()) _context.JobTagMappings.RemoveRange(removeJobTagMappings);
}
await _context.SaveChangesAsync();
await transaction.CommitAsync();
// Push update log asynchronously to MongoDB for audit
var updateLogTask = updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
OldObject = existingEntityBson,
UpdatedAt = DateTime.UtcNow
}, "JobTicketModificationLog");
// Reload updated job ticket with navigation properties
var jobTicketTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobTickets
.Include(jt => jt.Status)
.Include(jt => jt.Project)
.Include(jt => jt.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(jt => jt.UpdatedBy).ThenInclude(e => e!.JobRole)
.FirstOrDefaultAsync(jt => jt.Id == id && jt.TenantId == tenantId);
});
await Task.WhenAll(updateLogTask, jobTicketTask);
jobTicket = jobTicketTask.Result ?? new JobTicket();
var response = _mapper.Map<JobTicketVM>(jobTicket);
_logger.LogInfo("Job ticket {JobTicketId} updated successfully by employee {EmployeeId} in tenant {TenantId}", id, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.SuccessResponse(response, "Job updated successfully", 200);
}
catch (DbUpdateException dbEx)
{
await transaction.RollbackAsync();
_logger.LogError(dbEx, "Database error while updating job ticket for project {ProjectId} by employee {EmployeeId} in tenant {TenantId}",
model.ProjectId, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Database Error", "An error occurred while saving data to the database.", 500);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception while updating job ticket for project {ProjectId} by employee {EmployeeId} in tenant {TenantId}",
model.ProjectId, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500);
}
}
#endregion
#region =================================================================== Job Comments Functions ===================================================================
/// <summary>
/// Retrieves a paginated list of comments with attachments for a specified job ticket within a tenant context.
/// </summary>
/// <param name="jobTicketId">Optional job ticket ID to filter comments.</param>
/// <param name="pageNumber">Page number (1-based) for pagination.</param>
/// <param name="pageSize">Page size for pagination.</param>
/// <param name="loggedInEmployee">Employee making the request (for authorization and logging).</param>
/// <param name="tenantId">Tenant context ID for multi-tenancy.</param>
/// <returns>ApiResponse with paged comments, attachments, and metadata, or error details.</returns>
public async Task<ApiResponse<object>> GetCommentListByJobTicketAsync(Guid? jobTicketId, int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId)
{
if (tenantId == Guid.Empty)
{
_logger.LogWarning("TenantId missing in comment list request by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
if (pageNumber < 1 || pageSize < 1)
{
_logger.LogInfo("Invalid pagination parameters in comment list request. PageNumber: {PageNumber}, PageSize: {PageSize}", pageNumber, pageSize);
return ApiResponse<object>.ErrorResponse("Bad Request", "Page number and size must be greater than zero.", 400);
}
try
{
_logger.LogInfo("Fetching comment list for jobTicketId {JobTicketId} by employee {EmployeeId} in tenant {TenantId}",
jobTicketId ?? Guid.Empty, loggedInEmployee.Id, tenantId);
var commentQuery = _context.JobComments
.Include(jc => jc.JobTicket).ThenInclude(jt => jt!.Status)
.Include(jc => jc.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(jc => jc.UpdatedBy).ThenInclude(e => e!.JobRole)
.Where(jc => jc.TenantId == tenantId && jc.JobTicket != null && jc.CreatedBy != null && jc.CreatedBy.JobRole != null);
// Filter by jobTicketId if provided after verifying existence
if (jobTicketId.HasValue)
{
var jobTicketExists = await _context.JobTickets.AnyAsync(jt =>
jt.Id == jobTicketId && jt.TenantId == tenantId);
if (!jobTicketExists)
{
_logger.LogWarning("Job ticket {JobTicketId} not found in tenant {TenantId} for comment listing", jobTicketId, tenantId);
return ApiResponse<object>.ErrorResponse("Job not found", "Job ticket not found.", 404);
}
commentQuery = commentQuery.Where(jc => jc.JobTicketId == jobTicketId.Value);
}
// Calculate total count for pagination
var totalEntities = await commentQuery.CountAsync();
var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize);
// Fetch paged comments ordered by creation date descending
var comments = await commentQuery
.OrderByDescending(jc => jc.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var commentIds = comments.Select(jc => jc.Id).ToList();
// Fetch attachments for current page comments
var attachments = await _context.JobAttachments
.Include(ja => ja.Document)
.Where(ja => ja.JobCommentId.HasValue &&
ja.Document != null &&
commentIds.Contains(ja.JobCommentId.Value) &&
ja.TenantId == tenantId)
.ToListAsync();
// Map comments and attach corresponding documents with pre-signed URLs for access
var commentVMs = comments.Select(jc =>
{
var relatedDocuments = attachments
.Where(ja => ja.JobCommentId == jc.Id)
.Select(ja => ja.Document!)
.ToList();
var mappedComment = _mapper.Map<JobCommentVM>(jc);
if (relatedDocuments.Any())
{
mappedComment.Attachments = relatedDocuments.Select(doc =>
{
var docVM = _mapper.Map<DocumentVM>(doc);
docVM.PreSignedUrl = _s3Service.GeneratePreSignedUrl(doc.S3Key);
docVM.ThumbPreSignedUrl = string.IsNullOrWhiteSpace(doc.ThumbS3Key) ?
_s3Service.GeneratePreSignedUrl(doc.S3Key) :
_s3Service.GeneratePreSignedUrl(doc.ThumbS3Key);
return docVM;
}).ToList();
}
return mappedComment;
}).ToList();
var response = new
{
CurrentPage = pageNumber,
TotalPages = totalPages,
TotalEntities = totalEntities,
Data = commentVMs
};
_logger.LogInfo("{Count} comments fetched successfully for jobTicketId {JobTicketId} by employee {EmployeeId}",
commentVMs.Count, jobTicketId ?? Guid.Empty, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(response, $"{commentVMs.Count} record(s) fetched successfully.", 200);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching comments for jobTicketId {JobTicketId} by employee {EmployeeId} in tenant {TenantId}",
jobTicketId ?? Guid.Empty, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "Failed to fetch comments. Please try again later.", 500);
}
}
/// <summary>
/// Adds a comment with optional attachments to a job ticket within the specified tenant context.
/// </summary>
/// <param name="model">DTO containing comment content, job ticket ID, and attachments.</param>
/// <param name="loggedInEmployee">Employee making the comment (for auditing).</param>
/// <param name="tenantId">Tenant context for data isolation.</param>
/// <returns>ApiResponse containing created comment details or relevant error information.</returns>
public async Task<ApiResponse<object>> AddCommentToJobTicketAsync(JobCommentDto model, Employee loggedInEmployee, Guid tenantId)
{
// Validate tenant context
if (tenantId == Guid.Empty)
{
_logger.LogWarning("Add comment attempt with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
// Validate input DTO
if (model == null || model.JobTicketId == Guid.Empty || string.IsNullOrWhiteSpace(model.Comment))
{
_logger.LogInfo("Invalid comment model provided by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Bad Request", "Comment data is incomplete or invalid.", 400);
}
try
{
_logger.LogInfo("Attempting to add comment to job ticket {JobTicketId} by employee {EmployeeId}", model.JobTicketId, loggedInEmployee.Id);
// Verify the job ticket's existence and load minimal required info
var jobTicket = await _context.JobTickets
.Include(jt => jt.Status)
.AsNoTracking()
.FirstOrDefaultAsync(jt => jt.Id == model.JobTicketId && jt.TenantId == tenantId);
if (jobTicket == null)
{
_logger.LogWarning("Job ticket {JobTicketId} not found or inaccessible in tenant {TenantId}", model.JobTicketId, tenantId);
return ApiResponse<object>.ErrorResponse("Job Not Found", "Job ticket not found or inaccessible.", 404);
}
// Create new comment entity
var comment = new JobComment
{
Id = Guid.NewGuid(),
JobTicketId = jobTicket.Id,
Comment = model.Comment.Trim(),
IsActive = true,
CreatedAt = DateTime.UtcNow,
CreatedById = loggedInEmployee.Id,
TenantId = tenantId
};
_context.JobComments.Add(comment);
// Handle attachments if provided
if (model.Attachments?.Any() ?? false)
{
var batchId = Guid.NewGuid();
var documents = new List<Document>();
var attachments = new List<JobAttachment>();
foreach (var attachment in model.Attachments)
{
string base64 = attachment.Base64Data?.Split(',').LastOrDefault() ?? "";
if (string.IsNullOrWhiteSpace(base64))
{
_logger.LogWarning("Attachment missing base64 data in comment for job ticket {JobTicketId} by employee {EmployeeId}", jobTicket.Id, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Bad Request", "Attachment image data is missing or invalid.", 400);
}
// Determine content type and generate storage keys
var fileType = _s3Service.GetContentTypeFromBase64(base64);
var fileName = _s3Service.GenerateFileName(fileType, tenantId, "job_comment");
var objectKey = $"tenant-{tenantId}/ServiceProject/{jobTicket.ProjectId}/Job/{jobTicket.Id}/Comment/{comment.Id}/{fileName}";
// Upload file asynchronously to S3
await _s3Service.UploadFileAsync(base64, fileType, objectKey);
// Create document record for uploaded file
var document = new Document
{
Id = Guid.NewGuid(),
BatchId = batchId,
FileName = attachment.FileName ?? fileName,
ContentType = fileType,
S3Key = objectKey,
FileSize = attachment.FileSize,
UploadedAt = DateTime.UtcNow,
UploadedById = loggedInEmployee.Id,
TenantId = tenantId
};
documents.Add(document);
// Link document as attachment to the comment
attachments.Add(new JobAttachment
{
Id = Guid.NewGuid(),
DocumentId = document.Id,
StatusId = jobTicket.StatusId,
JobCommentId = comment.Id,
TenantId = tenantId
});
}
_context.Documents.AddRange(documents);
_context.JobAttachments.AddRange(attachments);
}
// Persist all inserts in database
await _context.SaveChangesAsync();
// Prepare response with mapped comment, creator info, and basic job ticket info
var response = _mapper.Map<JobCommentVM>(comment);
response.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
response.JobTicket = _mapper.Map<BasicJobTicketVM>(jobTicket);
_logger.LogInfo("Successfully added comment {CommentId} to job ticket {JobTicketId} by employee {EmployeeId}",
comment.Id, jobTicket.Id, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(response, "Comment added to job ticket successfully.", 201);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding comment to job ticket {JobTicketId} by employee {EmployeeId} in tenant {TenantId}",
model.JobTicketId, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "Failed to add comment. Please try again later.", 500);
}
}
/// <summary>
/// Updates a job comment, including its content and attachments, with audit and error logging.
/// </summary>
/// <param name="id">ID of the job comment to be updated.</param>
/// <param name="model">DTO containing updated comment and attachment details.</param>
/// <param name="loggedInEmployee">Employee performing the update (for audit/versioning).</param>
/// <param name="tenantId">Tenant identifier for multi-tenant isolation.</param>
/// <returns>ApiResponse containing updated comment details or error information.</returns>
public async Task<ApiResponse<object>> UpdateCommentAsync(Guid id, JobCommentDto model, Employee loggedInEmployee, Guid tenantId)
{
// Transaction ensures atomic update of comment and attachments.
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Validate ID consistency and input presence
if (!model.Id.HasValue || model.Id != id)
{
_logger.LogWarning("ID mismatch: route ({RouteId}) vs model ({ModelId}) by employee {EmployeeId}", id, model.Id ?? Guid.Empty, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("ID mismatch between route and payload", "ID mismatch between route and payload", 400);
}
// Concurrently fetch existing job comment and related active job ticket for validation
var jobCommentTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobComments
.AsNoTracking()
.FirstOrDefaultAsync(jc => jc.Id == id && jc.JobTicketId == model.JobTicketId && jc.TenantId == tenantId);
});
var jobTicketTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobTickets
.AsNoTracking()
.FirstOrDefaultAsync(jc => jc.Id == model.JobTicketId && jc.TenantId == tenantId && jc.IsActive);
});
await Task.WhenAll(jobCommentTask, jobTicketTask);
var jobComment = jobCommentTask.Result;
var jobTicket = jobTicketTask.Result;
if (jobTicket == null)
{
_logger.LogWarning("Job ticket {JobTicketId} not found for updating comment {CommentId}", model.JobTicketId, id);
return ApiResponse<object>.ErrorResponse("Job not found", "Job not found", 404);
}
if (jobComment == null)
{
_logger.LogWarning("Job comment {CommentId} not found for update.", id);
return ApiResponse<object>.ErrorResponse("Job Comment not found", "Job Comment not found", 404);
}
// Audit: BSON snapshot before update (MongoDB)
using var scope = _serviceScopeFactory.CreateScope();
var updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
BsonDocument existingEntityBson = updateLogHelper.EntityToBsonDocument(jobComment);
// Update comment core fields and audit
_mapper.Map(model, jobComment);
jobComment.UpdatedAt = DateTime.UtcNow;
jobComment.UpdatedById = loggedInEmployee.Id;
_context.JobComments.Update(jobComment);
await _context.SaveChangesAsync();
// Attachment: Add new or remove deleted as specified in DTO
if (model.Attachments?.Any() == true)
{
// New attachments
var newBillAttachments = model.Attachments.Where(ba => ba.DocumentId == null && ba.IsActive).ToList();
if (newBillAttachments.Any())
{
var batchId = Guid.NewGuid();
var documents = new List<Document>();
var attachments = new List<JobAttachment>();
foreach (var attachment in newBillAttachments)
{
string base64 = attachment.Base64Data?.Split(',').LastOrDefault() ?? "";
if (string.IsNullOrWhiteSpace(base64))
{
_logger.LogWarning("Missing base64 data for new attachment in comment {CommentId}", id);
return ApiResponse<object>.ErrorResponse("Bad Request", "Attachment image data is missing or invalid.", 400);
}
// File upload and document creation
var fileType = _s3Service.GetContentTypeFromBase64(base64);
var fileName = _s3Service.GenerateFileName(fileType, tenantId, "job_comment");
var objectKey = $"tenant-{tenantId}/ServiceProject/{jobTicket.ProjectId}/Job/{jobTicket.Id}/Comment/{jobComment.Id}/{fileName}";
await _s3Service.UploadFileAsync(base64, fileType, objectKey);
var document = new Document
{
Id = Guid.NewGuid(),
BatchId = batchId,
FileName = attachment.FileName ?? fileName,
ContentType = fileType,
S3Key = objectKey,
FileSize = attachment.FileSize,
UploadedAt = DateTime.UtcNow,
UploadedById = loggedInEmployee.Id,
TenantId = tenantId
};
documents.Add(document);
attachments.Add(new JobAttachment
{
Id = Guid.NewGuid(),
DocumentId = document.Id,
StatusId = jobTicket.StatusId,
JobCommentId = id,
TenantId = tenantId
});
}
_context.Documents.AddRange(documents);
_context.JobAttachments.AddRange(attachments);
try
{
await _context.SaveChangesAsync();
_logger.LogInfo("{Count} new attachments added to comment {CommentId} by employee {EmployeeId}", newBillAttachments.Count, id, loggedInEmployee.Id);
}
catch (DbUpdateException dbEx)
{
await transaction.RollbackAsync();
_logger.LogError(dbEx, "Database error adding new attachments for comment {CommentId}", id);
return ApiResponse<object>.ErrorResponse("Database Error", dbEx.Message, 500);
}
}
// Attachments for deletion
var deleteBillAttachments = model.Attachments.Where(ba => ba.DocumentId != null && !ba.IsActive).ToList();
if (deleteBillAttachments.Any())
{
var documentIds = deleteBillAttachments.Select(d => d.DocumentId!.Value).ToList();
try
{
await DeleteAttachemnts(documentIds);
_logger.LogInfo("{Count} attachments deleted for comment {CommentId} by employee {EmployeeId}", deleteBillAttachments.Count, id, loggedInEmployee.Id);
}
catch (DbUpdateException dbEx)
{
await transaction.RollbackAsync();
_logger.LogError(dbEx, "Database error deleting attachments during comment update {CommentId}", id);
return ApiResponse<object>.ErrorResponse("Database Error", dbEx.Message, 500);
}
catch (Exception ex)
{
await transaction.RollbackAsync();
_logger.LogError(ex, "General error deleting attachments during comment update {CommentId}", id);
return ApiResponse<object>.ErrorResponse("Attachment Deletion Error", ex.Message, 500);
}
}
}
// Push audit log to MongoDB
var updateLogTask = updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
OldObject = existingEntityBson,
UpdatedAt = DateTime.UtcNow
}, "JobTicketModificationLog");
// Reload updated comment with related entities for comprehensive response
var jobCommentTaskReload = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobComments
.Include(jc => jc.JobTicket).ThenInclude(jt => jt!.Status)
.Include(jc => jc.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(jc => jc.UpdatedBy).ThenInclude(e => e!.JobRole)
.AsNoTracking()
.FirstOrDefaultAsync(jc => jc.Id == id && jc.JobTicketId == model.JobTicketId && jc.TenantId == tenantId);
});
var documentReloadTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobAttachments
.Include(ja => ja.Document)
.AsNoTracking()
.Where(ja => ja.JobCommentId == id && ja.Document != null && ja.TenantId == tenantId)
.Select(ja => ja.Document!)
.ToListAsync();
});
await Task.WhenAll(updateLogTask, jobCommentTaskReload, documentReloadTask);
var updatedJobComment = jobCommentTaskReload.Result;
var updatedDocuments = documentReloadTask.Result;
var response = _mapper.Map<JobCommentVM>(updatedJobComment);
response.Attachments = updatedDocuments.Select(doc =>
{
var docVM = _mapper.Map<DocumentVM>(doc);
docVM.PreSignedUrl = _s3Service.GeneratePreSignedUrl(doc.S3Key);
docVM.ThumbPreSignedUrl = string.IsNullOrWhiteSpace(doc.ThumbS3Key)
? _s3Service.GeneratePreSignedUrl(doc.S3Key)
: _s3Service.GeneratePreSignedUrl(doc.ThumbS3Key);
return docVM;
}).ToList();
await transaction.CommitAsync();
_logger.LogInfo("Comment {CommentId} updated successfully by employee {EmployeeId}", id, loggedInEmployee.Id);
return ApiResponse<object>.SuccessResponse(response, "Comment updated successfully", 200);
}
catch (DbUpdateException dbEx)
{
await transaction.RollbackAsync();
_logger.LogError(dbEx, "Database error while updating comment {CommentId} by employee {EmployeeId} in tenant {TenantId}", id, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Database Error", "An error occurred while saving data to the database.", 500);
}
catch (Exception ex)
{
await transaction.RollbackAsync();
_logger.LogError(ex, "Unhandled exception while updating comment {CommentId} by employee {EmployeeId} in tenant {TenantId}", id, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500);
}
}
#endregion
#region =================================================================== Helper Functions ===================================================================
private async Task DeleteAttachemnts(List<Guid> documentIds)
{
using var scope = _serviceScopeFactory.CreateScope();
var _updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
var attachmentTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var attachments = await dbContext.JobAttachments.AsNoTracking().Where(ba => documentIds.Contains(ba.DocumentId)).ToListAsync();
dbContext.JobAttachments.RemoveRange(attachments);
await dbContext.SaveChangesAsync();
});
var documentsTask = Task.Run(async () =>
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var documents = await dbContext.Documents.AsNoTracking().Where(ba => documentIds.Contains(ba.Id)).ToListAsync();
if (documents.Any())
{
dbContext.Documents.RemoveRange(documents);
await dbContext.SaveChangesAsync();
List<S3DeletionObject> deletionObject = new List<S3DeletionObject>();
foreach (var document in documents)
{
deletionObject.Add(new S3DeletionObject
{
Key = document.S3Key
});
if (!string.IsNullOrWhiteSpace(document.ThumbS3Key) && document.ThumbS3Key != document.S3Key)
{
deletionObject.Add(new S3DeletionObject
{
Key = document.ThumbS3Key
});
}
}
await _updateLogHelper.PushToS3DeletionAsync(deletionObject);
}
});
await Task.WhenAll(attachmentTask, documentsTask);
}
/// <summary>
/// Retrieves a job ticket by its unique identifier and associated tenant ID.
/// </summary>
/// <param name="id">The unique identifier of the job ticket.</param>
/// <param name="tenantId">The tenant identifier for multi-tenant isolation.</param>
/// <returns>The job ticket if found; otherwise, null.</returns>
public async Task<JobTicket?> GetJobTicketByIdAsync(Guid id, Guid tenantId)
{
try
{
_logger.LogInfo($"Attempting to retrieve job ticket with ID: {id} for tenant: {tenantId}");
// Use AsNoTracking for read-only queries to improve performance
var jobTicket = await _context.JobTickets
.AsNoTracking()
.FirstOrDefaultAsync(jt => jt.Id == id && jt.TenantId == tenantId);
if (jobTicket == null)
{
_logger.LogWarning($"Job ticket not found. ID: {id}, TenantID: {tenantId}");
}
else
{
_logger.LogInfo($"Job ticket found. ID: {id}, TenantID: {tenantId}");
}
return jobTicket;
}
catch (Exception ex)
{
_logger.LogError(ex, $"An error occurred while retrieving job ticket. ID: {id}, TenantID: {tenantId}");
// Consider whether you want to rethrow or return null on error; returning null here
return null;
}
}
private async Task<JobStatusMapping?> GetJobStatusMappingAsync(Guid statusId, Guid nextStatusId, Guid projectId, Guid loggedInEmployeeId, Guid tenantId)
{
// Find transition mappings for the current status and desired status, considering team role if allocation exists
var statusMappingQuery = _context.JobStatusMappings
.Include(jsm => jsm.Status)
.Include(jsm => jsm.NextStatus)
.Where(jsm =>
jsm.StatusId == statusId &&
jsm.NextStatusId == nextStatusId &&
jsm.Status != null &&
jsm.NextStatus != null &&
jsm.TenantId == tenantId);
// Find allocation for current employee (to determine team role for advanced mapping)
var projectAllocation = await _context.ServiceProjectAllocations
.Where(spa => spa.EmployeeId == loggedInEmployeeId &&
spa.ProjectId == projectId &&
spa.TenantId == tenantId &&
spa.IsActive)
.OrderByDescending(spa => spa.AssignedAt)
.FirstOrDefaultAsync();
var teamRoleId = projectAllocation?.TeamRoleId;
var hasTeamRoleMapping = projectAllocation != null
&& await statusMappingQuery.AnyAsync(jsm => jsm.TeamRoleId == teamRoleId);
// Filter by team role or fallback to global (null team role)
if (hasTeamRoleMapping)
{
statusMappingQuery = statusMappingQuery.Where(jsm => jsm.TeamRoleId == teamRoleId);
}
else
{
statusMappingQuery = statusMappingQuery.Where(jsm => jsm.TeamRoleId == null);
}
var jobStatusMapping = await statusMappingQuery.FirstOrDefaultAsync();
return jobStatusMapping;
}
#endregion
}
}