3400 lines
184 KiB
C#
3400 lines
184 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 UtilityMongoDBHelper _updateLogHelper;
|
|
|
|
|
|
private readonly Guid NewStatus = Guid.Parse("32d76a02-8f44-4aa0-9b66-c3716c45a918");
|
|
private readonly Guid AssignedStatus = Guid.Parse("cfa1886d-055f-4ded-84c6-42a2a8a14a66");
|
|
private readonly Guid InProgressStatus = Guid.Parse("5a6873a5-fed7-4745-a52f-8f61bf3bd72d");
|
|
private readonly Guid WorkDoneStatus = Guid.Parse("aab71020-2fb8-44d9-9430-c9a7e9bf33b0");
|
|
private readonly Guid ReviewDoneStatus = Guid.Parse("ed10ab57-dbaa-4ca5-8ecd-56745dcbdbd7");
|
|
private readonly Guid ClosedStatus = Guid.Parse("3ddeefb5-ae3c-4e10-a922-35e0a452bb69");
|
|
private readonly Guid OnHoldStatus = Guid.Parse("75a0c8b8-9c6a-41af-80bf-b35bab722eb2");
|
|
|
|
public ServiceProjectService(IDbContextFactory<ApplicationDbContext> dbContextFactory,
|
|
IServiceScopeFactory serviceScopeFactory,
|
|
ApplicationDbContext context,
|
|
ILoggingService logger,
|
|
S3UploadService s3Service,
|
|
IMapper mapper,
|
|
UtilityMongoDBHelper updateLogHelper)
|
|
{
|
|
_serviceScopeFactory = serviceScopeFactory;
|
|
_context = context;
|
|
_logger = logger;
|
|
_s3Service = s3Service;
|
|
_mapper = mapper;
|
|
_dbContextFactory = dbContextFactory;
|
|
_updateLogHelper = updateLogHelper;
|
|
}
|
|
|
|
#region =================================================================== Service Project Functions ===================================================================
|
|
|
|
/// <summary>
|
|
/// Retrieves a paginated list of active service projects for a tenant, including related services, job counts, and team member information.
|
|
/// </summary>
|
|
/// <param name="searchString">Optional search string to filter projects by name.</param>
|
|
/// <param name="pageNumber">The page number starting from 1.</param>
|
|
/// <param name="pageSize">The number of items per page.</param>
|
|
/// <param name="loggedInEmployee">Currently authenticated employee making the request.</param>
|
|
/// <param name="tenantId">Tenant unique identifier for multi-tenant data isolation.</param>
|
|
/// <returns>Returns an ApiResponse containing paginated projects data or error details.</returns>
|
|
public async Task<ApiResponse<object>> GetServiceProjectListAsync(string? searchString, int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("Invalid tenant context in GetServiceProjectListAsync invoked by EmployeeId {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 service projects with tenant isolation and necessary eager loading.
|
|
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);
|
|
|
|
// Apply search filter if provided (case-insensitive)
|
|
if (!string.IsNullOrWhiteSpace(searchString))
|
|
{
|
|
var normalizedSearch = searchString.Trim().ToLowerInvariant();
|
|
serviceProjectQuery = serviceProjectQuery
|
|
.Where(sp => sp.Name.ToLower().Contains(normalizedSearch) ||
|
|
(!string.IsNullOrWhiteSpace(sp.ShortName) && sp.ShortName.ToLower().Contains(normalizedSearch)));
|
|
}
|
|
|
|
// Calculate total count and pages for pagination metadata
|
|
var totalEntities = await serviceProjectQuery.CountAsync();
|
|
var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize);
|
|
|
|
// Fetch projects for the requested page with ordering by newest
|
|
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 services in a single query to prevent N+1 issue
|
|
var serviceProjectServiceMappings = await _context.ServiceProjectServiceMapping
|
|
.Include(sps => sps.Service)
|
|
.Where(sps => serviceProjectIds.Contains(sps.ProjectId) && sps.Service != null && sps.TenantId == tenantId)
|
|
.ToListAsync();
|
|
|
|
// Execute related aggregate counts in parallel with separate contexts and async queries
|
|
var jobTask = Task.Run(async () =>
|
|
{
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
return await context.JobTickets
|
|
.Where(jt => serviceProjectIds.Contains(jt.ProjectId) && jt.TenantId == tenantId && jt.IsActive)
|
|
.GroupBy(jt => jt.ProjectId)
|
|
.Select(g => new
|
|
{
|
|
ProjectId = g.Key,
|
|
JobsPassedDueDateCount = g.Count(jt => jt.StatusId != ReviewDoneStatus && jt.StatusId != ClosedStatus && jt.DueDate.Date < DateTime.UtcNow.Date),
|
|
ActiveJobsCount = g.Count(jt => jt.StatusId != ReviewDoneStatus && jt.StatusId != ClosedStatus && jt.StatusId != OnHoldStatus),
|
|
AssignedJobsCount = g.Count(jt => jt.StatusId == AssignedStatus),
|
|
OnHoldJobsCount = g.Count(jt => jt.StatusId == OnHoldStatus)
|
|
})
|
|
.ToListAsync();
|
|
});
|
|
|
|
var teamMemberTask = Task.Run(async () =>
|
|
{
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
return await context.ServiceProjectAllocations
|
|
.Where(spa => serviceProjectIds.Contains(spa.ProjectId) && spa.TenantId == tenantId && spa.IsActive)
|
|
.GroupBy(spa => spa.ProjectId)
|
|
.Select(g => new
|
|
{
|
|
ProjectId = g.Key,
|
|
TeamMemberCount = g.Select(spa => spa.EmployeeId).Distinct().Count()
|
|
})
|
|
.ToListAsync();
|
|
});
|
|
|
|
await Task.WhenAll(jobTask, teamMemberTask);
|
|
|
|
var jobTickets = jobTask.Result;
|
|
var teamMembers = teamMemberTask.Result;
|
|
|
|
// Map the service projects into view models including related data
|
|
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);
|
|
projectVm.TeamMemberCount = teamMembers.FirstOrDefault(tm => tm.ProjectId == sp.Id)?.TeamMemberCount ?? 0;
|
|
projectVm.ActiveJobsCount = jobTickets.FirstOrDefault(jt => jt.ProjectId == sp.Id)?.ActiveJobsCount ?? 0;
|
|
projectVm.JobsPassedDueDateCount = jobTickets.FirstOrDefault(jt => jt.ProjectId == sp.Id)?.JobsPassedDueDateCount ?? 0;
|
|
projectVm.AssignedJobsCount = jobTickets.FirstOrDefault(jt => jt.ProjectId == sp.Id)?.AssignedJobsCount ?? 0;
|
|
projectVm.OnHoldJobsCount = jobTickets.FirstOrDefault(jt => jt.ProjectId == sp.Id)?.OnHoldJobsCount ?? 0;
|
|
return projectVm;
|
|
}).ToList();
|
|
|
|
var response = new
|
|
{
|
|
CurrentPage = pageNumber,
|
|
TotalPages = totalPages,
|
|
TotalEntities = totalEntities,
|
|
Data = serviceProjectVMs
|
|
};
|
|
|
|
_logger.LogInfo("Returned {Count} service projects for tenant {TenantId} requested by EmployeeId {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, "Error in GetServiceProjectListAsync for tenant {TenantId} invoked by EmployeeId {EmployeeId}",
|
|
tenantId, loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse(
|
|
"Internal Server Error",
|
|
"An unexpected error occurred while retrieving projects. Please try again later.",
|
|
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)
|
|
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)
|
|
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 =================================================================== Project Branch Functions ===================================================================
|
|
|
|
/// <summary>
|
|
/// Retrieves a paginated list of project branches filtered by activity status and optional search criteria.
|
|
/// Implements enterprise-grade optimizations, detailed logging, and standardized error handling.
|
|
/// </summary>
|
|
/// <param name="projectId">Unique identifier for the project.</param>
|
|
/// <param name="isActive">Filter by active/inactive branches.</param>
|
|
/// <param name="searchString">Optional search string for filtering by branch name, address, or type.</param>
|
|
/// <param name="pageNumber">Current page number for pagination.</param>
|
|
/// <param name="pageSize">Number of records per page.</param>
|
|
/// <param name="loggedInEmployee">Current logged-in employee details.</param>
|
|
/// <param name="tenantId">Tenant identifier for multi-tenant architecture.</param>
|
|
/// <returns>ApiResponse containing paginated branches or error details.</returns>
|
|
public async Task<ApiResponse<object>> GetProjectBranchListByProjectAsync(Guid projectId, bool isActive, string? searchString, int pageNumber, int pageSize,
|
|
Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("GetProjectBranchListByProjectAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
|
|
}
|
|
// Log method invocation with parameters for audit and debugging
|
|
_logger.LogInfo("Fetching project branches for ProjectId: {ProjectId}, IsActive: {IsActive}, Page: {PageNumber}, Size: {PageSize}", projectId, isActive, pageNumber, pageSize);
|
|
|
|
try
|
|
{
|
|
// Check if the service project exists
|
|
var serviceProject = await _context.ServiceProjects
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(sp => sp.Id == projectId && sp.TenantId == tenantId);
|
|
if (serviceProject == null)
|
|
{
|
|
_logger.LogWarning("Service project not found for ProjectId: {ProjectId}", projectId);
|
|
return ApiResponse<object>.ErrorResponse("Service project not found", "Service project not found", 404);
|
|
}
|
|
|
|
// Build base query with necessary includes and filters
|
|
var branchQuery = _context.ProjectBranches
|
|
.Include(pb => pb.Project).ThenInclude(sp => sp!.Status)
|
|
.Include(pb => pb.CreatedBy).ThenInclude(e => e!.JobRole)
|
|
.AsNoTracking()
|
|
.Where(pb => pb.ProjectId == projectId && pb.TenantId == tenantId && pb.IsActive == isActive);
|
|
|
|
// Apply search filtering if search string is provided
|
|
if (!string.IsNullOrWhiteSpace(searchString))
|
|
{
|
|
var normalized = searchString.Trim().ToLowerInvariant();
|
|
branchQuery = branchQuery.Where(pb =>
|
|
pb.BranchName.ToLower().Contains(normalized) ||
|
|
pb.Address.ToLower().Contains(normalized) ||
|
|
pb.BranchType.ToLower().Contains(normalized));
|
|
}
|
|
|
|
// Count total records for pagination metadata
|
|
var totalEntities = await branchQuery.CountAsync();
|
|
var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize);
|
|
|
|
// Fetch paginated data sorted by name descending
|
|
var branches = await branchQuery
|
|
.OrderByDescending(pb => pb.BranchName)
|
|
.Skip((pageNumber - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.ToListAsync();
|
|
|
|
// Map entities to view models
|
|
var projectBranchVMs = _mapper.Map<List<ProjectBranchVM>>(branches);
|
|
|
|
// Prepare response with pagination metadata
|
|
var response = new
|
|
{
|
|
CurrentPage = pageNumber,
|
|
TotalPages = totalPages,
|
|
TotalEntities = totalEntities,
|
|
Data = projectBranchVMs
|
|
};
|
|
|
|
// Log successful fetch
|
|
_logger.LogInfo("Fetched {Count} branches for Project: {ProjectName}", projectBranchVMs.Count, serviceProject.Name);
|
|
return ApiResponse<object>.SuccessResponse(response, $"{projectBranchVMs.Count} branches of project {serviceProject.Name} fetched successfully");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Log exception details
|
|
_logger.LogError(ex, "Error occurred while fetching project branches for ProjectId: {ProjectId}", projectId);
|
|
// Return standardized problem details response
|
|
return ApiResponse<object>.ErrorResponse("An unexpected error occurred.", ex.Message, 500);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves detailed information for a single project branch by ID, including related project and employee metadata.
|
|
/// Provides enterprise-grade optimization, structured error handling, and detailed logging.
|
|
/// </summary>
|
|
/// <param name="id">Unique identifier of the project branch.</param>
|
|
/// <param name="loggedInEmployee">Information about the currently logged-in employee (for auditing/security).</param>
|
|
/// <param name="tenantId">The current tenant's unique identifier (multi-tenancy support).</param>
|
|
/// <returns>ApiResponse with the branch details or a standardized error.</returns>
|
|
public async Task<ApiResponse<object>> GetProjectBranchDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("GetProjectBranchDetailsAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
|
|
}
|
|
_logger.LogInfo("Attempting to fetch details for ProjectBranchId: {ProjectBranchId}, TenantId: {TenantId} by EmployeeId: {EmployeeId}",
|
|
id, tenantId, loggedInEmployee.Id);
|
|
|
|
try
|
|
{
|
|
// Query the branch with required related entities; .AsNoTracking improves read speed/performance for lookups.
|
|
var projectBranch = await _context.ProjectBranches
|
|
.AsNoTracking()
|
|
.Include(pb => pb.Project).ThenInclude(sp => sp!.Status)
|
|
.Include(pb => pb.CreatedBy).ThenInclude(e => e!.JobRole)
|
|
.Include(pb => pb.UpdatedBy).ThenInclude(e => e!.JobRole)
|
|
.FirstOrDefaultAsync(pb => pb.Id == id && pb.TenantId == tenantId);
|
|
|
|
// Not found: log and return a descriptive error, using the correct HTTP status code.
|
|
if (projectBranch == null)
|
|
{
|
|
_logger.LogWarning("Project branch not found. ProjectBranchId: {ProjectBranchId}, TenantId: {TenantId}", id, tenantId);
|
|
return ApiResponse<object>.ErrorResponse(
|
|
"Project branch not found",
|
|
"No project branch exists with the given ID for this tenant.",
|
|
404
|
|
);
|
|
}
|
|
|
|
// Map entity to detail view model to avoid exposing domain internals in API.
|
|
var branchDetails = _mapper.Map<ProjectBranchDetailsVM>(projectBranch);
|
|
|
|
_logger.LogInfo("Project branch details successfully fetched. ProjectBranchId: {ProjectBranchId}", id);
|
|
|
|
// Return success with data using a descriptive message.
|
|
return ApiResponse<object>.SuccessResponse(branchDetails, "Project branch details fetched successfully.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Log the complete exception with an error log, capturing all contextual info for troubleshooting.
|
|
_logger.LogError(ex, "Error while fetching project branch details. ProjectBranchId: {ProjectBranchId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}",
|
|
id, tenantId, loggedInEmployee.Id);
|
|
|
|
// Return a standardized error message; hide internal error details when handing unknown errors.
|
|
return ApiResponse<object>.ErrorResponse(
|
|
"An unexpected error occurred while fetching project branch details.",
|
|
ex.Message,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves a filtered, distinct list of project branch types for a specified tenant.
|
|
/// Supports optional search filtering, optimized for read-only access.
|
|
/// </summary>
|
|
/// <param name="searchString">Optional search string to filter branch types.</param>
|
|
/// <param name="loggedInEmployee">The employee requesting data, for audit logging.</param>
|
|
/// <param name="tenantId">Tenant identifier to scope data in a multi-tenant environment.</param>
|
|
/// <returns>ApiResponse with list of branch types or error message.</returns>
|
|
public async Task<ApiResponse<object>> GetBranchTypeListAsync(string? searchString, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("GetBranchTypeListAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
|
|
}
|
|
_logger.LogInfo("Fetching distinct project branch types for TenantId: {TenantId}, RequestedBy: {EmployeeId}", tenantId, loggedInEmployee.Id);
|
|
|
|
try
|
|
{
|
|
// Build initial query with no tracking for optimized read performance
|
|
var branchTypeQuery = _context.ProjectBranches
|
|
.AsNoTracking()
|
|
.Where(pb => pb.TenantId == tenantId)
|
|
.Select(pb => pb.BranchType);
|
|
|
|
// Apply search filter if provided
|
|
if (!string.IsNullOrWhiteSpace(searchString))
|
|
{
|
|
_logger.LogDebug("Applying search filter for branch types with searchString: {SearchString}", searchString);
|
|
branchTypeQuery = branchTypeQuery.Where(bt => bt.Contains(searchString));
|
|
}
|
|
|
|
// Get distinct branch types asynchronously
|
|
var branchTypes = await branchTypeQuery
|
|
.Distinct()
|
|
.OrderBy(bt => bt)
|
|
.ToListAsync();
|
|
|
|
_logger.LogInfo("Fetched {Count} distinct branch types for TenantId: {TenantId}", branchTypes.Count, tenantId);
|
|
|
|
return ApiResponse<object>.SuccessResponse(branchTypes, $"{branchTypes.Count} project branch types fetched successfully.", 200);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error fetching branch types for TenantId: {TenantId}", tenantId);
|
|
return ApiResponse<object>.ErrorResponse("Failed to fetch branch types due to an internal error.", ex.Message, 500);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new project branch associated with a specific service project.
|
|
/// Applies enterprise-grade validation, logging, and exception handling.
|
|
/// </summary>
|
|
/// <param name="model">DTO containing project branch creation data.</param>
|
|
/// <param name="loggedInEmployee">Logged-in employee details for auditing.</param>
|
|
/// <param name="tenantId">Tenant identifier for multi-tenancy context.</param>
|
|
/// <returns>ApiResponse containing created project branch details or error info.</returns>
|
|
public async Task<ApiResponse<object>> CreateProjectBranchAsync(ProjectBranchDto model, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("CreateProjectBranchAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
|
|
}
|
|
|
|
_logger.LogInfo("Starting project branch creation. ProjectId: {ProjectId}, TenantId: {TenantId}, CreatedBy: {EmployeeId}",
|
|
model.ProjectId, tenantId, loggedInEmployee.Id);
|
|
|
|
try
|
|
{
|
|
// Validate existence of related service project for given tenant
|
|
var serviceProject = await _context.ServiceProjects
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(sp => sp.Id == model.ProjectId && sp.TenantId == tenantId && sp.IsActive);
|
|
|
|
if (serviceProject == null)
|
|
{
|
|
_logger.LogWarning("Service project not found for ProjectId: {ProjectId}, TenantId: {TenantId}", model.ProjectId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("Service project not found", "No service project exists with the given ID for this tenant.", 404);
|
|
}
|
|
|
|
// Map DTO to domain entity and initialize audit and status fields
|
|
var projectBranch = _mapper.Map<ProjectBranch>(model);
|
|
projectBranch.Id = Guid.NewGuid();
|
|
projectBranch.IsActive = true;
|
|
projectBranch.CreatedAt = DateTime.UtcNow;
|
|
projectBranch.CreatedById = loggedInEmployee.Id;
|
|
projectBranch.TenantId = tenantId;
|
|
|
|
// Add and persist new project branch
|
|
await using var transaction = await _context.Database.BeginTransactionAsync();
|
|
_context.ProjectBranches.Add(projectBranch);
|
|
await _context.SaveChangesAsync();
|
|
await transaction.CommitAsync();
|
|
|
|
// Map to response view models assembling nested related data
|
|
var response = _mapper.Map<ProjectBranchVM>(projectBranch);
|
|
response.Project = _mapper.Map<BasicServiceProjectVM>(serviceProject);
|
|
response.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
|
|
|
|
_logger.LogInfo("Project branch created successfully. ProjectBranchId: {ProjectBranchId}", projectBranch.Id);
|
|
return ApiResponse<object>.SuccessResponse(response, "Created project branch successfully", 201);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error occurred while creating project branch. ProjectId: {ProjectId}, TenantId: {TenantId}, EmployeeId: {EmployeeId}",
|
|
model.ProjectId, tenantId, loggedInEmployee.Id);
|
|
|
|
return ApiResponse<object>.ErrorResponse("An unexpected error occurred while creating the project branch.", ex.Message, 500);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates an existing project branch with new data. Ensures data consistency, logs changes, and maintains comprehensive audit trail.
|
|
/// Implements enterprise best practices for validation, logging, transaction management, and error handling.
|
|
/// </summary>
|
|
/// <param name="id">ID of the project branch to update.</param>
|
|
/// <param name="model">DTO containing updated project branch data.</param>
|
|
/// <param name="loggedInEmployee">Current employee performing the update (for audit and logging).</param>
|
|
/// <param name="tenantId">Tenant ID for multi-tenant data isolation.</param>
|
|
/// <returns>ApiResponse indicating success or failure with detailed messages.</returns>
|
|
public async Task<ApiResponse<object>> UpdateProjectBranchAsync(Guid id, ProjectBranchDto model, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("UpdateProjectBranchAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
|
|
}
|
|
|
|
// Validate ID consistency between route parameter and payload DTO
|
|
if (!model.Id.HasValue && model.Id != id)
|
|
{
|
|
_logger.LogWarning("ID mismatch: Route ID {RouteId} != Payload ID {PayloadId}", id, model.Id ?? Guid.Empty);
|
|
return ApiResponse<object>.ErrorResponse("ID mismatch between route and payload", "The ID provided in the route does not match the payload.", 400);
|
|
}
|
|
|
|
// Fetch current entity state for auditing
|
|
var projectBranch = await _context.ProjectBranches
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(pb => pb.Id == id && pb.TenantId == tenantId);
|
|
|
|
if (projectBranch == null)
|
|
{
|
|
_logger.LogWarning("Project branch not found for update. Id: {Id}, TenantId: {TenantId}", id, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("Project branch not found", "No project branch exists with the provided ID for this tenant.", 404);
|
|
}
|
|
|
|
// Convert existing entity to BSON for detailed audit logging
|
|
BsonDocument existingEntityBson = _updateLogHelper.EntityToBsonDocument(projectBranch);
|
|
|
|
// Map the incoming DTO onto the existing entity
|
|
_mapper.Map(model, projectBranch);
|
|
projectBranch.UpdatedAt = DateTime.UtcNow;
|
|
projectBranch.UpdatedById = loggedInEmployee.Id;
|
|
|
|
try
|
|
{
|
|
// Execute update within a transaction to ensure atomicity
|
|
await using var transaction = await _context.Database.BeginTransactionAsync();
|
|
|
|
// Mark the entity as modified
|
|
_context.ProjectBranches.Update(projectBranch);
|
|
await _context.SaveChangesAsync();
|
|
|
|
// Commit transaction
|
|
await transaction.CommitAsync();
|
|
|
|
// Log the update in a dedicated audit log asynchronously
|
|
var updateLogTask = _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = existingEntityBson,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, "ProjectBranchModificationLog");
|
|
|
|
// Fetch the latest entity details with related info for response
|
|
var branchTask = Task.Run(async () =>
|
|
{
|
|
await using var context = await _dbContextFactory.CreateDbContextAsync();
|
|
return await context.ProjectBranches
|
|
.Include(pb => pb.Project).ThenInclude(sp => sp!.Status)
|
|
.Include(pb => pb.CreatedBy).ThenInclude(e => e!.JobRole)
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(pb => pb.Id == id && pb.TenantId == tenantId);
|
|
});
|
|
|
|
await Task.WhenAll(updateLogTask, branchTask);
|
|
|
|
// Map updated entity to view model for API response
|
|
var response = _mapper.Map<ProjectBranchVM>(branchTask.Result);
|
|
|
|
_logger.LogInfo("Successfully updated project branch. Id: {Id}", id);
|
|
return ApiResponse<object>.SuccessResponse(response, "Project branch updated successfully.", 200);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Log detailed error for troubleshooting
|
|
_logger.LogError(ex, "Error during project branch update. Id: {Id}, TenantId: {TenantId}", id, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("Failed to update project branch due to an internal error.", ex.Message, 500);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft deletes or restores a project branch by toggling its IsActive flag.
|
|
/// Implements audit logging, transaction safety, and detailed error handling to ensure enterprise readiness.
|
|
/// </summary>
|
|
/// <param name="id">The unique identifier of the project branch to be deleted or restored.</param>
|
|
/// <param name="isActive">Boolean indicating active state; false to soft delete, true to restore.</param>
|
|
/// <param name="loggedInEmployee">The authenticated employee performing the operation, for auditing purposes.</param>
|
|
/// <param name="tenantId">Tenant ID to enforce multi-tenant data isolation.</param>
|
|
/// <returns>ApiResponse indicating the result of the operation, with status and descriptive message.</returns>
|
|
public async Task<ApiResponse<object>> DeleteProjectBranchAsync(Guid id, bool isActive, Employee loggedInEmployee, Guid tenantId)
|
|
{
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("DeleteProjectBranchAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
|
|
}
|
|
_logger.LogInfo("Starting soft delete operation for ProjectBranchId: {ProjectBranchId}, TenantId: {TenantId}, By EmployeeId: {EmployeeId}",
|
|
id, tenantId, loggedInEmployee.Id);
|
|
|
|
try
|
|
{
|
|
// Fetch the existing project branch record for the tenant
|
|
var projectBranch = await _context.ProjectBranches
|
|
.FirstOrDefaultAsync(pb => pb.Id == id && pb.TenantId == tenantId);
|
|
|
|
if (projectBranch == null)
|
|
{
|
|
_logger.LogWarning("Project branch not found for soft delete. ProjectBranchId: {ProjectBranchId}, TenantId: {TenantId}", id, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("Project branch not found", "No project branch exists with the given ID for this tenant.", 404);
|
|
}
|
|
|
|
// Capture existing entity state for audit logging
|
|
BsonDocument existingEntityBson = _updateLogHelper.EntityToBsonDocument(projectBranch);
|
|
|
|
// Update the IsActive flag to soft delete or restore
|
|
projectBranch.IsActive = isActive;
|
|
|
|
// Save changes within a transaction to ensure atomicity
|
|
await using var transaction = await _context.Database.BeginTransactionAsync();
|
|
await _context.SaveChangesAsync();
|
|
await transaction.CommitAsync();
|
|
|
|
// Log the change asynchronously for audit trail
|
|
await _updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
|
|
{
|
|
EntityId = id.ToString(),
|
|
UpdatedById = loggedInEmployee.Id.ToString(),
|
|
OldObject = existingEntityBson,
|
|
UpdatedAt = DateTime.UtcNow
|
|
}, "ProjectBranchModificationLog");
|
|
|
|
_logger.LogInfo("Soft delete operation completed successfully for ProjectBranchId: {ProjectBranchId}", id);
|
|
|
|
return ApiResponse<object>.SuccessResponse(new { }, isActive ? "Branch restored successfully" : "Branch deleted successfully", 200);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error occurred during soft delete operation for ProjectBranchId: {ProjectBranchId}, TenantId: {TenantId}", id, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("Failed to delete project branch due to an internal error.", ex.Message, 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, bool isArchive, 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.IsArchive == isArchive &&
|
|
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)
|
|
.Include(jt => jt.ProjectBranch)
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(jt =>
|
|
jt.Id == id &&
|
|
jt.TenantId == tenantId &&
|
|
jt.IsActive &&
|
|
!jt.IsArchive &&
|
|
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("The job could not be found. Please check the job details and try again.", "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,
|
|
UpdatedAt = ul.UpdatedAt,
|
|
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)
|
|
{
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("CreateJobTicketAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
|
|
}
|
|
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);
|
|
}
|
|
|
|
if (model.ProjectBranchId.HasValue)
|
|
{
|
|
// Log the attempt to fetch project branch
|
|
_logger.LogInfo("Attempting to fetch project branch with ID: {ProjectBranchId}, Project ID: {ProjectId}, Tenant ID: {TenantId}",
|
|
model.ProjectBranchId, serviceProject.Id, tenantId);
|
|
|
|
// Query project branch with all necessary filters
|
|
var projectBranch = await _context.ProjectBranches
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(pb =>
|
|
pb.Id == model.ProjectBranchId.Value &&
|
|
pb.ProjectId == serviceProject.Id &&
|
|
pb.TenantId == tenantId &&
|
|
pb.IsActive);
|
|
|
|
// Check if project branch exists
|
|
if (projectBranch == null)
|
|
{
|
|
// Log the failure to find project branch
|
|
_logger.LogWarning("Project branch not found for ID: {ProjectBranchId}, Project ID: {ProjectId}, Tenant ID: {TenantId}",
|
|
model.ProjectBranchId, serviceProject.Id, tenantId);
|
|
|
|
// Return a structured error response
|
|
return ApiResponse<object>.ErrorResponse(
|
|
"Project branch not found",
|
|
"The specified project branch does not exist or is not active for the given project and tenant.",
|
|
404);
|
|
}
|
|
|
|
// Log successful retrieval
|
|
_logger.LogInfo("Successfully retrieved project branch with ID: {ProjectBranchId}", model.ProjectBranchId);
|
|
}
|
|
|
|
|
|
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.IsActive = true;
|
|
jobTicket.IsArchive = false;
|
|
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 && jt.IsActive && !jt.IsArchive);
|
|
|
|
if (jobTicket == null)
|
|
{
|
|
_logger.LogWarning("Job ticket {JobTicketId} not found for status change in tenant {TenantId}", model.JobTicketId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("The job could not be found. Please check the job details and try again.", "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 == jobTicket.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}", jobTicket.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);
|
|
}
|
|
if (jobTicket.IsArchive != model.IsArchive)
|
|
{
|
|
// Validate if job ticket status permits archiving
|
|
if (model.IsArchive && jobTicket.StatusId != ReviewDoneStatus && jobTicket.StatusId != ClosedStatus)
|
|
{
|
|
_logger.LogWarning("Archiving failed: Job status not eligible. JobTicketId: {JobTicketId}, StatusId: {StatusId}", jobTicket.Id, jobTicket.StatusId);
|
|
return ApiResponse<object>.ErrorResponse(
|
|
"Archiving failed: Only jobs with status Done or Closed can be archived.",
|
|
"Invalid status: Job not eligible for archiving.",
|
|
400);
|
|
}
|
|
}
|
|
|
|
// Create BSON snapshot of existing entity for audit logging (MongoDB)
|
|
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}",
|
|
jobTicket.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}",
|
|
jobTicket.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.JobTicket.IsArchive && 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 && !jt.IsArchive);
|
|
|
|
if (!jobTicketExists)
|
|
{
|
|
_logger.LogWarning("Job ticket {JobTicketId} not found in tenant {TenantId} for comment listing", jobTicketId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("The job could not be found or is archived. Please check the job details and try again.", "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 && !jt.IsArchive);
|
|
|
|
if (jobTicket == null)
|
|
{
|
|
_logger.LogWarning("Job ticket {JobTicketId} not found or inaccessible in tenant {TenantId}", model.JobTicketId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("The job could not be found or is archived. Please check the job details and try again.", "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)
|
|
{
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("UpdateCommentAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
|
|
}
|
|
// 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(jt => jt.Id == model.JobTicketId && jt.TenantId == tenantId && !jt.IsArchive);
|
|
});
|
|
|
|
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("The job could not be found or is archived. Please check the job details and try again.", "The job could not be found. Please check the job details and try again.", 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)
|
|
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)
|
|
{
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("GetAttendanceForSelfAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
|
|
}
|
|
_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.IsArchive);
|
|
|
|
if (jobTicket == null)
|
|
{
|
|
_logger.LogWarning("JobTicket not found. JobTicketId: {JobTicketId}, TenantId: {TenantId}", jobTicketId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("The job could not be found. Please check the job details and try again.", "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)
|
|
{
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("GetAttendanceLogForAttendanceAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
|
|
}
|
|
|
|
_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)
|
|
{
|
|
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("GetAttendanceForJobTeamAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
|
|
}
|
|
|
|
_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.IsArchive);
|
|
|
|
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)
|
|
{
|
|
|
|
if (tenantId == Guid.Empty)
|
|
{
|
|
_logger.LogWarning("ManageJobTaggingAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
|
|
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
|
|
}
|
|
|
|
_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.IsArchive);
|
|
if (jobTicket == null)
|
|
{
|
|
_logger.LogWarning("JobTicket not found. JobTicketId: {JobTicketId}, TenantId: {TenantId}", model.JobTcketId, tenantId);
|
|
return ApiResponse<object>.ErrorResponse("The job could not be found. Please check the job details and try again.", "The job could not be found. Please check the job details and try again.", 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)
|
|
//{
|
|
|
|
// 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)
|
|
{
|
|
|
|
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
|
|
}
|
|
}
|