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>
173 lines
7.0 KiB
C#
173 lines
7.0 KiB
C#
using AutoMapper;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using PowderCoating.Shared.Constants;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using PowderCoating.Application.DTOs.Customer;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Interfaces;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
|
public class PricingTiersController : Controller
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly IMapper _mapper;
|
|
private readonly ILogger<PricingTiersController> _logger;
|
|
private readonly ITenantContext _tenantContext;
|
|
|
|
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger, ITenantContext tenantContext)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_mapper = mapper;
|
|
_logger = logger;
|
|
_tenantContext = tenantContext;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lists all pricing tiers with a live customer-count badge per tier. Sorted active-first then by discount ascending so the most relevant tiers appear at the top. Customer counts are computed in memory after a single query to avoid N+1 round-trips.
|
|
/// </summary>
|
|
public async Task<IActionResult> Index()
|
|
{
|
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId);
|
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
|
|
|
var customerCountByTier = customers
|
|
.Where(c => c.PricingTierId.HasValue)
|
|
.GroupBy(c => c.PricingTierId!.Value)
|
|
.ToDictionary(g => g.Key, g => g.Count());
|
|
|
|
var dtos = _mapper.Map<List<PricingTierDto>>(tiers);
|
|
foreach (var dto in dtos)
|
|
dto.CustomerCount = customerCountByTier.GetValueOrDefault(dto.Id, 0);
|
|
|
|
// Sort: active first, then by discount ascending
|
|
dtos = dtos.OrderByDescending(t => t.IsActive).ThenBy(t => t.DiscountPercent).ToList();
|
|
|
|
return View(dtos);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shows the create form, pre-populating IsActive to true so new tiers are immediately usable without an extra toggle.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public IActionResult Create()
|
|
{
|
|
return View(new CreatePricingTierDto { IsActive = true });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Persists a new pricing tier after validating that the tier name is unique within the company. Duplicate-name checks are done at the application layer because the DB unique index alone would produce a cryptic SQL error.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Create(CreatePricingTierDto dto)
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return View(dto);
|
|
|
|
// Check for duplicate name
|
|
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.TierName == dto.TierName);
|
|
if (existing.Any())
|
|
{
|
|
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
|
|
return View(dto);
|
|
}
|
|
|
|
var entity = _mapper.Map<PricingTier>(dto);
|
|
await _unitOfWork.PricingTiers.AddAsync(entity);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Pricing tier '{TierName}' created (ID: {Id})", entity.TierName, entity.Id);
|
|
TempData["SuccessMessage"] = $"Pricing tier '{entity.TierName}' created successfully.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shows the edit form for an existing pricing tier, mapping the entity to an UpdatePricingTierDto so the view is decoupled from the entity.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> Edit(int id)
|
|
{
|
|
var entity = await _unitOfWork.PricingTiers.GetByIdAsync(id);
|
|
if (entity == null) return NotFound();
|
|
|
|
var dto = _mapper.Map<UpdatePricingTierDto>(entity);
|
|
return View(dto);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves edits to an existing pricing tier. Duplicate-name check excludes the current record (t.Id != dto.Id) so a tier can be saved without changing its name.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Edit(UpdatePricingTierDto dto)
|
|
{
|
|
if (!ModelState.IsValid)
|
|
return View(dto);
|
|
|
|
var entity = await _unitOfWork.PricingTiers.GetByIdAsync(dto.Id);
|
|
if (entity == null) return NotFound();
|
|
|
|
// Check for duplicate name (excluding this record)
|
|
var duplicate = await _unitOfWork.PricingTiers.FindAsync(
|
|
t => t.TierName == dto.TierName && t.Id != dto.Id);
|
|
if (duplicate.Any())
|
|
{
|
|
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
|
|
return View(dto);
|
|
}
|
|
|
|
_mapper.Map(dto, entity);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Pricing tier '{TierName}' updated (ID: {Id})", entity.TierName, entity.Id);
|
|
TempData["SuccessMessage"] = $"Pricing tier '{entity.TierName}' updated successfully.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft-deletes a pricing tier. Deletion is blocked when customers are still assigned to the tier to prevent orphaned pricing references; the admin must reassign those customers first.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> Delete(int id)
|
|
{
|
|
var entity = await _unitOfWork.PricingTiers.GetByIdAsync(id);
|
|
if (entity == null) return NotFound();
|
|
|
|
// Block delete if customers are assigned to this tier
|
|
var assignedCustomers = await _unitOfWork.Customers.FindAsync(c => c.PricingTierId == id);
|
|
if (assignedCustomers.Any())
|
|
{
|
|
TempData["ErrorMessage"] = $"Cannot delete '{entity.TierName}' — {assignedCustomers.Count()} customer(s) are assigned to it. Reassign them first.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
await _unitOfWork.PricingTiers.SoftDeleteAsync(id);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
_logger.LogInformation("Pricing tier '{TierName}' deleted (ID: {Id})", entity.TierName, id);
|
|
TempData["SuccessMessage"] = $"Pricing tier '{entity.TierName}' deleted.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Toggles the IsActive flag on a pricing tier without a dedicated edit form round-trip. Inactive tiers remain in the database and on existing customer records but are hidden from new-customer assignment dropdowns.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> ToggleActive(int id)
|
|
{
|
|
var entity = await _unitOfWork.PricingTiers.GetByIdAsync(id);
|
|
if (entity == null) return NotFound();
|
|
|
|
entity.IsActive = !entity.IsActive;
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
TempData["SuccessMessage"] = $"'{entity.TierName}' marked as {(entity.IsActive ? "active" : "inactive")}.";
|
|
return RedirectToAction(nameof(Index));
|
|
}
|
|
}
|