Added an API to update the job ticket

This commit is contained in:
ashutosh.nehete 2025-11-14 15:17:29 +05:30
parent ed16c0f102
commit fbb8a2261b
8 changed files with 419 additions and 44 deletions

View File

@ -1,7 +1,7 @@
using Marco.Pms.Model.Dtos.Employees;
using Marco.Pms.Model.Dtos.Master;
namespace Marco.Pms.Model.ViewModels.ServiceProject
namespace Marco.Pms.Model.Dtos.ServiceProject
{
public class CreateJobTicketDto
{

View File

@ -0,0 +1,17 @@
using Marco.Pms.Model.Dtos.Employees;
using Marco.Pms.Model.Dtos.Master;
namespace Marco.Pms.Model.Dtos.ServiceProject
{
public class UpdateJobTicketDto
{
public string? Title { get; set; }
public string? Description { get; set; }
public Guid ProjectId { get; set; }
public Guid StatusId { get; set; }
public List<BasicEmployeeDto>? Assignees { get; set; }
public DateTime StartDate { get; set; }
public DateTime DueDate { get; set; }
public List<TagDto>? Tags { get; set; }
}
}

View File

@ -1,10 +1,11 @@
using Marco.Pms.Model.Dtos.ServiceProject;
using AutoMapper;
using Marco.Pms.Model.Dtos.ServiceProject;
using Marco.Pms.Model.Employees;
using Marco.Pms.Model.ViewModels.ServiceProject;
using Marco.Pms.Model.Utilities;
using Marco.Pms.Services.Service.ServiceInterfaces;
using MarcoBMS.Services.Helpers;
using MarcoBMS.Services.Service;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.JsonPatch;
using Microsoft.AspNetCore.Mvc;
namespace Marco.Pms.Services.Controllers
@ -15,15 +16,18 @@ namespace Marco.Pms.Services.Controllers
public class ServiceProjectController : Controller
{
private readonly IServiceProject _serviceProject;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly UserHelper _userHelper;
private readonly ILoggingService _logger;
private readonly ISignalRService _signalR;
private readonly Guid tenantId;
public ServiceProjectController(IServiceProject serviceProject, UserHelper userHelper, ILoggingService logger, ISignalRService signalR)
public ServiceProjectController(IServiceProject serviceProject,
IServiceScopeFactory serviceScopeFactory,
UserHelper userHelper,
ISignalRService signalR)
{
_serviceProject = serviceProject;
_serviceScopeFactory = serviceScopeFactory;
_userHelper = userHelper;
_logger = logger;
_signalR = signalR;
tenantId = userHelper.GetTenantId();
@ -156,6 +160,51 @@ namespace Marco.Pms.Services.Controllers
return StatusCode(response.StatusCode, response);
}
[HttpPatch("job/edit/{id}")]
public async Task<IActionResult> UpdateJobTicket(Guid id, JsonPatchDocument<UpdateJobTicketDto> patchDoc)
{
// Validate incoming patch document
if (patchDoc == null)
{
return BadRequest(ApiResponse<object>.ErrorResponse("Invalid request", "Patch document cannot be null", 400));
}
// Get the currently logged in employee
Employee loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync();
// Retrieve the job ticket by id and tenant context
var jobTicket = await _serviceProject.GetJobTicketByIdAsync(id, tenantId);
if (jobTicket == null)
{
return NotFound(ApiResponse<object>.ErrorResponse("Job ticket not found", $"No active job ticket found with id {id}", 404));
}
// Use a scoped Mapper instance to map the entity to the DTO for patching
using var scope = _serviceScopeFactory.CreateScope();
var mapper = scope.ServiceProvider.GetRequiredService<IMapper>();
var modelToPatch = mapper.Map<UpdateJobTicketDto>(jobTicket);
// Apply the JSON Patch document to the DTO and check model state validity
patchDoc.ApplyTo(modelToPatch, ModelState);
if (!ModelState.IsValid)
{
return BadRequest(ApiResponse<object>.ErrorResponse("Validation failed", "Provided patch document values are invalid", 400));
}
// Update the job ticket with the patched model and logged-in user info
var response = await _serviceProject.UpdateJobTicketAsync(id, jobTicket, modelToPatch, loggedInEmployee, tenantId);
if (response.Success)
{
// If update successful, send SignalR notification with relevant info
var notificationPayload = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Job_Ticket", Response = response.Data };
await _signalR.SendNotificationAsync(notificationPayload);
}
// Return status codes with appropriate response
return StatusCode(response.StatusCode, response);
}
[HttpPost("job/add/comment")]
public async Task<IActionResult> AddCommentToJobTicket(JobCommentDto model)
{

View File

@ -198,8 +198,11 @@ namespace Marco.Pms.Services.MappingProfiles
CreateMap<ServiceProject, ServiceProjectVM>();
CreateMap<ServiceProject, BasicServiceProjectVM>();
CreateMap<ServiceProject, ServiceProjectDetailsVM>();
#region ======================================================= Job Ticket =======================================================
CreateMap<CreateJobTicketDto, JobTicket>();
CreateMap<UpdateJobTicketDto, JobTicket>();
CreateMap<JobTicket, UpdateJobTicketDto>();
CreateMap<JobTicket, JobTicketVM>();
CreateMap<JobTicket, JobTicketDetailsVM>();
CreateMap<JobTicket, BasicJobTicketVM>()
@ -210,6 +213,7 @@ namespace Marco.Pms.Services.MappingProfiles
CreateMap<JobComment, JobCommentVM>();
#endregion
#endregion
#region ======================================================= Employee =======================================================

View File

@ -18,6 +18,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.20" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.22" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.7" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.12">

View File

@ -72,7 +72,7 @@ builder.Services.AddCors(options =>
#endregion
#region Core Web & Framework Services
builder.Services.AddControllers();
builder.Services.AddControllers().AddNewtonsoftJson();
builder.Services.AddSignalR();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddHttpContextAccessor();

View File

@ -1,7 +1,7 @@
using Marco.Pms.Model.Dtos.ServiceProject;
using Marco.Pms.Model.Employees;
using Marco.Pms.Model.ServiceProject;
using Marco.Pms.Model.Utilities;
using Marco.Pms.Model.ViewModels.ServiceProject;
namespace Marco.Pms.Services.Service.ServiceInterfaces
{
@ -22,8 +22,13 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces
Task<ApiResponse<object>> GetJobTagListAsync(Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> CreateJobTicketAsync(CreateJobTicketDto model, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> ChangeJobsStatusAsync(ChangeJobStatusDto model, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> UpdateJobTicketAsync(Guid id, JobTicket jobTicket, UpdateJobTicketDto model, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> AddCommentToJobTicketAsync(JobCommentDto model, Employee loggedInEmployee, Guid tenantId);
#endregion
#region =================================================================== Pubic Helper Functions ===================================================================
Task<JobTicket?> GetJobTicketByIdAsync(Guid id, Guid tenantId);
#endregion
}
}

View File

@ -1,9 +1,11 @@
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;
@ -14,6 +16,7 @@ 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
{
@ -867,41 +870,7 @@ namespace Marco.Pms.Services.Service
return ApiResponse<object>.ErrorResponse("Job Not Found", "Job ticket not found.", 404);
}
// Find transition mappings for the current status and desired status, considering team role if allocation exists
var statusMappingQuery = _context.JobStatusMappings
.Include(jsm => jsm.Status)
.Include(jsm => jsm.NextStatus)
.Where(jsm =>
jsm.StatusId == jobTicket.StatusId &&
jsm.NextStatusId == model.StatusId &&
jsm.Status != null &&
jsm.NextStatus != null &&
jsm.TenantId == tenantId);
// Find allocation for current employee (to determine team role for advanced mapping)
var projectAllocation = await _context.ServiceProjectAllocations
.Where(spa => spa.EmployeeId == loggedInEmployee.Id &&
spa.ProjectId == jobTicket.ProjectId &&
spa.TenantId == tenantId &&
spa.IsActive)
.OrderByDescending(spa => spa.AssignedAt)
.FirstOrDefaultAsync();
var teamRoleId = projectAllocation?.TeamRoleId;
var hasTeamRoleMapping = projectAllocation != null
&& await statusMappingQuery.AnyAsync(jsm => jsm.TeamRoleId == teamRoleId);
// Filter by team role or fallback to global (null team role)
if (hasTeamRoleMapping)
{
statusMappingQuery = statusMappingQuery.Where(jsm => jsm.TeamRoleId == teamRoleId);
}
else
{
statusMappingQuery = statusMappingQuery.Where(jsm => jsm.TeamRoleId == null);
}
var jobStatusMapping = await statusMappingQuery.FirstOrDefaultAsync();
var jobStatusMapping = await GetJobStatusMappingAsync(jobTicket.StatusId, model.StatusId, jobTicket.ProjectId, loggedInEmployee.Id, tenantId);
if (jobStatusMapping == null)
{
@ -946,6 +915,258 @@ namespace Marco.Pms.Services.Service
}
}
/// <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);
}
try
{
// Concurrently load referenced project and status entities to validate foreign keys
var projectTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ServiceProjects.FirstOrDefaultAsync(sp => sp.Id == model.ProjectId && sp.TenantId == tenantId && sp.IsActive);
});
var statusTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobStatus.FirstOrDefaultAsync(js => js.Id == model.StatusId);
});
await Task.WhenAll(projectTask, statusTask);
// Validate existence of foreign entities
if (projectTask.Result == null)
{
_logger.LogWarning("Service project not found during job ticket update. ProjectId: {ProjectId}, TenantId: {TenantId}", model.ProjectId, tenantId);
return ApiResponse<object>.ErrorResponse("Service project not found", "Service project not found", 404);
}
if (statusTask.Result == null)
{
_logger.LogWarning("Job status not found during job ticket update. StatusId: {StatusId}", model.StatusId);
return ApiResponse<object>.ErrorResponse("Job status not found", "Job status not found", 404);
}
// Begin database transaction for atomicity
await using var transaction = await _context.Database.BeginTransactionAsync();
// Handle status change with validation and log creation
if (jobTicket.StatusId != model.StatusId)
{
var jobStatusMapping = await GetJobStatusMappingAsync(jobTicket.StatusId, model.StatusId, jobTicket.ProjectId, loggedInEmployee.Id, tenantId);
if (jobStatusMapping == null)
{
_logger.LogWarning("Invalid status transition requested from {CurrentStatusId} to {NewStatusId} in tenant {TenantId}",
jobTicket.StatusId, model.StatusId, tenantId);
return ApiResponse<object>.ErrorResponse("Invalid Status", "Selected status transition is not allowed.", 400);
}
var comment = $"Status changed from {jobStatusMapping.Status!.Name} to {jobStatusMapping.NextStatus!.Name}";
var updateLog = new StatusUpdateLog
{
Id = Guid.NewGuid(),
EntityId = jobTicket.Id,
StatusId = jobStatusMapping.StatusId,
NextStatusId = jobStatusMapping.NextStatusId,
Comment = comment,
UpdatedAt = DateTime.UtcNow,
UpdatedById = loggedInEmployee.Id,
TenantId = tenantId
};
_context.StatusUpdateLogs.Add(updateLog);
}
// Create BSON snapshot of existing entity for audit logging (MongoDB)
using var scope = _serviceScopeFactory.CreateScope();
var updateLogHelper = scope.ServiceProvider.GetRequiredService<UtilityMongoDBHelper>();
BsonDocument existingEntityBson = updateLogHelper.EntityToBsonDocument(jobTicket);
// Map updated properties from DTO, set audit metadata
_mapper.Map(model, jobTicket);
jobTicket.UpdatedAt = DateTime.UtcNow;
jobTicket.UpdatedById = loggedInEmployee.Id;
_context.JobTickets.Update(jobTicket);
await _context.SaveChangesAsync();
// Handle assignee changes: add new and remove inactive mappings
if (model.Assignees?.Any() == true)
{
var employeeIds = model.Assignees.Select(e => e.EmployeeId).ToList();
var employeeTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.Employees.Where(e => employeeIds.Contains(e.Id)).ToListAsync();
});
var jobEmployeeMappingTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobEmployeeMappings.Where(jem => employeeIds.Contains(jem.AssigneeId) && jem.TenantId == tenantId).ToListAsync();
});
await Task.WhenAll(employeeTask, jobEmployeeMappingTask);
var employees = employeeTask.Result;
var jobEmployeeMappings = jobEmployeeMappingTask.Result;
var newMappings = new List<JobEmployeeMapping>();
var removeMappings = new List<JobEmployeeMapping>();
foreach (var assignee in model.Assignees)
{
var employee = employees.FirstOrDefault(e => e.Id == assignee.EmployeeId);
var mapping = jobEmployeeMappings.FirstOrDefault(jem => jem.AssigneeId == assignee.EmployeeId);
if (assignee.IsActive && mapping == null && employee != null)
{
newMappings.Add(new JobEmployeeMapping
{
Id = Guid.NewGuid(),
AssigneeId = assignee.EmployeeId,
JobTicketId = jobTicket.Id,
TenantId = tenantId,
});
}
else if (!assignee.IsActive && mapping != null)
{
removeMappings.Add(mapping);
}
}
if (newMappings.Any()) _context.JobEmployeeMappings.AddRange(newMappings);
if (removeMappings.Any()) _context.JobEmployeeMappings.RemoveRange(removeMappings);
}
await _context.SaveChangesAsync();
// Handle tag changes: add new tags/mappings and remove inactive mappings
if (model.Tags?.Any() == true)
{
var tagNames = model.Tags.Select(jt => jt.Name).ToList();
var tagTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobTags.Where(jt => tagNames.Contains(jt.Name) && jt.TenantId == tenantId).ToListAsync();
});
var tagMappingTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobTagMappings.Where(jtm => jtm.JobTicketId == jobTicket.Id && jtm.TenantId == tenantId).ToListAsync();
});
await Task.WhenAll(tagTask, tagMappingTask);
var existingTags = tagTask.Result;
var existingTagMappings = tagMappingTask.Result;
var newJobTags = new List<JobTag>();
var newJobTagMappings = new List<JobTagMapping>();
var removeJobTagMappings = new List<JobTagMapping>();
foreach (var tagDto in model.Tags)
{
var tag = existingTags.FirstOrDefault(jt => jt.Name == tagDto.Name);
if (tag == null)
{
tag = new JobTag
{
Id = Guid.NewGuid(),
Name = tagDto.Name,
TenantId = tenantId
};
newJobTags.Add(tag);
}
var tagMapping = existingTagMappings.FirstOrDefault(jtm => jtm.JobTagId == tag.Id);
if (tagDto.IsActive && tagMapping == null)
{
newJobTagMappings.Add(new JobTagMapping
{
Id = Guid.NewGuid(),
JobTagId = tag.Id,
JobTicketId = jobTicket.Id,
TenantId = tenantId
});
}
else if (!tagDto.IsActive && tagMapping != null)
{
removeJobTagMappings.Add(tagMapping);
}
}
if (newJobTags.Any()) _context.JobTags.AddRange(newJobTags);
if (newJobTagMappings.Any()) _context.JobTagMappings.AddRange(newJobTagMappings);
if (removeJobTagMappings.Any()) _context.JobTagMappings.RemoveRange(removeJobTagMappings);
}
await _context.SaveChangesAsync();
await transaction.CommitAsync();
// Push update log asynchronously to MongoDB for audit
var updateLogTask = updateLogHelper.PushToUpdateLogsAsync(new UpdateLogsObject
{
EntityId = id.ToString(),
UpdatedById = loggedInEmployee.Id.ToString(),
OldObject = existingEntityBson,
UpdatedAt = DateTime.UtcNow
}, "JobTicketModificationLog");
// Reload updated job ticket with navigation properties
var jobTicketTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.JobTickets
.Include(jt => jt.Status)
.Include(jt => jt.Project)
.Include(jt => jt.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(jt => jt.UpdatedBy).ThenInclude(e => e!.JobRole)
.FirstOrDefaultAsync(jt => jt.Id == id && jt.TenantId == tenantId);
});
await Task.WhenAll(updateLogTask, jobTicketTask);
jobTicket = jobTicketTask.Result ?? new JobTicket();
var response = _mapper.Map<JobTicketVM>(jobTicket);
_logger.LogInfo("Job ticket {JobTicketId} updated successfully by employee {EmployeeId} in tenant {TenantId}", id, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.SuccessResponse(response, "Job updated successfully", 200);
}
catch (DbUpdateException dbEx)
{
_logger.LogError(dbEx, "Database error while updating job ticket for project {ProjectId} by employee {EmployeeId} in tenant {TenantId}",
model.ProjectId, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Database Error", "An error occurred while saving data to the database.", 500);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception while updating job ticket for project {ProjectId} by employee {EmployeeId} in tenant {TenantId}",
model.ProjectId, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Internal Server Error", "An unexpected error occurred.", 500);
}
}
#endregion
#region =================================================================== Job Comments Functions ===================================================================
@ -1195,5 +1416,83 @@ namespace Marco.Pms.Services.Service
}
#endregion
#region =================================================================== Pubic Helper Functions ===================================================================
/// <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
}
}