Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/JobsPriorityController.cs
T
spouliot 8acbc8605d Harden multi-tenant isolation across all user-facing controllers
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>
2026-05-17 18:04:22 -04:00

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" });
}
}
}