Added Google map url in service project table

This commit is contained in:
ashutosh.nehete 2025-11-14 17:49:49 +05:30
parent c61ef92f6e
commit 2806dceab2
9 changed files with 8968 additions and 156 deletions

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marco.Pms.DataAccess.Migrations
{
/// <inheritdoc />
public partial class Added_GoogleMapUrl_In_ServiceProject_Table : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "GoogleMapUrl",
table: "ServiceProjects",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "GoogleMapUrl",
table: "ServiceProjects");
}
}
}

View File

@ -5463,6 +5463,9 @@ namespace Marco.Pms.DataAccess.Migrations
b.Property<Guid>("CreatedById") b.Property<Guid>("CreatedById")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<string>("GoogleMapUrl")
.HasColumnType("longtext");
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)"); .HasColumnType("tinyint(1)");

View File

@ -17,6 +17,6 @@ namespace Marco.Pms.Model.Dtos.ServiceProject
public required string ContactName { get; set; } public required string ContactName { get; set; }
public required string ContactPhone { get; set; } public required string ContactPhone { get; set; }
public required string ContactEmail { get; set; } public required string ContactEmail { get; set; }
public string? GoogleMapUrl { get; set; }
} }
} }

View File

@ -27,6 +27,7 @@ namespace Marco.Pms.Model.ServiceProject
public string ContactName { get; set; } = string.Empty; public string ContactName { get; set; } = string.Empty;
public string ContactPhone { get; set; } = string.Empty; public string ContactPhone { get; set; } = string.Empty;
public string ContactEmail { get; set; } = string.Empty; public string ContactEmail { get; set; } = string.Empty;
public string? GoogleMapUrl { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public Guid CreatedById { get; set; } public Guid CreatedById { get; set; }

View File

@ -18,6 +18,7 @@ namespace Marco.Pms.Model.ViewModels.ServiceProject
public string? ContactName { get; set; } public string? ContactName { get; set; }
public string? ContactPhone { get; set; } public string? ContactPhone { get; set; }
public string? ContactEmail { get; set; } public string? ContactEmail { get; set; }
public string? GoogleMapUrl { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public BasicEmployeeVM? CreatedBy { get; set; } public BasicEmployeeVM? CreatedBy { get; set; }
} }

View File

@ -119,6 +119,7 @@ namespace Marco.Pms.Services.Controllers
return StatusCode(response.StatusCode, response); return StatusCode(response.StatusCode, response);
} }
#endregion #endregion
#region =================================================================== Job Tickets Functions =================================================================== #region =================================================================== Job Tickets Functions ===================================================================
[HttpGet("job/list")] [HttpGet("job/list")]

View File

