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>
327 lines
14 KiB
C#
327 lines
14 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using PowderCoating.Shared.Constants;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Interfaces;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
/// <summary>
|
|
/// Manages reusable job templates that pre-populate job items, coats, and prep services when a
|
|
/// new job is created. Templates are created either from scratch (Edit form) or by snapshotting an
|
|
/// existing job via <see cref="SaveJobAsTemplate"/>. A <c>UsageCount</c> field tracks how often
|
|
/// each template is applied, allowing admins to identify the most-used service configurations.
|
|
/// Template items store the same coating and prep-service structure as live job items, so the
|
|
/// full item wizard state can be replayed client-side on job creation without additional queries.
|
|
/// </summary>
|
|
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
|
public class JobTemplatesController : Controller
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly ITenantContext _tenantContext;
|
|
|
|
public JobTemplatesController(
|
|
IUnitOfWork unitOfWork,
|
|
ITenantContext tenantContext)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_tenantContext = tenantContext;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Displays all non-deleted job templates for the current company, ordered by name, with their
|
|
/// linked customer and item counts. Multi-tenancy and soft-delete scoping are handled by global
|
|
/// query filters; the typed repository provides the ThenInclude chain for items.
|
|
/// </summary>
|
|
public async Task<IActionResult> Index()
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
var templates = await _unitOfWork.JobTemplates.FindAsync(
|
|
t => t.CompanyId == companyId,
|
|
false,
|
|
t => t.Customer,
|
|
t => t.Items);
|
|
|
|
templates = templates.OrderBy(t => t.Name).ToList();
|
|
return View(templates);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shows the full template detail including all non-deleted items, each with their coats
|
|
/// (including the linked inventory item for color/powder info) and prep services (including the
|
|
/// prep service entity for the service name). Soft-deleted items, coats, and prep services are
|
|
/// excluded via filtered includes in the typed repository.
|
|
/// </summary>
|
|
public async Task<IActionResult> Details(int id)
|
|
{
|
|
var template = await _unitOfWork.JobTemplates.LoadForDetailsAsync(id);
|
|
if (template == null) return NotFound();
|
|
return View(template);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the Edit form for a template's header fields (name, description, linked customer,
|
|
/// special instructions, active flag). Template items are managed separately via the item wizard
|
|
/// on the job creation page; only the header metadata is editable here to keep the form simple.
|
|
/// </summary>
|
|
public async Task<IActionResult> Edit(int id)
|
|
{
|
|
var template = await _unitOfWork.JobTemplates.GetByIdAsync(id);
|
|
if (template == null) return NotFound();
|
|
|
|
await PopulateCustomerDropdown(template.CustomerId);
|
|
return View(template);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves changes to a template's header fields. Uses individual scalar parameters instead of a
|
|
/// DTO because only a handful of simple fields are editable here; a dedicated DTO would add
|
|
/// boilerplate for minimal benefit given the small scope of this form.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Edit(int id, string name, string? description, int? customerId, string? specialInstructions, bool isActive)
|
|
{
|
|
var template = await _unitOfWork.JobTemplates.GetByIdAsync(id);
|
|
if (template == null) return NotFound();
|
|
|
|
template.Name = name;
|
|
template.Description = description;
|
|
template.CustomerId = customerId;
|
|
template.SpecialInstructions = specialInstructions;
|
|
template.IsActive = isActive;
|
|
template.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _unitOfWork.JobTemplates.UpdateAsync(template);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
TempData["Success"] = "Template updated successfully.";
|
|
return RedirectToAction(nameof(Details), new { id });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft-deletes a job template. Existing jobs created from this template are unaffected because
|
|
/// job items are copied at creation time (not linked by FK), so removing the template does not
|
|
/// alter any live jobs.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Delete(int id)
|
|
{
|
|
await _unitOfWork.JobTemplates.SoftDeleteAsync(id);
|
|
await _unitOfWork.CompleteAsync();
|
|
TempData["Success"] = "Template deleted.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Snapshots an existing job into a new reusable template by deep-copying its items, coats, and
|
|
/// prep services. Each template item is saved and immediately given an ID before its coats and
|
|
/// prep services are inserted, because the child records need <c>JobTemplateItemId</c> as a FK.
|
|
/// This is why <c>CompleteAsync()</c> is called inside the item loop rather than once at the end.
|
|
/// The source job's <c>CustomerId</c> and <c>SpecialInstructions</c> are copied to the template
|
|
/// so that the template can pre-fill those fields when used; they can be overridden at job
|
|
/// creation time.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> SaveJobAsTemplate(int jobId, string templateName, string? templateDescription)
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
|
|
var job = await _unitOfWork.Jobs.LoadForTemplateSnapshotAsync(jobId);
|
|
|
|
if (job == null) return NotFound();
|
|
|
|
var template = new JobTemplate
|
|
{
|
|
Name = templateName.Trim(),
|
|
Description = templateDescription?.Trim(),
|
|
CustomerId = job.CustomerId,
|
|
SpecialInstructions = job.SpecialInstructions,
|
|
IsActive = true,
|
|
CompanyId = companyId,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
await _unitOfWork.JobTemplates.AddAsync(template);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
// Copy items
|
|
int displayOrder = 1;
|
|
foreach (var item in job.JobItems.OrderBy(i => i.Id))
|
|
{
|
|
var templateItem = new JobTemplateItem
|
|
{
|
|
JobTemplateId = template.Id,
|
|
Description = item.Description,
|
|
Quantity = item.Quantity,
|
|
SurfaceAreaSqFt = item.SurfaceAreaSqFt,
|
|
CatalogItemId = item.CatalogItemId,
|
|
IsGenericItem = item.IsGenericItem,
|
|
IsLaborItem = item.IsLaborItem,
|
|
IsSalesItem = item.IsSalesItem,
|
|
Sku = item.Sku,
|
|
ManualUnitPrice = item.ManualUnitPrice,
|
|
RequiresSandblasting = item.RequiresSandblasting,
|
|
RequiresMasking = item.RequiresMasking,
|
|
IncludePrepCost = item.IncludePrepCost,
|
|
EstimatedMinutes = item.EstimatedMinutes,
|
|
Complexity = item.Complexity,
|
|
Notes = item.Notes,
|
|
DisplayOrder = displayOrder++,
|
|
CompanyId = companyId,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
await _unitOfWork.JobTemplateItems.AddAsync(templateItem);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
// Copy coats
|
|
foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
|
|
{
|
|
await _unitOfWork.JobTemplateItemCoats.AddAsync(new JobTemplateItemCoat
|
|
{
|
|
JobTemplateItemId = templateItem.Id,
|
|
CoatName = coat.CoatName,
|
|
Sequence = coat.Sequence,
|
|
InventoryItemId = coat.InventoryItemId,
|
|
ColorName = coat.ColorName,
|
|
VendorId = coat.VendorId,
|
|
ColorCode = coat.ColorCode,
|
|
Finish = coat.Finish,
|
|
CoverageSqFtPerLb = coat.CoverageSqFtPerLb,
|
|
TransferEfficiency = coat.TransferEfficiency,
|
|
PowderCostPerLb = coat.PowderCostPerLb,
|
|
Notes = coat.Notes,
|
|
CompanyId = companyId,
|
|
CreatedAt = DateTime.UtcNow
|
|
});
|
|
}
|
|
|
|
// Copy prep services
|
|
foreach (var prep in item.PrepServices)
|
|
{
|
|
await _unitOfWork.JobTemplateItemPrepServices.AddAsync(new JobTemplateItemPrepService
|
|
{
|
|
JobTemplateItemId = templateItem.Id,
|
|
PrepServiceId = prep.PrepServiceId,
|
|
EstimatedMinutes = prep.EstimatedMinutes,
|
|
CompanyId = companyId,
|
|
CreatedAt = DateTime.UtcNow
|
|
});
|
|
}
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
TempData["Success"] = $"Template \"{template.Name}\" saved successfully.";
|
|
return RedirectToAction(nameof(Details), new { id = template.Id });
|
|
}
|
|
|
|
/// <summary>
|
|
/// AJAX endpoint that returns all active templates for the current company as JSON, fully
|
|
/// expanded with items, coats, and prep services. This payload is consumed by the job creation
|
|
/// page's JavaScript to hydrate the item wizard when the user selects a template, so it must
|
|
/// contain every field the wizard needs to reconstruct the full item state client-side without
|
|
/// additional round-trips. Only active templates are returned so deactivated templates are no
|
|
/// longer selectable on new jobs.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetTemplatesJson()
|
|
{
|
|
var templates = await _unitOfWork.JobTemplates.GetAllActiveWithFullIncludesAsync();
|
|
|
|
var result = templates.Select(t => new
|
|
{
|
|
id = t.Id,
|
|
name = t.Name,
|
|
description = t.Description,
|
|
customerId = t.CustomerId,
|
|
customerName = t.Customer != null
|
|
? (t.Customer.CompanyName ?? $"{t.Customer.ContactFirstName} {t.Customer.ContactLastName}".Trim())
|
|
: null,
|
|
specialInstructions = t.SpecialInstructions,
|
|
usageCount = t.UsageCount,
|
|
items = t.Items.OrderBy(i => i.DisplayOrder).Select(i => new
|
|
{
|
|
description = i.Description,
|
|
quantity = i.Quantity,
|
|
surfaceAreaSqFt = i.SurfaceAreaSqFt,
|
|
catalogItemId = i.CatalogItemId,
|
|
isGenericItem = i.IsGenericItem,
|
|
isLaborItem = i.IsLaborItem,
|
|
isSalesItem = i.IsSalesItem,
|
|
sku = i.Sku,
|
|
manualUnitPrice = i.ManualUnitPrice,
|
|
requiresSandblasting = i.RequiresSandblasting,
|
|
requiresMasking = i.RequiresMasking,
|
|
includePrepCost = i.IncludePrepCost,
|
|
estimatedMinutes = i.EstimatedMinutes,
|
|
complexity = i.Complexity,
|
|
coats = i.Coats.OrderBy(c => c.Sequence).Select(c => new
|
|
{
|
|
coatName = c.CoatName,
|
|
sequence = c.Sequence,
|
|
inventoryItemId = c.InventoryItemId,
|
|
colorName = c.ColorName,
|
|
colorCode = c.ColorCode,
|
|
finish = c.Finish,
|
|
coverageSqFtPerLb = c.CoverageSqFtPerLb,
|
|
transferEfficiency = c.TransferEfficiency,
|
|
powderCostPerLb = c.PowderCostPerLb
|
|
}),
|
|
prepServices = i.PrepServices.Select(p => new
|
|
{
|
|
prepServiceId = p.PrepServiceId,
|
|
prepServiceName = p.PrepService?.ServiceName,
|
|
estimatedMinutes = p.EstimatedMinutes
|
|
})
|
|
})
|
|
});
|
|
|
|
return Json(result);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Increments the <c>UsageCount</c> on a template when a job is created from it. Called via
|
|
/// AJAX from the job creation page immediately after the job is saved. Silently succeeds even
|
|
/// if the template no longer exists (soft-deleted between page load and job save) to avoid
|
|
/// blocking the job creation flow over a non-critical analytics update.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> IncrementUsage(int id)
|
|
{
|
|
var template = await _unitOfWork.JobTemplates.GetByIdAsync(id);
|
|
if (template != null)
|
|
{
|
|
template.UsageCount++;
|
|
template.UpdatedAt = DateTime.UtcNow;
|
|
await _unitOfWork.JobTemplates.UpdateAsync(template);
|
|
await _unitOfWork.CompleteAsync();
|
|
}
|
|
return Ok();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Populates <c>ViewBag.Customers</c> with a <see cref="SelectList"/> of active customers in the
|
|
/// current company, used by the Edit form's optional customer association. The customer link on a
|
|
/// template pre-fills the customer field on new jobs created from that template, but it is
|
|
/// optional — templates representing generic services (e.g. "Standard Wheel Coat") are not tied
|
|
/// to any specific customer.
|
|
/// </summary>
|
|
private async Task PopulateCustomerDropdown(int? selectedId = null)
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
var customers = await _unitOfWork.Customers.FindAsync(
|
|
c => c.CompanyId == companyId && !c.IsDeleted);
|
|
|
|
ViewBag.Customers = new SelectList(
|
|
customers.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
|
|
.Select(c => new { c.Id, Name = c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim() }),
|
|
"Id", "Name", selectedId);
|
|
}
|
|
}
|