1200 lines
60 KiB
C#
1200 lines
60 KiB
C#
using AutoMapper;
|
|
using Marco.Pms.DataAccess.Data;
|
|
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.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;
|
|
|
|
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 ===================================================================
|
|
public async Task<ApiResponse<object>> GetServiceProjectListAsync(int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
try
|
|
{
|
|
|
|
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 totalEntites = await serviceProjectQuery.CountAsync();
|
|
var totalPages = (int)Math.Ceiling((double)totalEntites / pageSize);
|
|
|
|
var serviceProjects = await serviceProjectQuery
|
|
.OrderByDescending(e => e.CreatedAt)
|
|
.Skip((pageNumber - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.ToListAsync();
|
|
|
|
var serviceProjectIds = serviceProjects.Select(sp => sp.Id).ToList();
|
|
var serviceProjectServiceMappings = await _context.ServiceProjectServiceMapping
|
|
.Include(sps => sps.Service)
|
|
.Where(sps => serviceProjectIds.Contains(sps.ProjectId) &&
|
|
sps.Service != null &&
|
|
sps.TenantId == tenantId)
|
|
.ToListAsync();
|
|
|
|
var serviceProjectVMs = serviceProjects.Select(sp =>
|
|
{
|
|
var services = serviceProjectServiceMappings.Where(sps => sps.ProjectId == sp.Id).Select(sps => sps.Service!).ToList();
|
|
var result = _mapper.Map<ServiceProjectVM>(sp);
|
|
result.Services = _mapper.Map<List<ServiceMasterVM>>(services);
|
|
return result;
|
|
}).ToList();
|
|
|
|
var response = new
|
|
{
|
|
CurrentPage = pageNumber,
|
|
TotalPages = totalPages,
|
|
TotalEntites = totalEntites,
|
|
Data = serviceProjectVMs,
|
|
};
|
|
|
|
_logger.LogInfo("Successfully retrieved a total of {ProjectCount} projects.", serviceProjectVMs.Count);
|
|
return ApiResponse<object>.SuccessResponse(response, "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<object>.ErrorResponse("An internal server error occurred. Please try again later.", null, 500);
|
|
}
|
|
|
|
}
|
|
public async Task<ApiResponse<object>> GetServiceProjectDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
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)
|
|
.FirstOrDefaultAsync(sp => sp.Id == id && sp.TenantId == tenantId);
|
|
if (serviceProject == null)
|
|
{
|
|
return ApiResponse<object>.ErrorResponse("Service Project not found", "Service Project not found", 404);
|
|
}
|
|
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();
|
|
//var numberOfJobs = await _context.JobTickets.Where(jt => jt.ProjectId == serviceProject.Id && jt.TenantId == tenantId).CountAsync();
|
|
|
|
var response = _mapper.Map<ServiceProjectDetailsVM>(serviceProject);
|
|
response.Services = _mapper.Map<List<ServiceMasterVM>>(services);
|
|
//response.NumberOfJobs = numberOfJobs;
|
|
response.NumberOfJobs = 0;
|
|
return ApiResponse<object>.SuccessResponse(response, "Service Project Details fetched successfully", 200);
|
|
}
|
|
public async Task<ApiResponse<object>> CreateServiceProjectAsync(ServiceProjectDto model, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
var serviceIds = model.Services.Where(s => s.IsActive).Select(s => s.ServiceId).ToList();
|
|
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;
|
|
|
|
if (client == null)
|
|
{
|
|
return ApiResponse<object>.ErrorResponse("Client not found", "Client not found", 404);
|
|
}
|
|
if (status == null)
|
|
{
|
|
return ApiResponse<object>.ErrorResponse("Project Status not found", "Project Status not found", 404);
|
|
}
|
|
|
|
var serviceProject = _mapper.Map<ServiceProject>(model);
|
|
serviceProject.Id = Guid.NewGuid();
|
|
serviceProject.CreatedById = loggedInEmployee.Id;
|
|
serviceProject.CreatedAt = DateTime.UtcNow;
|
|
serviceProject.IsActive = true;
|
|
serviceProject.TenantId = tenantId;
|
|
|
|
var projectServiceMapping = model.Services.Where(sdto => services.Any(s => s.Id == sdto.ServiceId)).Select(sdto => new ServiceProjectServiceMapping
|
|
{
|
|
ServiceId = sdto.ServiceId,
|
|
ProjectId = serviceProject.Id,
|
|
TenantId = tenantId
|
|
}).ToList();
|
|
try
|
|
{
|
|
_context.ServiceProjects.Add(serviceProject);
|
|
_context.ServiceProjectServiceMapping.AddRange(projectServiceMapping);
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInfo("Service Project {ProjectId} created successfully for TenantId={TenantId}, by Employee {EmployeeId}.",
|
|
serviceProject.Id, tenantId, loggedInEmployee);
|
|
|
|
var serviceProjectVM = _mapper.Map<ServiceProjectVM>(serviceProject);
|
|
|
|
serviceProjectVM.Client = _mapper.Map<BasicOrganizationVm>(client);
|
|
serviceProjectVM.Status = status;
|
|
|
|
serviceProjectVM.Services = services.Where(s => serviceIds.Contains(s.Id)).Select(s => _mapper.Map<ServiceMasterVM>(s)).ToList();
|
|
|
|
serviceProjectVM.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
|
|
return ApiResponse<object>.SuccessResponse(serviceProjectVM, "An Successfullly occurred while saving the project.", 201);
|
|
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "DB Failure: Service Project creation failed for TenantId={TenantId}. Rolling back.", tenantId);
|
|
return ApiResponse<object>.ErrorResponse("An error occurred while saving the project.", ex.Message, 500);
|
|
}
|
|
|
|
|
|
}
|
|
public async Task<ApiResponse<object>> UpdateServiceProjectAsync(Guid id, ServiceProjectDto model, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
try
|
|
{
|
|
var serviceIds = model.Services.Select(s => s.ServiceId).ToList();
|
|
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;
|
|
|
|
if (client == null)
|
|
{
|
|
return ApiResponse<object>.ErrorResponse("Client not found", "Client not found", 404);
|
|
}
|
|
if (status == null)
|
|
{
|
|
return ApiResponse<object>.ErrorResponse("Project Status not found", "Project Status not found", 404);
|
|
}
|
|
|
|
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 user {UserId}.", id, loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404);
|
|
}
|
|
|
|
_mapper.Map(model, serviceProject);
|
|
|
|
serviceProject.UpdatedAt = DateTime.UtcNow;
|
|
serviceProject.UpdatedById = loggedInEmployee.Id;
|
|
|
|
var serviceProjectServiceMappings = await _context.ServiceProjectServiceMapping
|
|
.AsNoTracking()
|
|
.Where(sps => sps.ProjectId == serviceProject.Id && sps.TenantId == tenantId)
|
|
.ToListAsync();
|
|
|
|
var newMapping = new List<ServiceProjectServiceMapping>();
|
|
var removedMapping = new List<ServiceProjectServiceMapping>();
|
|
|
|
foreach (var dto in model.Services)
|
|
{
|
|
var serviceProjectServiceMapping = serviceProjectServiceMappings
|
|
.FirstOrDefault(sps => sps.ServiceId == dto.ServiceId);
|
|
|
|
if (dto.IsActive && serviceProjectServiceMapping == null)
|
|
{
|
|
newMapping.Add(new ServiceProjectServiceMapping
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ServiceId = dto.ServiceId,
|
|
ProjectId = serviceProject.Id,
|
|
TenantId = tenantId,
|
|
});
|
|
}
|
|
else if (!dto.IsActive && serviceProjectServiceMapping != null)
|
|
{
|
|
removedMapping.Add(serviceProjectServiceMapping);
|
|
}
|
|
}
|
|
|
|
_context.ServiceProjectServiceMapping.AddRange(newMapping);
|
|
_context.ServiceProjectServiceMapping.RemoveRange(removedMapping);
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
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.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();
|
|
});
|
|
|
|
await Task.WhenAll(serviceProjectTask, servicesTask);
|
|
|
|
|
|
serviceProject = serviceProjectTask.Result;
|
|
services = servicesTask.Result;
|
|
|
|
ServiceProjectVM serviceProjectVm = _mapper.Map<ServiceProjectVM>(serviceProject);
|
|
serviceProjectVm.Services = _mapper.Map<List<ServiceMasterVM>>(services);
|
|
|
|
return ApiResponse<object>.SuccessResponse(serviceProjectVm, "Service Project updated successfully.", 200);
|
|
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "An unexpected error occurred in Updating Service Project for tenant {TenantId}.", tenantId);
|
|
return ApiResponse<object>.ErrorResponse("An internal server error occurred. Please try again later.", null, 500);
|
|
}
|
|
|
|
}
|
|
public async Task<ApiResponse<object>> DeActivateServiceProjectAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
try
|
|
{
|
|
|
|
var serviceProject = await _context.ServiceProjects
|
|
.Where(sp => sp.Id == id && sp.TenantId == tenantId).FirstOrDefaultAsync();
|
|
|
|
if (serviceProject == null)
|
|
{
|
|
_logger.LogWarning("Attempt to de-activate non-existent Service project with ID {ProjectId} by user {UserId}.", id, loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404);
|
|
}
|
|
|
|
serviceProject.IsActive = isActive;
|
|
|
|
await _context.SaveChangesAsync();
|
|
|
|
_logger.LogInfo("Successfully de-activated service project {ProjectId}", id);
|
|
return ApiResponse<object>.SuccessResponse(new { }, "Projects de-activated successfully.", 200);
|
|
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// --- Step 5: Graceful Error Handling ---
|
|
_logger.LogError(ex, "An unexpected error occurred in DeActivateServiceProject for tenant {TenantId}.", tenantId);
|
|
return ApiResponse<object>.ErrorResponse("An internal server error occurred. Please try again later.", null, 500);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Expense Functions ===================================================================
|
|
#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)
|
|
{
|
|
await transaction.RollbackAsync();
|
|
_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);
|
|
}
|
|
|
|
// 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 == jobTicket.StatusId &&
|
|
jsm.NextStatusId == model.StatusId &&
|
|
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 == loggedInEmployee.Id &&
|
|
spa.ProjectId == jobTicket.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();
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
#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");
|
|
var objectKey = $"tenant-{tenantId}/ServiceProject/{jobTicket.ProjectId}/Job/{jobTicket.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);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|