@ -19,22 +19,23 @@ namespace Marco.Pms.Services.Service.ServiceInterfaces
Task<ApiResponse<object>> GetServiceProjectAllocationListAsync(Guid? projectId, Guid? employeeId, bool isActive, Employee loggedInEmployee, Guid tenantId); Task<ApiResponse<object>> GetServiceProjectAllocationListAsync(Guid? projectId, Guid? employeeId, bool isActive, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> ManageServiceProjectAllocationAsync(List<ServiceProjectAllocationDto> model, Employee loggedInEmployee, Guid tenantId); Task<ApiResponse<object>> ManageServiceProjectAllocationAsync(List<ServiceProjectAllocationDto> model, Employee loggedInEmployee, Guid tenantId);
#endregion #endregion
#region =================================================================== Job Tickets Functions =================================================================== #region =================================================================== Job Tickets Functions ===================================================================
Task<ApiResponse<object>> GetJobTicketsListAsync(Guid? projectId, int pageNumber, int pageSize, bool isActive, Employee loggedInEmployee, Guid tenantId); Task<ApiResponse<object>> GetJobTicketsListAsync(Guid? projectId, int pageNumber, int pageSize, bool isActive, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> GetJobTicketDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId); Task<ApiResponse<object>> GetJobTicketDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> GetCommentListByJobTicketAsync(Guid? jobTicketId, int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> GetJobTagListAsync(Employee loggedInEmployee, Guid tenantId); Task<ApiResponse<object>> GetJobTagListAsync(Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> CreateJobTicketAsync(CreateJobTicketDto model, 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>> ChangeJobsStatusAsync(ChangeJobStatusDto model, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> UpdateJobTicketAsync(Guid id, JobTicket jobTicket, UpdateJobTicketDto 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 #endregion
#region =================================================================== Job Comments Functions =================================================================== #region =================================================================== Job Comments Functions ===================================================================
Task<ApiResponse<object>> GetCommentListByJobTicketAsync(Guid? jobTicketId, int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId);
Task<ApiResponse<object>> AddCommentToJobTicketAsync(JobCommentDto model, Employee loggedInEmployee, Guid tenantId);
#endregion #endregion
#region =================================================================== Pubic Helper Functions =================================================================== #region =================================================================== Helper Functions ===================================================================
Task<JobTicket?> GetJobTicketByIdAsync(Guid id, Guid tenantId); Task<JobTicket?> GetJobTicketByIdAsync(Guid id, Guid tenantId);
#endregion #endregion
} }

View File

@ -49,180 +49,192 @@ namespace Marco.Pms.Services.Service
} }
#region =================================================================== Service Project Functions =================================================================== #region =================================================================== Service Project Functions ===================================================================
/// <summary>
/// Retrieves a paginated list of active service projects including their clients, status, creators, and related services for a given tenant.
/// </summary>
/// <param name="pageNumber">Page number (1-based) for pagination.</param>
/// <param name="pageSize">Number of records per page.</param>
/// <param name="loggedInEmployee">Employee making the request (for logging).</param>
/// <param name="tenantId">Tenant identifier for multi-tenant isolation.</param>
/// <returns>ApiResponse containing paged service projects with related data or error information.</returns>
public async Task<ApiResponse<object>> GetServiceProjectListAsync(int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId) public async Task<ApiResponse<object>> GetServiceProjectListAsync(int pageNumber, int pageSize, Employee loggedInEmployee, Guid tenantId)
{ {
if (tenantId == Guid.Empty)
{
_logger.LogWarning("GetServiceProjectListAsync called with invalid tenant context by employee {EmployeeId}", loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
}
if (pageNumber < 1 || pageSize < 1)
{
_logger.LogInfo("Invalid pagination parameters: PageNumber={PageNumber}, PageSize={PageSize}", pageNumber, pageSize);
return ApiResponse<object>.ErrorResponse("Bad Request", "Page number and size must be greater than zero.", 400);
}
try try
{ {
// Base query for active projects scoped by tenant including necessary related entities
var serviceProjectQuery = _context.ServiceProjects var serviceProjectQuery = _context.ServiceProjects
.Include(sp => sp.Client) .Include(sp => sp.Client)
.Include(sp => sp.Status) .Include(sp => sp.Status)
.Include(sp => sp.CreatedBy).ThenInclude(e => e!.JobRole) .Include(sp => sp.CreatedBy).ThenInclude(e => e!.JobRole)
.Where(sp => sp.TenantId == tenantId && sp.IsActive); .Where(sp => sp.TenantId == tenantId && sp.IsActive);
var totalEntites = await serviceProjectQuery.CountAsync(); var totalEntities = await serviceProjectQuery.CountAsync();
var totalPages = (int)Math.Ceiling((double)totalEntites / pageSize); var totalPages = (int)Math.Ceiling((double)totalEntities / pageSize);
// Fetch paged projects ordered by creation date descending
var serviceProjects = await serviceProjectQuery var serviceProjects = await serviceProjectQuery
.OrderByDescending(e => e.CreatedAt) .OrderByDescending(sp => sp.CreatedAt)
.Skip((pageNumber - 1) * pageSize) .Skip((pageNumber - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.ToListAsync(); .ToListAsync();
var serviceProjectIds = serviceProjects.Select(sp => sp.Id).ToList(); var serviceProjectIds = serviceProjects.Select(sp => sp.Id).ToList();
// Load related service mappings with services for current page projects (avoid N+1)
var serviceProjectServiceMappings = await _context.ServiceProjectServiceMapping var serviceProjectServiceMappings = await _context.ServiceProjectServiceMapping
.Include(sps => sps.Service) .Include(sps => sps.Service)
.Where(sps => serviceProjectIds.Contains(sps.ProjectId) && .Where(sps => serviceProjectIds.Contains(sps.ProjectId) &&
sps.Service != null && sps.Service != null &&
sps.TenantId == tenantId) sps.TenantId == tenantId)
.ToListAsync(); .ToListAsync();
// Map each project with its related services into the view models
var serviceProjectVMs = serviceProjects.Select(sp => var serviceProjectVMs = serviceProjects.Select(sp =>
{ {
var services = serviceProjectServiceMappings.Where(sps => sps.ProjectId == sp.Id).Select(sps => sps.Service!).ToList(); var relatedServices = serviceProjectServiceMappings
var result = _mapper.Map<ServiceProjectVM>(sp); .Where(sps => sps.ProjectId == sp.Id)
result.Services = _mapper.Map<List<ServiceMasterVM>>(services); .Select(sps => sps.Service!)
return result; .ToList();
var projectVm = _mapper.Map<ServiceProjectVM>(sp);
projectVm.Services = _mapper.Map<List<ServiceMasterVM>>(relatedServices);
return projectVm;
}).ToList(); }).ToList();
var response = new var response = new
{ {
CurrentPage = pageNumber, CurrentPage = pageNumber,
TotalPages = totalPages, TotalPages = totalPages,
TotalEntites = totalEntites, TotalEntities = totalEntities,
Data = serviceProjectVMs, Data = serviceProjectVMs,
}; };
_logger.LogInfo("Successfully retrieved a total of {ProjectCount} projects.", serviceProjectVMs.Count); _logger.LogInfo("Retrieved {Count} service projects for tenant {TenantId} by employee {EmployeeId}. Page {PageNumber}/{TotalPages}",
return ApiResponse<object>.SuccessResponse(response, "Projects retrieved successfully.", 200); serviceProjectVMs.Count, tenantId, loggedInEmployee.Id, pageNumber, totalPages);
return ApiResponse<object>.SuccessResponse(response, "Projects retrieved successfully.", 200);
} }
catch (Exception ex) catch (Exception ex)
{ {
// --- Step 5: Graceful Error Handling --- _logger.LogError(ex, "An unexpected error occurred in GetServiceProjectListAsync for tenant {TenantId} by employee {EmployeeId}",
_logger.LogError(ex, "An unexpected error occurred in GetAllProjects for tenant {TenantId}.", tenantId); tenantId, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); return ApiResponse<object>.ErrorResponse("An internal server error occurred. Please try again later.", null, 500);
} }
} }
/// <summary>
/// Retrieves detailed information for a specific service project, including related client, status, services, and audit information.
/// </summary>
/// <param name="id">The unique identifier of the service project.</param>
/// <param name="loggedInEmployee">The employee requesting the details (for audit logging).</param>
/// <param name="tenantId">Tenant identifier ensuring multi-tenant data isolation.</param>
/// <returns>ApiResponse containing detailed service project information or error details.</returns>
public async Task<ApiResponse<object>> GetServiceProjectDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId) public async Task<ApiResponse<object>> GetServiceProjectDetailsAsync(Guid id, Employee loggedInEmployee, Guid tenantId)
{ {
var serviceProject = await _context.ServiceProjects if (tenantId == Guid.Empty)
.Include(sp => sp.Client)
.Include(sp => sp.Status)
.Include(sp => sp.CreatedBy).ThenInclude(e => e!.JobRole)
.Include(sp => sp.UpdatedBy).ThenInclude(e => e!.JobRole)
.FirstOrDefaultAsync(sp => sp.Id == id && sp.TenantId == tenantId);
if (serviceProject == null)
{ {
return ApiResponse<object>.ErrorResponse("Service Project not found", "Service Project not found", 404); _logger.LogWarning("GetServiceProjectDetailsAsync called with missing tenant context by employee {EmployeeId}", loggedInEmployee.Id);
} return ApiResponse<object>.ErrorResponse("Access Denied", "Invalid tenant context.", 403);
var services = await _context.ServiceProjectServiceMapping
.Include(sps => sps.Service)
.Where(sps => sps.ProjectId == serviceProject.Id &&
sps.Service != null &&
sps.TenantId == tenantId)
.Select(sps => sps.Service!)
.ToListAsync();
//var numberOfJobs = await _context.JobTickets.Where(jt => jt.ProjectId == serviceProject.Id && jt.TenantId == tenantId).CountAsync();
var response = _mapper.Map<ServiceProjectDetailsVM>(serviceProject);
response.Services = _mapper.Map<List<ServiceMasterVM>>(services);
//response.NumberOfJobs = numberOfJobs;
response.NumberOfJobs = 0;
return ApiResponse<object>.SuccessResponse(response, "Service Project Details fetched successfully", 200);
}
public async Task<ApiResponse<object>> CreateServiceProjectAsync(ServiceProjectDto model, Employee loggedInEmployee, Guid tenantId)
{
var serviceIds = model.Services.Where(s => s.IsActive).Select(s => s.ServiceId).ToList();
var clientTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.Organizations.FirstOrDefaultAsync(o => o.Id == model.ClientId && o.IsActive);
});
var serviceTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ServiceMasters.Where(s => serviceIds.Contains(s.Id) && s.TenantId == tenantId && s.IsActive).ToListAsync();
});
var statusTask = Task.Run(async () =>
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.StatusMasters.FirstOrDefaultAsync(s => s.Id == model.StatusId);
});
await Task.WhenAll(clientTask, serviceTask, statusTask);
var client = clientTask.Result;
var services = serviceTask.Result;
var status = statusTask.Result;
if (client == null)
{
return ApiResponse<object>.ErrorResponse("Client not found", "Client not found", 404);
}
if (status == null)
{
return ApiResponse<object>.ErrorResponse("Project Status not found", "Project Status not found", 404);
} }
var serviceProject = _mapper.Map<ServiceProject>(model);
serviceProject.Id = Guid.NewGuid();
serviceProject.CreatedById = loggedInEmployee.Id;
serviceProject.CreatedAt = DateTime.UtcNow;
serviceProject.IsActive = true;
serviceProject.TenantId = tenantId;
var projectServiceMapping = model.Services.Where(sdto => services.Any(s => s.Id == sdto.ServiceId)).Select(sdto => new ServiceProjectServiceMapping
{
ServiceId = sdto.ServiceId,
ProjectId = serviceProject.Id,
TenantId = tenantId
}).ToList();
try try
{ {
_context.ServiceProjects.Add(serviceProject); // Load service project with related client, status, and creator/updater roles
_context.ServiceProjectServiceMapping.AddRange(projectServiceMapping); 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);
await _context.SaveChangesAsync(); 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);
}
_logger.LogInfo("Service Project {ProjectId} created successfully for TenantId={TenantId}, by Employee {EmployeeId}.", // Retrieve related services for the project
serviceProject.Id, tenantId, loggedInEmployee); var services = await _context.ServiceProjectServiceMapping
.Include(sps => sps.Service)
.Where(sps => sps.ProjectId == serviceProject.Id &&
sps.Service != null &&
sps.TenantId == tenantId)
.Select(sps => sps.Service!)
.ToListAsync();
var serviceProjectVM = _mapper.Map<ServiceProjectVM>(serviceProject); // 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);
serviceProjectVM.Client = _mapper.Map<BasicOrganizationVm>(client); var response = _mapper.Map<ServiceProjectDetailsVM>(serviceProject);
serviceProjectVM.Status = status; response.Services = _mapper.Map<List<ServiceMasterVM>>(services);
response.NumberOfJobs = await _context.JobTickets.CountAsync(jt => jt.ProjectId == id && jt.IsActive && jt.TenantId == tenantId);
serviceProjectVM.Services = services.Where(s => serviceIds.Contains(s.Id)).Select(s => _mapper.Map<ServiceMasterVM>(s)).ToList(); _logger.LogInfo("Fetched details for service project {ServiceProjectId} for tenant {TenantId} requested by employee {EmployeeId}",
id, tenantId, loggedInEmployee.Id);
serviceProjectVM.CreatedBy = _mapper.Map<BasicEmployeeVM>(loggedInEmployee);
return ApiResponse<object>.SuccessResponse(serviceProjectVM, "An Successfullly occurred while saving the project.", 201);
return ApiResponse<object>.SuccessResponse(response, "Service Project details fetched successfully.", 200);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "DB Failure: Service Project creation failed for TenantId={TenantId}. Rolling back.", tenantId); _logger.LogError(ex, "Failed to fetch service project details for project {ServiceProjectId} by employee {EmployeeId} in tenant {TenantId}",
return ApiResponse<object>.ErrorResponse("An error occurred while saving the project.", ex.Message, 500); 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);
}
}
public async Task<ApiResponse<object>> UpdateServiceProjectAsync(Guid id, ServiceProjectDto model, Employee loggedInEmployee, Guid tenantId)
{
try try
{ {
var serviceIds = model.Services.Select(s => s.ServiceId).ToList(); // 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 () => var clientTask = Task.Run(async () =>
{ {
await using var context = await _dbContextFactory.CreateDbContextAsync(); await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.Organizations.FirstOrDefaultAsync(o => o.Id == model.ClientId && o.IsActive); return await context.Organizations.FirstOrDefaultAsync(o => o.Id == model.ClientId && o.IsActive);
}); });
var serviceTask = Task.Run(async () => var serviceTask = Task.Run(async () =>
{ {
await using var context = await _dbContextFactory.CreateDbContextAsync(); await using var context = await _dbContextFactory.CreateDbContextAsync();
return await context.ServiceMasters.Where(s => serviceIds.Contains(s.Id) && s.TenantId == tenantId && s.IsActive).ToListAsync(); return await context.ServiceMasters.Where(s => serviceIds.Contains(s.Id) && s.TenantId == tenantId && s.IsActive).ToListAsync();
}); });
var statusTask = Task.Run(async () => var statusTask = Task.Run(async () =>
{ {
await using var context = await _dbContextFactory.CreateDbContextAsync(); await using var context = await _dbContextFactory.CreateDbContextAsync();
@ -235,62 +247,181 @@ namespace Marco.Pms.Services.Service
var services = serviceTask.Result; var services = serviceTask.Result;
var status = statusTask.Result; var status = statusTask.Result;
// Validate existence of critical foreign entities
if (client == null) if (client == null)
{ {
return ApiResponse<object>.ErrorResponse("Client not found", "Client not found", 404); _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) if (status == null)
{ {
return ApiResponse<object>.ErrorResponse("Project Status not found", "Project Status not found", 404); _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);
} }
var serviceProject = await _context.ServiceProjects.Where(sp => sp.Id == id && sp.TenantId == tenantId && sp.IsActive).FirstOrDefaultAsync(); // 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) if (serviceProject == null)
{ {
_logger.LogWarning("Attempt to update non-existent Service project with ID {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); _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 project found with ID {id}.", 404); return ApiResponse<object>.ErrorResponse("Project Not Found", $"No active project found with ID {id}.", 404);
} }
// Map incoming DTO to the tracked entity
_mapper.Map(model, serviceProject); _mapper.Map(model, serviceProject);
serviceProject.UpdatedAt = DateTime.UtcNow; serviceProject.UpdatedAt = DateTime.UtcNow;
serviceProject.UpdatedById = loggedInEmployee.Id; serviceProject.UpdatedById = loggedInEmployee.Id;
var serviceProjectServiceMappings = await _context.ServiceProjectServiceMapping // Get existing service mappings for the project (no tracking as we perform add/remove explicitly)
var existingMappings = await _context.ServiceProjectServiceMapping
.AsNoTracking() .AsNoTracking()
.Where(sps => sps.ProjectId == serviceProject.Id && sps.TenantId == tenantId) .Where(sps => sps.ProjectId == serviceProject.Id && sps.TenantId == tenantId)
.ToListAsync(); .ToListAsync();
var newMapping = new List<ServiceProjectServiceMapping>(); // Determine service mappings to add or remove based on input DTO state
var removedMapping = new List<ServiceProjectServiceMapping>(); var newMappings = new List<ServiceProjectServiceMapping>();
var removedMappings = new List<ServiceProjectServiceMapping>();
foreach (var dto in model.Services) if (model.Services != null)
{ {
var serviceProjectServiceMapping = serviceProjectServiceMappings foreach (var dto in model.Services)
.FirstOrDefault(sps => sps.ServiceId == dto.ServiceId); {
var existingMapping = existingMappings.FirstOrDefault(sps => sps.ServiceId == dto.ServiceId);
if (dto.IsActive && serviceProjectServiceMapping == null) if (dto.IsActive && existingMapping == null)
{
newMapping.Add(new ServiceProjectServiceMapping
{ {
Id = Guid.NewGuid(), newMappings.Add(new ServiceProjectServiceMapping
ServiceId = dto.ServiceId, {
ProjectId = serviceProject.Id, Id = Guid.NewGuid(),
TenantId = tenantId, ServiceId = dto.ServiceId,
}); ProjectId = serviceProject.Id,
} TenantId = tenantId,
else if (!dto.IsActive && serviceProjectServiceMapping != null) });
{ }
removedMapping.Add(serviceProjectServiceMapping); else if (!dto.IsActive && existingMapping != null)
{
removedMappings.Add(existingMapping);
}
} }
} }
_context.ServiceProjectServiceMapping.AddRange(newMapping); // Apply added and removed mappings
_context.ServiceProjectServiceMapping.RemoveRange(removedMapping); 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(); await _context.SaveChangesAsync();
// Reload updated project and related entities for comprehensive response
var serviceProjectTask = Task.Run(async () => var serviceProjectTask = Task.Run(async () =>
{ {
await using var context = await _dbContextFactory.CreateDbContextAsync(); await using var context = await _dbContextFactory.CreateDbContextAsync();
@ -298,9 +429,9 @@ namespace Marco.Pms.Services.Service
.Include(sp => sp.Client) .Include(sp => sp.Client)
.Include(sp => sp.Status) .Include(sp => sp.Status)
.Include(sp => sp.CreatedBy).ThenInclude(e => e!.JobRole) .Include(sp => sp.CreatedBy).ThenInclude(e => e!.JobRole)
.Where(sp => sp.TenantId == tenantId && sp.IsActive).FirstOrDefaultAsync(); .Where(sp => sp.Id == id && sp.TenantId == tenantId && sp.IsActive)
.FirstOrDefaultAsync();
}); });
var servicesTask = Task.Run(async () => var servicesTask = Task.Run(async () =>
{ {
await using var context = await _dbContextFactory.CreateDbContextAsync(); await using var context = await _dbContextFactory.CreateDbContextAsync();
@ -313,50 +444,67 @@ namespace Marco.Pms.Services.Service
await Task.WhenAll(serviceProjectTask, servicesTask); await Task.WhenAll(serviceProjectTask, servicesTask);
serviceProject = serviceProjectTask.Result; serviceProject = serviceProjectTask.Result;
services = servicesTask.Result; services = servicesTask.Result;
ServiceProjectVM serviceProjectVm = _mapper.Map<ServiceProjectVM>(serviceProject); var projectVm = _mapper.Map<ServiceProjectVM>(serviceProject);
serviceProjectVm.Services = _mapper.Map<List<ServiceMasterVM>>(services); projectVm.Services = _mapper.Map<List<ServiceMasterVM>>(services);
return ApiResponse<object>.SuccessResponse(serviceProjectVm, "Service Project updated successfully.", 200); _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) catch (Exception ex)
{ {
_logger.LogError(ex, "An unexpected error occurred in Updating Service Project for tenant {TenantId}.", tenantId); _logger.LogError(ex, "Unexpected error updating service project {ProjectId} for tenant {TenantId} by employee {EmployeeId}", id, tenantId, loggedInEmployee.Id);
return ApiResponse<object>.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); 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) 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 try
{ {
// Load the service project scoped by tenant and ID
var serviceProject = await _context.ServiceProjects var serviceProject = await _context.ServiceProjects
.Where(sp => sp.Id == id && sp.TenantId == tenantId).FirstOrDefaultAsync(); .FirstOrDefaultAsync(sp => sp.Id == id && sp.TenantId == tenantId);
if (serviceProject == null) if (serviceProject == null)
{ {
_logger.LogWarning("Attempt to de-activate non-existent Service project with ID {ProjectId} by user {UserId}.", id, loggedInEmployee.Id); _logger.LogWarning("Attempt to {(Action)} non-existent service project {ProjectId} by employee {EmployeeId} in tenant {TenantId}",
return ApiResponse<object>.ErrorResponse("Project not found.", $"No project found with ID {id}.", 404); isActive ? "activate" : "deactivate", id, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("Project Not Found", $"No project found with ID {id}.", 404);
} }
// Update active status as requested by the client
serviceProject.IsActive = isActive; serviceProject.IsActive = isActive;
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
_logger.LogInfo("Successfully de-activated service project {ProjectId}", id); _logger.LogInfo("Service project {ProjectId} {(Action)}d successfully by employee {EmployeeId} in tenant {TenantId}",
return ApiResponse<object>.SuccessResponse(new { }, "Projects de-activated successfully.", 200); id, isActive ? "activate" : "deactivate", loggedInEmployee.Id, tenantId);
return ApiResponse<object>.SuccessResponse(new { }, $"Project {(isActive ? "activated" : "deactivated")} successfully.", 200);
} }
catch (Exception ex) catch (Exception ex)
{ {
// --- Step 5: Graceful Error Handling --- _logger.LogError(ex, "Error occurred while {(Action)} service project {ProjectId} by employee {EmployeeId} in tenant {TenantId}",
_logger.LogError(ex, "An unexpected error occurred in DeActivateServiceProject for tenant {TenantId}.", tenantId); isActive ? "activating" : "deactivating", id, loggedInEmployee.Id, tenantId);
return ApiResponse<object>.ErrorResponse("An internal server error occurred. Please try again later.", null, 500); return ApiResponse<object>.ErrorResponse("Internal Server Error", "An internal error occurred, please try again later.", 500);
} }
} }
@ -430,7 +578,6 @@ namespace Marco.Pms.Services.Service
} }
} }
/// <summary> /// <summary>
/// Manages service project allocations by adding new active allocations and removing inactive ones. /// Manages service project allocations by adding new active allocations and removing inactive ones.
/// Validates projects, employees, and team roles exist before applying changes. /// Validates projects, employees, and team roles exist before applying changes.
@ -1367,6 +1514,7 @@ namespace Marco.Pms.Services.Service
} }
#endregion #endregion
#region =================================================================== Job Comments Functions =================================================================== #region =================================================================== Job Comments Functions ===================================================================
/// <summary> /// <summary>
@ -1615,7 +1763,8 @@ namespace Marco.Pms.Services.Service
} }
#endregion #endregion
#region =================================================================== Pubic Helper Functions ===================================================================
#region =================================================================== Helper Functions ===================================================================
/// <summary> /// <summary>
/// Retrieves a job ticket by its unique identifier and associated tenant ID. /// Retrieves a job ticket by its unique identifier and associated tenant ID.
@ -1652,7 +1801,6 @@ namespace Marco.Pms.Services.Service
return null; return null;
} }
} }
private async Task<JobStatusMapping?> GetJobStatusMappingAsync(Guid statusId, Guid nextStatusId, Guid projectId, Guid loggedInEmployeeId, Guid tenantId) 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 // Find transition mappings for the current status and desired status, considering team role if allocation exists