2842 lines
152 KiB
C#
2842 lines
152 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.AttendanceVM;
|
|
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 =================================================================== Service Project Talking Points Functions ===================================================================
|
|
//public async Task<ApiResponse<object>> CreateTalkingPointToServiceProjectAsync(TalkingPointDto model, Employee loggedInEmployee, Guid tenantId)
|
|
//{
|
|
// var serviceProject = await _context.ServiceProjects.AsNoTracking().FirstOrDefaultAsync(sp => sp.Id == model.ServiceProjectId && sp.TenantId == tenantId);
|
|
// if (serviceProject == null)
|
|
// {
|
|
// return ApiResponse<object>.ErrorResponse("Service project not found", "Service project not found", 404);
|
|
// }
|
|
// var talkingPoint = _mapper.Map<TalkingPoint>(model);
|
|
// talkingPoint.Id = Guid.NewGuid();
|
|
// talkingPoint.IsActive = true;
|
|
// talkingPoint.CreatedAt = DateTime.UtcNow;
|
|
// talkingPoint.CreatedById = loggedInEmployee.Id;
|
|
// talkingPoint.TenantId = tenantId;
|
|
|
|
// _context.TalkingPoints.Add(talkingPoint);
|
|
|
|
// var documents = new List<Document>();
|
|
|
|
// // Handle attachments if provided
|
|
// if (model.Attachments?.Any() ?? false)
|
|
// {
|
|
// var batchId = Guid.NewGuid();
|
|
// var attachments = new List<TalkingPointAttachment>();
|
|
|
|
// foreach (var attachment in model.Attachments)
|
|
// {
|
|
// string base64 = attachment.Base64Data?.Split(',').LastOrDefault() ?? "";
|
|
// if (string.IsNullOrWhiteSpace(base64))
|
|
// {
|
|
// 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, "talking_point");
|
|
// var objectKey = $"tenant-{tenantId}/ServiceProject/{serviceProject.Id}/TalkingPoint/{talkingPoint.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 TalkingPointAttachment
|
|
// {
|
|
// Id = Guid.NewGuid(),
|
|
// DocumentId = document.Id,
|
|
// TalkingPointId = talkingPoint.Id,
|
|
// TenantId = tenantId
|
|
// });
|
|
// }
|
|
// _context.Documents.AddRange(documents);
|
|
// _context.TalkingPointAttachments.AddRange(attachments);
|
|
// }
|
|
|
|
// var response = _mapper.Map<TalkingPointVM>(talkingPoint);
|
|
// response.ServiceProject = _mapper.Map<BasicServiceProjectVM>(serviceProject);
|
|
// response.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
|
|
|
|
// if (documents.Any())
|
|
// {
|
|
// response.Attachments = documents.Select(d =>
|
|
// {
|
|
// var result = _mapper.Map<DocumentVM>(d);
|
|
|
|
// var preSignedUrl = _s3Service.GeneratePreSignedUrl(d.S3Key);
|
|
// var thumbPreSignedUrl = !string.IsNullOrWhiteSpace(d.ThumbS3Key)
|
|
// ? _s3Service.GeneratePreSignedUrl(d.ThumbS3Key)
|
|
// : preSignedUrl;
|
|
// result.PreSignedUrl = preSignedUrl;
|
|
// result.ThumbPreSignedUrl = thumbPreSignedUrl;
|
|
|
|
// return result;
|
|
// }).ToList();
|
|
// }
|
|
|
|
// return ApiResponse<object>.SuccessResponse(response, "Talking point added to service project", 201);
|
|
//}
|
|
//public async Task<ApiResponse<object>> UpdateTalkingPointAsync(Guid id, TalkingPointDto model, Employee loggedInEmployee, Guid tenantId)
|
|
//{
|
|
// // Transaction ensures atomic update of comment and attachments.
|
|
// await using var transaction = await _context.Database.BeginTransactionAsync();
|
|
|
|
// if (!model.Id.HasValue || id != model.Id)
|
|
// {
|
|
// return ApiResponse<object>.ErrorResponse("The Id in the path does not match the Id in the request body.", "The Id in the path does not match the Id in the request body.", 400);
|
|
// }
|
|
// var serviceProject = await _context.ServiceProjects.AsNoTracking().FirstOrDefaultAsync(sp => sp.Id == model.ServiceProjectId && sp.TenantId == tenantId);
|
|
// if (serviceProject == null)
|
|
// {
|
|
// return ApiResponse<object>.ErrorResponse("Service project not found", "Service project not found", 404);
|
|
// }
|
|
|
|
// var talkingPoint = await _context.TalkingPoints.AsNoTracking().FirstOrDefaultAsync(tp => tp.Id == model.Id && tp.TenantId == tenantId);
|
|
// if (talkingPoint == null)
|
|
// {
|
|
// return ApiResponse<object>.ErrorResponse("Talking point not found", "Talking point not found", 404);
|
|
// }
|
|
// _mapper.Map(model, talkingPoint);
|
|
|
|
// _context.TalkingPoints.Update(talkingPoint);
|
|
|
|
// // 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<TalkingPointAttachment>();
|
|
|
|
// 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/{serviceProject.Id}/TalkingPoint/{talkingPoint.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);
|
|
|
|
// // Link document as attachment to the comment
|
|
// attachments.Add(new TalkingPointAttachment
|
|
// {
|
|
// Id = Guid.NewGuid(),
|
|
// DocumentId = document.Id,
|
|
// TalkingPointId = talkingPoint.Id,
|
|
// TenantId = tenantId
|
|
// });
|
|
// }
|
|
// _context.Documents.AddRange(documents);
|
|
// _context.TalkingPointAttachments.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 DeleteTalkingPointAttachments(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);
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
// var talkingPointTask = Task.Run(async () =>
|
|
// {
|
|
// await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
// return context.TalkingPoints
|
|
// .Include(tp => tp.ServiceProject)
|
|
// .Include(tp => tp.CreatedBy).ThenInclude(e => e!.JobRole)
|
|
// .Include(tp => tp.UpdatedBy).ThenInclude(e => e!.JobRole)
|
|
// .AsNoTracking()
|
|
// .FirstOrDefaultAsync(tp => tp.Id == id && tp.TenantId == tenantId && tp.IsActive);
|
|
// });
|
|
|
|
// var attachmentTask = Task.Run(async () =>
|
|
// {
|
|
// await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
// return context.TalkingPointAttachments
|
|
// .Include(tpa => tpa.Document)
|
|
// });
|
|
|
|
// return ApiResponse<object>.SuccessResponse(new { }, "Talking point updated successfully", 200);
|
|
//}
|
|
//#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);
|
|
|
|
var assignees = assigneeTask.Result;
|
|
var isAssigned = assignees.Any(e => e.Id == loggedInEmployee.Id);
|
|
|
|
// 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>>(assignees);
|
|
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;
|
|
|
|
if (isAssigned)
|
|
{
|
|
// Fetch the most recent attendance record for the logged-in employee for the specified job
|
|
var jobAttendance = await _context.JobAttendance
|
|
.AsNoTracking()
|
|
.Where(ja => ja.JobTcketId == jobTicket.Id && ja.EmployeeId == loggedInEmployee.Id && ja.TenantId == tenantId)
|
|
.OrderByDescending(ja => ja.TaggedInTime)
|
|
.FirstOrDefaultAsync();
|
|
|
|
|
|
// If no attendance record exists or last record is tagged out or for a different day, prepare a default response with next action TAG_IN
|
|
if (jobAttendance == null || (jobAttendance.TaggedOutTime.HasValue && jobAttendance.TaggedInTime.Date != DateTime.UtcNow.Date))
|
|
{
|
|
response.AttendanceId = null;
|
|
response.NextTaggingAction = TAGGING_MARK_TYPE.TAG_IN;
|
|
_logger.LogInfo("No current active attendance found for EmployeeId: {EmployeeId}. Prompting to TAG_IN.", loggedInEmployee.Id);
|
|
}
|
|
else
|
|
{
|
|
// Active attendance exists
|
|
response.AttendanceId = jobAttendance.Id;
|
|
response.TaggingAction = jobAttendance.Action;
|
|
response.NextTaggingAction = jobAttendance.Action == TAGGING_MARK_TYPE.TAG_IN ? TAGGING_MARK_TYPE.TAG_OUT : TAGGING_MARK_TYPE.TAG_IN;
|
|
_logger.LogInfo("Latest attendance fetched for EmployeeId: {EmployeeId} on JobTicketId: {JobTicketId}", loggedInEmployee.Id, jobTicket.Id);
|
|
}
|
|
}
|
|
|
|
_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;
|
|
|
|
string uIDPrefix = $"JT/{DateTime.Now:MMyy}";
|
|
|
|
// Generate unique UID postfix based on existing requests for the current prefix
|
|
var lastPR = await _context.JobTickets.Where(pr => pr.UIDPrefix == uIDPrefix)
|
|
.OrderByDescending(pr => pr.UIDPostfix)
|
|
.FirstOrDefaultAsync();
|
|
int uIDPostfix = lastPR == null ? 1 : (lastPR.UIDPostfix + 1);
|
|
|
|
// Map DTO to entity
|
|
var jobTicket = _mapper.Map<JobTicket>(model);
|
|
jobTicket.Id = Guid.NewGuid();
|
|
jobTicket.StatusId = hasAssignees ? AssignedStatus : NewStatus;
|
|
jobTicket.UIDPrefix = uIDPrefix;
|
|
jobTicket.UIDPostfix = uIDPostfix;
|
|
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.JobTicketId == jobTicket.Id && 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 DeleteJobAttachemnts(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 =================================================================== Job Tagging Functions ===================================================================
|
|
public async Task<ApiResponse<object>> GetAttendanceForSelfAsync(Guid jobTicketId, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
_logger.LogInfo("GetAttendanceForSelfAsync initiated for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, jobTicketId);
|
|
|
|
try
|
|
{
|
|
// Validate existence of the Job Ticket with related Status
|
|
var jobTicket = await _context.JobTickets
|
|
.AsNoTracking()
|
|
.Include(jt => jt.Status)
|
|
.FirstOrDefaultAsync(jt => jt.Id == jobTicketId && jt.TenantId == tenantId && jt.IsActive);
|
|
|
|
if (jobTicket == null)
|
|
{
|
|
_logger.LogWarning("JobTicket not found. JobTicketId: {JobTicketId}, TenantId: {TenantId}", jobTicketId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("Job not found", "Job is not found", 404);
|
|
}
|
|
|
|
var jobEmployeeMapping = await _context.JobEmployeeMappings
|
|
.Where(jem => jem.AssigneeId == loggedInEmployee.Id && jem.JobTicketId == jobTicketId && jem.TenantId == tenantId)
|
|
.FirstOrDefaultAsync();
|
|
|
|
// Check if the job-to-employee mapping is null, indicating no assignment
|
|
if (jobEmployeeMapping == null)
|
|
{
|
|
// Log the error with relevant context for diagnostics
|
|
_logger.LogWarning("Tagging failed: Employee is not assigned to the job. JobId: {JobId}, EmployeeId: {EmployeeId}", jobTicketId, loggedInEmployee.Id);
|
|
|
|
// Return a structured error response with meaningful message and HTTP 400 Bad Request code
|
|
return ApiResponse<object>.ErrorResponse(
|
|
"Tagging operation failed because the employee is not assigned to this job.",
|
|
$"No job-employee mapping found for JobId: {jobTicketId} and EmployeeId: {loggedInEmployee.Id}.",
|
|
statusCode: 400);
|
|
}
|
|
|
|
|
|
// Fetch the most recent attendance record for the logged-in employee for the specified job
|
|
var jobAttendance = await _context.JobAttendance
|
|
.AsNoTracking()
|
|
.Include(ja => ja.JobTicket).ThenInclude(jt => jt!.Status)
|
|
.Include(ja => ja.Employee).ThenInclude(e => e!.JobRole)
|
|
.Where(ja => ja.JobTcketId == jobTicketId && ja.EmployeeId == loggedInEmployee.Id && ja.TenantId == tenantId)
|
|
.OrderByDescending(ja => ja.TaggedInTime)
|
|
.FirstOrDefaultAsync();
|
|
|
|
JobAttendanceVM response;
|
|
|
|
// If no attendance record exists or last record is tagged out or for a different day, prepare a default response with next action TAG_IN
|
|
if (jobAttendance == null || (jobAttendance.TaggedOutTime.HasValue && jobAttendance.TaggedInTime.Date != DateTime.UtcNow.Date))
|
|
{
|
|
response = new JobAttendanceVM
|
|
{
|
|
JobTicket = _mapper.Map<BasicJobTicketVM>(jobTicket),
|
|
Employee = _mapper.Map<BasicEmployeeVM>(loggedInEmployee),
|
|
NextAction = TAGGING_MARK_TYPE.TAG_IN
|
|
};
|
|
_logger.LogInfo("No current active attendance found for EmployeeId: {EmployeeId}. Prompting to TAG_IN.", loggedInEmployee.Id);
|
|
}
|
|
else
|
|
{
|
|
// Active attendance exists, returning last attendance with details
|
|
response = _mapper.Map<JobAttendanceVM>(jobAttendance);
|
|
_logger.LogInfo("Latest attendance fetched for EmployeeId: {EmployeeId} on JobTicketId: {JobTicketId}", loggedInEmployee.Id, jobTicketId);
|
|
}
|
|
|
|
// Return success with the constructed response
|
|
return ApiResponse<object>.SuccessResponse(response, "Latest job tagging for current employee fetched successfully", 200);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Unhandled exception in GetAttendanceForSelfAsync for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, jobTicketId);
|
|
return ApiResponse<object>.ErrorResponse("An unexpected error occurred.", ex.Message, 500);
|
|
}
|
|
}
|
|
public async Task<ApiResponse<object>> GetAttendanceLogForAttendanceAsync(Guid jobAttendanceId, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
_logger.LogInfo("GetAttendanceLogForAttendanceAsync called for JobAttendanceId: {JobAttendanceId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", jobAttendanceId, tenantId, loggedInEmployee.Id);
|
|
|
|
try
|
|
{
|
|
// Validate existence of the JobAttendance record for the tenant
|
|
var jobAttendance = await _context.JobAttendance
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(ja => ja.Id == jobAttendanceId && ja.TenantId == tenantId);
|
|
|
|
if (jobAttendance == null)
|
|
{
|
|
_logger.LogWarning("JobAttendance not found. JobAttendanceId: {JobAttendanceId}, TenantId: {TenantId}", jobAttendanceId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("Job attendance not found", "Job attendance not found", 404);
|
|
}
|
|
|
|
// Fetch related attendance logs including JobTicket status and Employee role details
|
|
var attendanceLogs = await _context.JobAttendanceLogs
|
|
.AsNoTracking()
|
|
.Include(jal => jal.JobTicket).ThenInclude(jt => jt!.Status)
|
|
.Include(jal => jal.Employee).ThenInclude(e => e!.JobRole)
|
|
.Where(jal => jal.JobAttendanceId == jobAttendanceId && jal.TenantId == tenantId)
|
|
.ToListAsync();
|
|
|
|
// If no logs found, return empty list with success message
|
|
if (!attendanceLogs.Any())
|
|
{
|
|
_logger.LogInfo("No attendance logs found for JobAttendanceId: {JobAttendanceId}", jobAttendanceId);
|
|
return ApiResponse<object>.SuccessResponse(new List<AttendanceLogVM>(), "Job attendance log fetched successfully", 200);
|
|
}
|
|
|
|
// Extract document IDs from logs that have attached documents
|
|
var documentIds = attendanceLogs.Where(log => log.DocumentId.HasValue)
|
|
.Select(log => log.DocumentId!.Value)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
// Fetch documents related to the extracted document IDs and tenant
|
|
var documents = await _context.Documents
|
|
.AsNoTracking()
|
|
.Where(d => documentIds.Contains(d.Id) && d.TenantId == tenantId)
|
|
.ToListAsync();
|
|
|
|
// Map each attendance log and enrich with document info including pre-signed URLs
|
|
var response = attendanceLogs.Select(log =>
|
|
{
|
|
var logVm = _mapper.Map<JobAttendanceLogVM>(log);
|
|
|
|
if (log.DocumentId.HasValue)
|
|
{
|
|
var document = documents.FirstOrDefault(d => d.Id == log.DocumentId.Value);
|
|
if (document != null)
|
|
{
|
|
var preSignedUrl = _s3Service.GeneratePreSignedUrl(document.S3Key);
|
|
var thumbPreSignedUrl = !string.IsNullOrWhiteSpace(document.ThumbS3Key)
|
|
? _s3Service.GeneratePreSignedUrl(document.ThumbS3Key)
|
|
: preSignedUrl;
|
|
|
|
logVm.Document = _mapper.Map<BasicDocumentVM>(document);
|
|
logVm.Document.PreSignedUrl = preSignedUrl;
|
|
logVm.Document.ThumbPreSignedUrl = thumbPreSignedUrl;
|
|
}
|
|
}
|
|
|
|
return logVm;
|
|
}).ToList();
|
|
|
|
_logger.LogInfo("Job attendance log fetched successfully. JobAttendanceId: {JobAttendanceId}, RecordsCount: {Count}", jobAttendanceId, response.Count);
|
|
|
|
return ApiResponse<object>.SuccessResponse(response, "Job attendance log fetched successfully", 200);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Unhandled exception in GetAttendanceLogForAttendanceAsync for JobAttendanceId: {JobAttendanceId}, TenantId: {TenantId}", jobAttendanceId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("An unexpected error occurred.", ex.Message, 500);
|
|
}
|
|
}
|
|
public async Task<ApiResponse<object>> GetAttendanceForJobTeamAsync(Guid jobTicketId, DateTime? startDate, DateTime? endDate, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
_logger.LogInfo("GetAttendanceForJobTeamAsync called for JobTicketId: {JobTicketId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}", jobTicketId, tenantId, loggedInEmployee.Id);
|
|
|
|
try
|
|
{
|
|
// Validate the existence and active status of the job ticket including its status related data
|
|
var jobTicket = await _context.JobTickets
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(jt => jt.Id == jobTicketId && jt.TenantId == tenantId && jt.IsActive);
|
|
|
|
if (jobTicket == null)
|
|
{
|
|
_logger.LogWarning("JobTicket not found. JobTicketId: {JobTicketId}, TenantId: {TenantId}", jobTicketId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("Job is not found", "Job is not found", 404);
|
|
}
|
|
|
|
// Define date range for attendance query: default from today to next 7 days if not provided
|
|
DateTime fromDate = startDate?.Date ?? DateTime.UtcNow.Date;
|
|
DateTime toDate = endDate?.Date ?? fromDate.AddDays(7);
|
|
|
|
// Fetch attendance records within the date range for the specified job ticket and tenant
|
|
var attendances = await _context.JobAttendance
|
|
.AsNoTracking()
|
|
.Include(ja => ja.JobTicket).ThenInclude(jt => jt!.Status)
|
|
.Include(ja => ja.Employee).ThenInclude(e => e!.JobRole)
|
|
.Where(ja => ja.JobTcketId == jobTicketId
|
|
&& ja.TaggedInTime.Date >= fromDate
|
|
&& ja.TaggedInTime.Date <= toDate
|
|
&& ja.TenantId == tenantId)
|
|
.ToListAsync();
|
|
|
|
// Map attendance entities to view models
|
|
var response = attendances.Select(ja =>
|
|
{
|
|
var result = _mapper.Map<JobAttendanceVM>(ja);
|
|
|
|
// Determine if current attendance record is not the latest, if so clear NextAction
|
|
var isNotLast = attendances.Any(attendance => attendance.TaggedInTime.Date > ja.TaggedInTime.Date);
|
|
if (isNotLast || (ja.TaggedOutTime.HasValue && ja.TaggedInTime.Date != DateTime.UtcNow.Date) || ja.EmployeeId != loggedInEmployee.Id)
|
|
{
|
|
result.NextAction = null;
|
|
}
|
|
|
|
return result;
|
|
}).ToList();
|
|
|
|
_logger.LogInfo("Attendance for job team fetched successfully. JobTicketId: {JobTicketId}, RecordsCount: {Count}", jobTicketId, response.Count);
|
|
|
|
return ApiResponse<object>.SuccessResponse(response, "Attendance for job team fetched successfully", 200);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error occurred in GetAttendanceForJobTeamAsync for JobTicketId: {JobTicketId}, TenantId: {TenantId}", jobTicketId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("An unexpected error occurred.", ex.Message, 500);
|
|
}
|
|
}
|
|
public async Task<ApiResponse<object>> ManageJobTaggingAsync(JobAttendanceDto model, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
_logger.LogInfo("ManageJobTaggingAsync called for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
|
|
|
try
|
|
{
|
|
// Validate the job ticket existence and status
|
|
var jobTicket = await _context.JobTickets
|
|
.AsNoTracking()
|
|
.Include(jt => jt.Status)
|
|
.FirstOrDefaultAsync(jt => jt.Id == model.JobTcketId && jt.TenantId == tenantId && jt.IsActive);
|
|
if (jobTicket == null)
|
|
{
|
|
_logger.LogWarning("JobTicket not found. JobTicketId: {JobTicketId}, TenantId: {TenantId}", model.JobTcketId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("Job not found", "Job not found", 404);
|
|
}
|
|
|
|
// Check if the current user is part of the job team
|
|
var jobEmployeeMapping = await _context.JobEmployeeMappings
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(jem => jem.JobTicketId == model.JobTcketId && jem.AssigneeId == loggedInEmployee.Id && jem.TenantId == tenantId);
|
|
if (jobEmployeeMapping == null)
|
|
{
|
|
_logger.LogWarning("User is not part of job team. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
|
return ApiResponse<object>.ErrorResponse("User is not part of job team", "User is not part of job team", 400);
|
|
}
|
|
|
|
// Get the last attendance record for the user and job
|
|
var jobAttendance = await _context.JobAttendance
|
|
.AsNoTracking()
|
|
.Where(ja => ja.EmployeeId == loggedInEmployee.Id && ja.JobTcketId == model.JobTcketId)
|
|
.OrderByDescending(ja => ja.TaggedInAt)
|
|
.FirstOrDefaultAsync();
|
|
|
|
JobAttendance updateJobAttendance;
|
|
DateTime markedAt = model.MarkedAt ?? DateTime.UtcNow;
|
|
DateTime currentTime = DateTime.UtcNow;
|
|
|
|
// Handle TAG_IN action
|
|
if (model.Action == TAGGING_MARK_TYPE.TAG_IN)
|
|
{
|
|
var isLastTaggedOut = jobAttendance != null && jobAttendance.Action == TAGGING_MARK_TYPE.TAG_OUT && jobAttendance.TaggedOutTime != null;
|
|
|
|
if (jobAttendance == null || (isLastTaggedOut && jobAttendance.TaggedInTime.Date != currentTime.Date))
|
|
{
|
|
// Create new JobAttendance record for Tag In
|
|
var newJobAttendance = new JobAttendance
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
JobTcketId = model.JobTcketId,
|
|
EmployeeId = loggedInEmployee.Id,
|
|
Action = TAGGING_MARK_TYPE.TAG_IN,
|
|
TaggedInTime = markedAt,
|
|
TaggedInAt = currentTime,
|
|
TenantId = tenantId
|
|
};
|
|
_context.JobAttendance.Add(newJobAttendance);
|
|
updateJobAttendance = newJobAttendance;
|
|
_logger.LogInfo("New Tag In created for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
|
}
|
|
else if (isLastTaggedOut && jobAttendance.TaggedInTime.Date == currentTime.Date)
|
|
{
|
|
// Update the existing last JobAttendance to Tag In
|
|
jobAttendance.Action = TAGGING_MARK_TYPE.TAG_IN;
|
|
jobAttendance.TaggedInTime = markedAt;
|
|
jobAttendance.TaggedInAt = currentTime;
|
|
jobAttendance.TaggedOutTime = null;
|
|
jobAttendance.TaggedOutAt = null;
|
|
|
|
_context.JobAttendance.Update(jobAttendance);
|
|
updateJobAttendance = jobAttendance;
|
|
_logger.LogInfo("Existing JobAttendance updated to Tag In for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Attempted to Tag In without tagging out last session. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
|
return ApiResponse<object>.ErrorResponse("First, mark the last tag as out before tagging in.", "First, mark the last tag as out before tagging in.", 400);
|
|
}
|
|
}
|
|
// Handle TAG_OUT action
|
|
else if (model.Action == TAGGING_MARK_TYPE.TAG_OUT)
|
|
{
|
|
if (jobAttendance != null && jobAttendance.Action == TAGGING_MARK_TYPE.TAG_IN && jobAttendance.TaggedOutTime == null)
|
|
{
|
|
jobAttendance.Action = TAGGING_MARK_TYPE.TAG_OUT;
|
|
jobAttendance.TaggedOutTime = markedAt;
|
|
jobAttendance.TaggedOutAt = currentTime;
|
|
|
|
_context.JobAttendance.Update(jobAttendance);
|
|
updateJobAttendance = jobAttendance;
|
|
_logger.LogInfo("JobAttendance updated to Tag Out for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Attempted to Tag Out without previous Tag In. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
|
return ApiResponse<object>.ErrorResponse("First, mark the last tag as in before tagging out.", "First, mark the last tag as in before tagging out.", 400);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Invalid action provided: {Action}. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", model.Action, loggedInEmployee.Id, model.JobTcketId);
|
|
return ApiResponse<object>.ErrorResponse("Provided invalid action", "Provided invalid action", 400);
|
|
}
|
|
|
|
Document? document = null;
|
|
|
|
// Handle attachment upload if present
|
|
if (model.Attachment != null && model.Attachment.ContentType != null)
|
|
{
|
|
string base64 = model.Attachment.Base64Data?.Split(',').LastOrDefault() ?? "";
|
|
if (string.IsNullOrWhiteSpace(base64))
|
|
{
|
|
_logger.LogWarning("Base64 data missing in attachment. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
|
return ApiResponse<object>.ErrorResponse("Base64 data is missing", "Attachment data missing", 400);
|
|
}
|
|
|
|
var fileType = _s3Service.GetContentTypeFromBase64(base64);
|
|
var fileName = _s3Service.GenerateFileName(fileType, tenantId, "job_attendance");
|
|
var objectKey = $"tenant-{tenantId}/ServiceProject/{jobTicket.ProjectId}/Job/{jobTicket.Id}/Attendance/{updateJobAttendance.Id}/{fileName}";
|
|
|
|
await _s3Service.UploadFileAsync(base64, fileType, objectKey);
|
|
|
|
document = new Document
|
|
{
|
|
BatchId = Guid.NewGuid(),
|
|
UploadedById = loggedInEmployee.Id,
|
|
FileName = model.Attachment.FileName ?? "",
|
|
ContentType = model.Attachment.ContentType,
|
|
S3Key = objectKey,
|
|
FileSize = model.Attachment.FileSize,
|
|
UploadedAt = currentTime,
|
|
TenantId = tenantId
|
|
};
|
|
|
|
_context.Documents.Add(document);
|
|
_logger.LogInfo("Attachment uploaded and document record created. EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}, DocumentId: {DocumentId}", loggedInEmployee.Id, model.JobTcketId, document.Id);
|
|
}
|
|
|
|
// Create attendance log entry for audit trail
|
|
var attendanceLog = new JobAttendanceLog
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
EmployeeId = updateJobAttendance.EmployeeId,
|
|
Action = updateJobAttendance.Action,
|
|
MarkedTIme = markedAt,
|
|
MarkedAt = currentTime,
|
|
Latitude = model.Latitude,
|
|
Longitude = model.Longitude,
|
|
Comment = model.Comment,
|
|
JobAttendanceId = updateJobAttendance.Id,
|
|
JobTcketId = model.JobTcketId,
|
|
DocumentId = document?.Id,
|
|
TenantId = tenantId
|
|
};
|
|
|
|
_context.JobAttendanceLogs.Add(attendanceLog);
|
|
|
|
// Persist all changes in one save operation
|
|
await _context.SaveChangesAsync();
|
|
|
|
var response = _mapper.Map<JobAttendanceVM>(updateJobAttendance);
|
|
response.JobTicket = _mapper.Map<BasicJobTicketVM>(jobTicket);
|
|
response.Employee = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
|
|
|
|
_logger.LogInfo("Tagging managed successfully for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
|
|
|
return ApiResponse<object>.SuccessResponse(response, "Tagging managed successfully", 200);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error occurred in ManageJobTaggingAsync for EmployeeId: {EmployeeId}, JobTicketId: {JobTicketId}", loggedInEmployee.Id, model.JobTcketId);
|
|
return ApiResponse<object>.ErrorResponse("An unexpected error occurred.", ex.Message, 500);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region =================================================================== Helper Functions ===================================================================
|
|
|
|
//private async Task DeleteTalkingPointAttachments(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.TalkingPointAttachments.AsNoTracking().Where(ba => documentIds.Contains(ba.DocumentId)).ToListAsync();
|
|
|
|
// dbContext.TalkingPointAttachments.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);
|
|
//}
|
|
|
|
private async Task DeleteJobAttachemnts(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
|
|
}
|
|
}
|