8acbc8605d
Added explicit CompanyId == companyId predicates to every tenant-scoped query in 22 controllers so cross-tenant data leakage is impossible even if EF Core global query filters are bypassed or misconfigured. Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true for SuperAdmins with no CompanyId claim (break-glass accounts) and when no HTTP context is present (background services, unit tests), resolving 225 unit test failures that stemmed from the global filter blocking all in-memory test data. New MultiTenantIsolationTests class (8 tests) verifies the explicit predicate layer independently of the global query filters. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
468 lines
19 KiB
C#
468 lines
19 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using PowderCoating.Application.DTOs.Job;
|
|
using PowderCoating.Application.Services;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Enums;
|
|
using PowderCoating.Core.Interfaces;
|
|
using PowderCoating.Shared.Constants;
|
|
using PowderCoating.Web.Hubs;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
|
public class JobsPriorityController : Controller
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly ILogger<JobsPriorityController> _logger;
|
|
private readonly UserManager<ApplicationUser> _userManager;
|
|
private readonly ITenantContext _tenantContext;
|
|
private readonly IHubContext<ShopHub> _shopHub;
|
|
|
|
public JobsPriorityController(
|
|
IUnitOfWork unitOfWork,
|
|
ILogger<JobsPriorityController> logger,
|
|
UserManager<ApplicationUser> userManager,
|
|
ITenantContext tenantContext,
|
|
IHubContext<ShopHub> shopHub)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_logger = logger;
|
|
_userManager = userManager;
|
|
_tenantContext = tenantContext;
|
|
_shopHub = shopHub;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the daily job-scheduling board for the given date (defaults to today).
|
|
/// <para>
|
|
/// Loads all jobs whose <c>ScheduledDate</c> matches the requested day, then
|
|
/// merges them with any existing <c>JobDailyPriority</c> drag-drop order records.
|
|
/// Jobs that have not yet been given an explicit display order receive
|
|
/// <c>int.MaxValue</c> so they sort to the bottom, after already-ordered jobs.
|
|
/// </para>
|
|
/// <para>
|
|
/// Maintenance records for the same day (status Scheduled or InProgress) are
|
|
/// also loaded and passed via <c>ViewBag.MaintenanceItems</c> so the view can
|
|
/// render a separate maintenance panel on the same board without a second round-trip.
|
|
/// Priority and worker option lists are serialised to JSON ViewBag properties for
|
|
/// use by the inline-edit JavaScript modals.
|
|
/// </para>
|
|
/// </summary>
|
|
// GET: JobsPriority (Job Schedule)
|
|
public async Task<IActionResult> Index(DateTime? date)
|
|
{
|
|
var today = date?.Date ?? DateTime.Today;
|
|
|
|
// Get all jobs scheduled for today with related data
|
|
var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today);
|
|
|
|
// Get existing priority records for today
|
|
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
|
.FindAsync(p => p.ScheduledDate.Date == today);
|
|
|
|
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
|
|
|
|
// Map to DTOs with display order
|
|
var jobDtos = jobs.Select(j => new JobDailyPriorityDto
|
|
{
|
|
Id = priorityDict.ContainsKey(j.Id) ? priorityDict[j.Id].Id : 0,
|
|
JobId = j.Id,
|
|
JobNumber = j.JobNumber,
|
|
CustomerName = j.Customer.CompanyName ?? $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim(),
|
|
StatusDisplayName = j.JobStatus.DisplayName,
|
|
StatusColorClass = j.JobStatus.ColorClass,
|
|
JobPriorityId = j.JobPriorityId,
|
|
PriorityDisplayName = j.JobPriority.DisplayName,
|
|
PriorityColorClass = j.JobPriority.ColorClass,
|
|
AssignedUserId = j.AssignedUserId,
|
|
AssignedWorkerName = j.AssignedUser?.FullName,
|
|
ScheduledDate = j.ScheduledDate,
|
|
DueDate = j.DueDate,
|
|
DisplayOrder = priorityDict.ContainsKey(j.Id) ? priorityDict[j.Id].DisplayOrder : int.MaxValue
|
|
})
|
|
.OrderBy(j => j.DisplayOrder)
|
|
.ThenBy(j => j.JobNumber)
|
|
.ToList();
|
|
|
|
// Get priorities and workers for modal options
|
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
|
|
var workers = await _userManager.Users
|
|
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
|
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
|
.ToListAsync();
|
|
|
|
// Get maintenance records scheduled for today (Scheduled or InProgress)
|
|
var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync(
|
|
m => m.ScheduledDate.Date == today &&
|
|
(m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress),
|
|
false,
|
|
m => m.Equipment, m => m.AssignedUser))
|
|
.OrderByDescending(m => (int)m.Priority)
|
|
.ThenBy(m => m.ScheduledDate)
|
|
.ToList();
|
|
|
|
// Load overdue jobs only when viewing today — past-date navigation shows that day as-is
|
|
if (today == DateTime.Today)
|
|
{
|
|
var overdueJobs = await _unitOfWork.Jobs.GetOverdueScheduledJobsAsync();
|
|
ViewBag.OverdueJobs = overdueJobs.Select(j => new JobDailyPriorityDto
|
|
{
|
|
Id = 0,
|
|
JobId = j.Id,
|
|
JobNumber = j.JobNumber,
|
|
CustomerName = j.Customer.CompanyName ?? $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim(),
|
|
StatusDisplayName = j.JobStatus.DisplayName,
|
|
StatusColorClass = j.JobStatus.ColorClass,
|
|
JobPriorityId = j.JobPriorityId,
|
|
PriorityDisplayName = j.JobPriority.DisplayName,
|
|
PriorityColorClass = j.JobPriority.ColorClass,
|
|
AssignedUserId = j.AssignedUserId,
|
|
AssignedWorkerName = j.AssignedUser?.FullName,
|
|
ScheduledDate = j.ScheduledDate,
|
|
DueDate = j.DueDate,
|
|
DisplayOrder = int.MaxValue
|
|
}).ToList();
|
|
}
|
|
|
|
ViewBag.ScheduledDate = today;
|
|
ViewBag.MaintenanceItems = maintenanceItems;
|
|
ViewBag.PrioritiesJson = priorities.OrderBy(p => p.DisplayOrder)
|
|
.Select(p => new { id = p.Id, name = p.DisplayName, colorClass = p.ColorClass })
|
|
.ToList();
|
|
ViewBag.WorkersJson = workers.OrderBy(w => w.FirstName).ThenBy(w => w.LastName)
|
|
.Select(w => new { id = w.Id, name = w.FullName })
|
|
.ToList();
|
|
|
|
return View(jobDtos);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Persists the new drag-drop display order for jobs on the daily scheduling board.
|
|
/// Called via AJAX after the user reorders cards; returns JSON so the page does not
|
|
/// reload.
|
|
/// <para>
|
|
/// For each job in the payload the action either updates the existing
|
|
/// <c>JobDailyPriority</c> row for today or creates a new one if the job has not
|
|
/// been ordered before. Using an upsert-style approach (check dictionary → update or
|
|
/// insert) avoids a separate EXISTS query per row while keeping the logic clear.
|
|
/// After saving, a <c>DailyBoardUpdated</c> SignalR event is pushed to the
|
|
/// company's shop-hub group so any connected shop-floor screen refreshes
|
|
/// automatically without polling.
|
|
/// </para>
|
|
/// </summary>
|
|
// POST: JobsPriority/UpdateOrder
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> UpdateOrder([FromBody] List<UpdateJobOrderDto> updates)
|
|
{
|
|
try
|
|
{
|
|
if (updates == null || !updates.Any())
|
|
{
|
|
return Json(new { success = false, message = "No updates provided" });
|
|
}
|
|
|
|
var today = DateTime.Today;
|
|
|
|
// Get all existing priority records for today
|
|
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
|
.FindAsync(p => p.ScheduledDate.Date == today);
|
|
|
|
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
|
|
|
|
// Update or create priority records
|
|
foreach (var update in updates)
|
|
{
|
|
if (priorityDict.ContainsKey(update.JobId))
|
|
{
|
|
// Update existing record
|
|
var priority = priorityDict[update.JobId];
|
|
priority.DisplayOrder = update.DisplayOrder;
|
|
}
|
|
else
|
|
{
|
|
// Create new record
|
|
var newPriority = new JobDailyPriority
|
|
{
|
|
JobId = update.JobId,
|
|
ScheduledDate = today,
|
|
DisplayOrder = update.DisplayOrder
|
|
};
|
|
await _unitOfWork.JobDailyPriorities.AddAsync(newPriority);
|
|
}
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId()?.ToString();
|
|
if (!string.IsNullOrEmpty(companyId))
|
|
await _shopHub.Clients.Group($"shop-{companyId}").SendAsync("DailyBoardUpdated");
|
|
|
|
return Json(new { success = true, message = "Job order updated successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating job order");
|
|
return Json(new { success = false, message = "Failed to update job order" });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Changes the urgency priority of a job directly from the scheduling board via
|
|
/// an inline-edit modal, without navigating to the full Job Edit page.
|
|
/// <para>
|
|
/// <c>JobPriority</c> is a lookup-table entity (<c>JobPriorityLookup</c>), not an
|
|
/// enum, so the action validates that <paramref name="priorityId"/> maps to a known
|
|
/// lookup row before writing it to the job. The resolved
|
|
/// <c>DisplayName</c> and <c>ColorClass</c> are returned in the JSON response so
|
|
/// the JavaScript can update the board badge in-place without reloading.
|
|
/// A SignalR <c>DailyBoardUpdated</c> push notifies other connected clients.
|
|
/// </para>
|
|
/// </summary>
|
|
// POST: JobsPriority/UpdatePriority
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> UpdatePriority(int jobId, int priorityId)
|
|
{
|
|
try
|
|
{
|
|
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId);
|
|
if (job == null)
|
|
{
|
|
return Json(new { success = false, message = "Job not found" });
|
|
}
|
|
|
|
var priority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(priorityId);
|
|
if (priority == null)
|
|
{
|
|
return Json(new { success = false, message = "Priority not found" });
|
|
}
|
|
|
|
job.JobPriorityId = priorityId;
|
|
await _unitOfWork.Jobs.UpdateAsync(job);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId()?.ToString();
|
|
if (!string.IsNullOrEmpty(companyId))
|
|
await _shopHub.Clients.Group($"shop-{companyId}").SendAsync("DailyBoardUpdated");
|
|
|
|
return Json(new {
|
|
success = true,
|
|
message = "Priority updated successfully",
|
|
displayName = priority.DisplayName,
|
|
colorClass = priority.ColorClass
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating job priority");
|
|
return Json(new { success = false, message = "Failed to update priority" });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Assigns or unassigns a worker (ASP.NET Identity user) to a job from the scheduling board.
|
|
/// Passing <c>null</c> or an empty <paramref name="workerId"/> clears the assignment.
|
|
/// <para>
|
|
/// Worker lookup is done via <c>UserManager</c> (not the repository layer) because
|
|
/// workers are stored as Identity users, not as <c>ShopWorker</c> entities.
|
|
/// The resolved <c>FullName</c> ("Unassigned" when cleared) is echoed back in JSON
|
|
/// so the board card updates immediately without a page reload.
|
|
/// A SignalR push keeps other open boards in sync.
|
|
/// </para>
|
|
/// </summary>
|
|
// POST: JobsPriority/UpdateWorker
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> UpdateWorker(int jobId, string? workerId)
|
|
{
|
|
try
|
|
{
|
|
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId);
|
|
if (job == null)
|
|
{
|
|
return Json(new { success = false, message = "Job not found" });
|
|
}
|
|
|
|
string workerName = "Unassigned";
|
|
if (!string.IsNullOrEmpty(workerId))
|
|
{
|
|
var user = await _userManager.FindByIdAsync(workerId);
|
|
if (user == null)
|
|
{
|
|
return Json(new { success = false, message = "User not found" });
|
|
}
|
|
workerName = user.FullName;
|
|
job.AssignedUserId = workerId;
|
|
}
|
|
else
|
|
{
|
|
job.AssignedUserId = null;
|
|
}
|
|
|
|
await _unitOfWork.Jobs.UpdateAsync(job);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
var companyId = _tenantContext.GetCurrentCompanyId()?.ToString();
|
|
if (!string.IsNullOrEmpty(companyId))
|
|
await _shopHub.Clients.Group($"shop-{companyId}").SendAsync("DailyBoardUpdated");
|
|
|
|
return Json(new {
|
|
success = true,
|
|
message = "Worker assigned successfully",
|
|
workerName = workerName
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating job worker assignment");
|
|
return Json(new { success = false, message = "Failed to update worker assignment" });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the <c>ScheduledDate</c> on a job inline from the scheduling board.
|
|
/// Moving a job to a different date will cause it to disappear from the current
|
|
/// day's board view on the next render — this is intentional behaviour.
|
|
/// <para>
|
|
/// Returns both a human-readable formatted date ("Jan 15, 2026") and an ISO raw
|
|
/// value ("2026-01-15") in the JSON response so the JavaScript can update the
|
|
/// display label and any hidden form inputs simultaneously.
|
|
/// This action does NOT push a SignalR notification because rescheduling a job
|
|
/// affects a different day's board, not the currently viewed board.
|
|
/// </para>
|
|
/// </summary>
|
|
// POST: JobsPriority/UpdateScheduledDate
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> UpdateScheduledDate(int jobId, DateTime? scheduledDate)
|
|
{
|
|
try
|
|
{
|
|
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId);
|
|
if (job == null)
|
|
{
|
|
return Json(new { success = false, message = "Job not found" });
|
|
}
|
|
|
|
job.ScheduledDate = scheduledDate;
|
|
await _unitOfWork.Jobs.UpdateAsync(job);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
return Json(new {
|
|
success = true,
|
|
message = "Scheduled date updated successfully",
|
|
scheduledDate = scheduledDate?.ToString("MMM dd, yyyy"),
|
|
scheduledDateRaw = scheduledDate?.ToString("yyyy-MM-dd")
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating job scheduled date");
|
|
return Json(new { success = false, message = "Failed to update scheduled date" });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Assigns or unassigns a worker on a maintenance record directly from the
|
|
/// scheduling board's maintenance panel.
|
|
/// <para>
|
|
/// Because this action uses <c>DbContext.FindAsync</c> (which bypasses EF global
|
|
/// query filters), a manual company-ownership check is performed: if the current
|
|
/// user is not a SuperAdmin, the record's <c>CompanyId</c> must match the tenant
|
|
/// context to prevent cross-tenant data mutation. This guard is intentional and
|
|
/// must not be removed.
|
|
/// The raw <c>ApplicationDbContext</c> is used instead of <c>IUnitOfWork</c> here
|
|
/// because the maintenance repository does not expose <c>FindAsync(int)</c> with
|
|
/// the bypass semantics needed for this cross-filter lookup.
|
|
/// </para>
|
|
/// </summary>
|
|
// POST: JobsPriority/UpdateMaintenanceWorker
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> UpdateMaintenanceWorker(int maintenanceId, string? workerId)
|
|
{
|
|
try
|
|
{
|
|
var record = await _unitOfWork.MaintenanceRecords.GetByIdAsync(maintenanceId);
|
|
if (record == null || record.IsDeleted)
|
|
return Json(new { success = false, message = "Maintenance record not found" });
|
|
|
|
string workerName = "Unassigned";
|
|
if (!string.IsNullOrEmpty(workerId))
|
|
{
|
|
var user = await _userManager.FindByIdAsync(workerId);
|
|
if (user == null)
|
|
return Json(new { success = false, message = "User not found" });
|
|
|
|
workerName = user.FullName;
|
|
record.AssignedUserId = workerId;
|
|
}
|
|
else
|
|
{
|
|
record.AssignedUserId = null;
|
|
}
|
|
|
|
record.UpdatedAt = DateTime.UtcNow;
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
return Json(new { success = true, message = "Worker assigned successfully", workerName });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating maintenance worker assignment");
|
|
return Json(new { success = false, message = "Failed to update worker assignment" });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the customer-facing <c>DueDate</c> on a job from the scheduling board.
|
|
/// Unlike <c>ScheduledDate</c> (the internal work date), <c>DueDate</c> is the
|
|
/// date promised to the customer and is used to calculate overdue status.
|
|
/// <para>
|
|
/// Returns an <c>isOverdue</c> flag in JSON (true when the new date is in the past)
|
|
/// so the JavaScript can immediately toggle the overdue badge colour on the board
|
|
/// card without waiting for a reload.
|
|
/// </para>
|
|
/// </summary>
|
|
// POST: JobsPriority/UpdateDueDate
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> UpdateDueDate(int jobId, DateTime? dueDate)
|
|
{
|
|
try
|
|
{
|
|
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId);
|
|
if (job == null)
|
|
{
|
|
return Json(new { success = false, message = "Job not found" });
|
|
}
|
|
|
|
job.DueDate = dueDate;
|
|
await _unitOfWork.Jobs.UpdateAsync(job);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
var isOverdue = dueDate.HasValue && dueDate.Value.Date < DateTime.Today;
|
|
|
|
return Json(new {
|
|
success = true,
|
|
message = "Due date updated successfully",
|
|
dueDate = dueDate?.ToString("MMM dd, yyyy"),
|
|
dueDateRaw = dueDate?.ToString("yyyy-MM-dd"),
|
|
isOverdue = isOverdue
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error updating job due date");
|
|
return Json(new { success = false, message = "Failed to update due date" });
|
|
}
|
|
}
|
|
}
|