Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/CompanySettingsController.cs
T
2026-04-23 21:38:24 -04:00

2791 lines
123 KiB
C#

using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.DTOs.Lookup;
using PowderCoating.Application.DTOs.Notification;
using PowderCoating.Application.DTOs.PrepService;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Security.Claims;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class CompanySettingsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly ILogger<CompanySettingsController> _logger;
private readonly ICompanyLogoService _logoService;
private readonly ILookupCacheService _lookupCache;
private readonly IStripeConnectService _stripeConnect;
private readonly IConfiguration _configuration;
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public CompanySettingsController(
IUnitOfWork unitOfWork,
IMapper mapper,
ITenantContext tenantContext,
ILogger<CompanySettingsController> logger,
ICompanyLogoService logoService,
ILookupCacheService lookupCache,
IStripeConnectService stripeConnect,
IConfiguration configuration,
ApplicationDbContext context,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_tenantContext = tenantContext;
_logger = logger;
_logoService = logoService;
_lookupCache = lookupCache;
_stripeConnect = stripeConnect;
_configuration = configuration;
_context = context;
_userManager = userManager;
_signInManager = signInManager;
}
/// <summary>
/// Diagnostic endpoint that dumps the current user's claims as JSON. Used during development and
/// support investigations to verify that Identity claims (CompanyId, CompanyRole, system role) are
/// correctly populated after login. Restricted to SuperAdmin so it is never exposed to tenant users.
/// </summary>
// GET: CompanySettings/TestClaims - Debug endpoint (SuperAdmin only)
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public IActionResult TestClaims()
{
var claims = User.Claims.Select(c => new { c.Type, c.Value }).ToList();
return Json(new
{
isAuthenticated = User.Identity?.IsAuthenticated,
userName = User.Identity?.Name,
claims = claims,
companyId = User.FindFirst("CompanyId")?.Value,
companyRole = User.FindFirst("CompanyRole")?.Value
});
}
/// <summary>
/// Renders the Company Settings hub page with all configuration tabs pre-loaded.
/// Resolves the tenant from <see cref="ITenantContext"/> — if the claim is missing or malformed
/// (e.g. Base-64 decode failure after a cookie format change) the user is bounced to Home rather
/// than shown a hard error. Notification templates are lazily seeded on first visit via
/// <see cref="EnsureNotificationTemplatesSeededAsync"/> so the database never starts empty.
/// The <c>AllowOnlinePayments</c> flag is read from <c>SubscriptionPlanConfig</c> (not hardcoded)
/// so plan changes take effect without a deployment.
/// </summary>
// GET: CompanySettings
public async Task<IActionResult> Index()
{
try
{
// Log user information for debugging
var userName = User.Identity?.Name;
var companyRole = User.FindFirst("CompanyRole")?.Value;
var companyIdClaim = User.FindFirst("CompanyId")?.Value;
_logger.LogInformation("CompanySettings accessed by user: {UserName}, CompanyRole: {CompanyRole}, CompanyIdClaim: {CompanyIdClaim}",
userName, companyRole, companyIdClaim);
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
_logger.LogWarning("User {UserName} does not have a company ID. CompanyRole: {CompanyRole}", userName, companyRole);
TempData["ErrorMessage"] = "Your account is not associated with a company. Please contact support.";
return RedirectToAction("Index", "Home");
}
// Load company with operating costs and preferences
var company = await _unitOfWork.Companies.GetByIdAsync(
companyId.Value,
false,
c => c.OperatingCosts,
c => c.Preferences);
if (company == null)
{
_logger.LogWarning("Company {CompanyId} not found for user {UserName}", companyId, userName);
TempData["ErrorMessage"] = "Company not found.";
return RedirectToAction("Index", "Home");
}
var dto = _mapper.Map<CompanySettingsDto>(company);
// Populate AllowOnlinePayments from subscription plan config
var planConfig = await _context.Set<SubscriptionPlanConfig>()
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
// Flag whether Stripe Connect is configured (non-placeholder client ID)
var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId)
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
// Load notification templates for inline tab
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
if (seeded > 0)
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
dto.NotificationTemplates = existing
.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
.Select(t => new NotificationTemplateDto
{
Id = t.Id,
NotificationType = t.NotificationType,
Channel = t.Channel,
DisplayName = t.DisplayName,
Subject = t.Subject,
Body = t.Body,
UpdatedAt = t.UpdatedAt
}).ToList();
return View(dto);
}
catch (FormatException fex)
{
_logger.LogError(fex, "Format exception in CompanySettings - likely Base-64 decode issue");
TempData["ErrorMessage"] = "An authentication error occurred. Please try logging out and logging back in.";
return RedirectToAction("Index", "Home");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading company settings for user {UserName}", User.Identity?.Name);
TempData["ErrorMessage"] = $"An error occurred: {ex.Message}";
return RedirectToAction("Index", "Home");
}
}
/// <summary>
/// Persists basic company profile fields (name, address, contact info, timezone) from an AJAX call.
/// Uses AutoMapper to apply only the mapped fields so unrelated Company properties
/// (logo, subscription plan, Stripe keys) are never accidentally overwritten.
/// Returns a JSON envelope so the view can display an inline success/error toast without a full reload.
/// </summary>
// POST: CompanySettings/UpdateCompanyInfo
[HttpPost]
public async Task<IActionResult> UpdateCompanyInfo([FromBody] UpdateCompanySettingsDto dto)
{
try
{
if (!ModelState.IsValid)
{
var errors = ModelState.Values
.SelectMany(v => v.Errors)
.Select(e => e.ErrorMessage)
.ToList();
return Json(new { success = false, message = string.Join(", ", errors) });
}
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "User does not have a company ID." });
}
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null)
{
return Json(new { success = false, message = "Company not found." });
}
// Update company properties
_mapper.Map(dto, company);
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} settings updated by user", companyId);
return Json(new { success = true, message = "Company information updated successfully." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating company information");
return Json(new { success = false, message = "An error occurred while updating company information." });
}
}
/// <summary>
/// Serves the current company's logo as a binary file response. Logos are stored on the filesystem
/// via <see cref="ICompanyLogoService"/> (primary) or as raw bytes in <c>Company.LogoData</c>
/// (legacy). On first access of a legacy DB-stored logo the file is automatically migrated to the
/// filesystem and the DB columns are cleared, so the migration happens transparently without a
/// separate migration script. The 1-hour response cache prevents redundant DB hits on pages that
/// display the logo repeatedly (e.g. every request to the sidebar layout).
/// </summary>
// GET: CompanySettings/Logo
[HttpGet]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByHeader = "Accept")]
public async Task<IActionResult> Logo()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return NotFound();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null) return NotFound();
// Try filesystem first
if (!string.IsNullOrEmpty(company.LogoFilePath))
{
var (success, fileContent, contentType, errorMessage) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
if (success)
{
return File(fileContent, contentType);
}
}
// Fallback to database (legacy)
if (company.LogoData != null && company.LogoData.Length > 0)
{
// Migrate to filesystem on access
var tempFile = new FormFile(
new MemoryStream(company.LogoData),
0,
company.LogoData.Length,
"logo",
$"logo{Path.GetExtension(company.LogoContentType ?? ".png")}"
);
var (migrationSuccess, filePath, _) = await _logoService.SaveCompanyLogoAsync(tempFile, companyId.Value);
if (migrationSuccess)
{
company.LogoFilePath = filePath;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Migrated company {CompanyId} logo from database to filesystem", companyId);
}
return File(company.LogoData, company.LogoContentType ?? "image/png");
}
return NotFound();
}
/// <summary>
/// Accepts a multipart logo upload, saves it to the filesystem via <see cref="ICompanyLogoService"/>,
/// and updates <c>Company.LogoFilePath</c>. Any previously stored filesystem file is deleted first
/// to prevent orphaned files accumulating over repeated uploads. The legacy DB columns
/// (<c>LogoData</c> and <c>LogoContentType</c>) are explicitly nulled on every new upload so that
/// the old binary storage path is permanently retired for this tenant.
/// Returns a JSON envelope with the new logo URL so the view can hot-swap the img src without reload.
/// </summary>
// POST: CompanySettings/UploadLogo
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadLogo(IFormFile logoFile)
{
try
{
if (logoFile == null || logoFile.Length == 0)
{
return Json(new { success = false, message = "No file was uploaded." });
}
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "User does not have a company ID." });
}
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null)
{
return Json(new { success = false, message = "Company not found." });
}
// Delete old logo from filesystem if it exists
if (!string.IsNullOrEmpty(company.LogoFilePath))
{
await _logoService.DeleteCompanyLogoAsync(company.LogoFilePath);
}
// Save new logo to filesystem
var (success, filePath, errorMessage) = await _logoService.SaveCompanyLogoAsync(logoFile, companyId.Value);
if (!success)
{
return Json(new { success = false, message = errorMessage });
}
// Update company record
company.LogoFilePath = filePath;
company.LogoData = null; // Clear legacy database storage
company.LogoContentType = null; // Clear legacy database storage
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} logo uploaded to filesystem", companyId);
return Json(new
{
success = true,
message = "Logo uploaded successfully.",
logoUrl = Url.Action("Logo", "CompanySettings")
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading company logo");
return Json(new { success = false, message = "An error occurred while uploading the logo." });
}
}
/// <summary>
/// Removes the company logo from both the filesystem and all DB columns (filesystem path and the
/// legacy binary fields). After this call the sidebar will fall back to the platform PCL logo.
/// Both storage locations are always cleared in one transaction so the DB and filesystem never
/// get out of sync regardless of which path currently holds the logo.
/// </summary>
// POST: CompanySettings/DeleteLogo
[HttpPost]
public async Task<IActionResult> DeleteLogo()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "User does not have a company ID." });
}
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null)
{
return Json(new { success = false, message = "Company not found." });
}
// Delete from filesystem if it exists
if (!string.IsNullOrEmpty(company.LogoFilePath))
{
await _logoService.DeleteCompanyLogoAsync(company.LogoFilePath);
}
// Clear database fields (both legacy and new)
company.LogoFilePath = null;
company.LogoData = null;
company.LogoContentType = null;
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} logo deleted", companyId);
return Json(new { success = true, message = "Logo deleted successfully." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting company logo");
return Json(new { success = false, message = "An error occurred while deleting the logo." });
}
}
/// <summary>
/// Generic helper that persists any <c>CompanyPreferences</c>-mapped DTO via upsert. Because all
/// preferences live on a single child entity (<see cref="CompanyPreferences"/>) that may not exist
/// for newly onboarded companies, this method creates the row if absent rather than failing with a
/// null-reference. All public preference endpoints (<see cref="UpdateAppDefaults"/>,
/// <see cref="UpdateJobDefaults"/>, <see cref="UpdateNotifications"/>, etc.) delegate here so the
/// upsert logic is maintained in one place.
/// </summary>
private async Task<IActionResult> UpdatePreferences<TDto>(TDto dto, string successMessage)
{
try
{
if (!ModelState.IsValid)
{
var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage);
return Json(new { success = false, message = string.Join(", ", errors) });
}
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "User does not have a company ID." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.Preferences);
if (company == null)
return Json(new { success = false, message = "Company not found." });
if (company.Preferences == null)
{
company.Preferences = new CompanyPreferences { CompanyId = companyId.Value };
_mapper.Map(dto, company.Preferences);
await _unitOfWork.CompanyPreferences.AddAsync(company.Preferences);
}
else
{
_mapper.Map(dto, company.Preferences);
company.Preferences.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompanyPreferences.UpdateAsync(company.Preferences);
}
await _unitOfWork.CompleteAsync();
return Json(new { success = true, message = successMessage });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating company preferences");
return Json(new { success = false, message = "An error occurred while saving settings." });
}
}
/// <summary>
/// Saves application-level defaults (currency, date format, default payment terms, quote validity days)
/// to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
/// </summary>
// POST: CompanySettings/UpdateAppDefaults
[HttpPost]
public Task<IActionResult> UpdateAppDefaults([FromBody] UpdateAppDefaultsDto dto) =>
UpdatePreferences(dto, "Application defaults saved successfully.");
/// <summary>
/// Saves job and workflow defaults (default turnaround days, auto-status transitions, shop access code
/// requirements) to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
/// </summary>
// POST: CompanySettings/UpdateJobDefaults
[HttpPost]
public Task<IActionResult> UpdateJobDefaults([FromBody] UpdateJobDefaultsDto dto) =>
UpdatePreferences(dto, "Job/workflow defaults saved successfully.");
/// <summary>
/// Saves notification channel preferences (email enabled, SMS enabled, in-app enabled) to
/// <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
/// </summary>
// POST: CompanySettings/UpdateNotifications
[HttpPost]
public Task<IActionResult> UpdateNotifications([FromBody] UpdateNotificationsDto dto) =>
UpdatePreferences(dto, "Notification settings saved successfully.");
/// <summary>
/// Saves data-retention policy preferences (how long to keep soft-deleted records, completed jobs, etc.)
/// to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
/// </summary>
// POST: CompanySettings/UpdateDataRetention
[HttpPost]
public Task<IActionResult> UpdateDataRetention([FromBody] UpdateDataRetentionDto dto) =>
UpdatePreferences(dto, "Data retention settings saved successfully.");
/// <summary>
/// Saves Quote PDF layout preferences (footer text, T&amp;C blurb, show/hide columns, signature line)
/// to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
/// </summary>
// POST: CompanySettings/UpdateQuoteTemplate
[HttpPost]
public Task<IActionResult> UpdateQuoteTemplate([FromBody] UpdateQuoteTemplateDto dto) =>
UpdatePreferences(dto, "Quote PDF settings saved successfully.");
/// <summary>
/// Saves Invoice PDF layout preferences (payment instructions, bank details, footer text)
/// to <see cref="CompanyPreferences"/>. Delegates to <see cref="UpdatePreferences{TDto}"/>.
/// </summary>
// POST: CompanySettings/UpdateInvoiceTemplate
[HttpPost]
public Task<IActionResult> UpdateInvoiceTemplate([FromBody] UpdateInvoiceTemplateDto dto) =>
UpdatePreferences(dto, "Invoice PDF settings saved successfully.");
// POST: CompanySettings/UpdateWorkOrderTemplate
[HttpPost]
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
UpdatePreferences(dto, "Work order settings saved successfully.");
/// <summary>
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
/// into <see cref="CompanyOperatingCosts"/>. These values feed the <c>IPricingCalculationService</c>
/// for every quote and job costing calculation, so changes here take effect on all new quotes
/// immediately. Uses an upsert pattern identical to <see cref="UpdatePreferences{TDto}"/>.
/// </summary>
// POST: CompanySettings/UpdateOperatingCosts
[HttpPost]
public async Task<IActionResult> UpdateOperatingCosts([FromBody] UpdateOperatingCostsDto dto)
{
try
{
if (!ModelState.IsValid)
{
var errors = ModelState.Values
.SelectMany(v => v.Errors)
.Select(e => e.ErrorMessage)
.ToList();
return Json(new { success = false, message = string.Join(", ", errors) });
}
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
return Json(new { success = false, message = "User does not have a company ID." });
}
var company = await _unitOfWork.Companies.GetByIdAsync(
companyId.Value,
false,
c => c.OperatingCosts);
if (company == null)
{
return Json(new { success = false, message = "Company not found." });
}
// Create or update operating costs
if (company.OperatingCosts == null)
{
company.OperatingCosts = new CompanyOperatingCosts
{
CompanyId = companyId.Value
};
_mapper.Map(dto, company.OperatingCosts);
await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts);
}
else
{
_mapper.Map(dto, company.OperatingCosts);
company.OperatingCosts.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompanyOperatingCosts.UpdateAsync(company.OperatingCosts);
}
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} operating costs updated", companyId);
return Json(new { success = true, message = "Operating costs updated successfully." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating operating costs");
return Json(new { success = false, message = "An error occurred while updating operating costs." });
}
}
/// <summary>
/// Saves the free-text AI context profile to <c>CompanyOperatingCosts.AiContextProfile</c>.
/// This text is injected into the system prompt by <c>AiQuoteService.BuildSystemPrompt(context)</c>
/// so the Anthropic Claude model receives company-specific context (specialties, common item types,
/// pricing philosophy) before analyzing photos. This is one of three layers in the per-company AI
/// learning approach (manual context, auto pricing config, few-shot accepted predictions).
/// Leading/trailing whitespace is trimmed before storage to avoid invisible formatting issues in prompts.
/// </summary>
// POST: CompanySettings/UpdateAiProfile
[HttpPost]
public async Task<IActionResult> UpdateAiProfile([FromBody] UpdateAiProfileDto dto)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "User does not have a company ID." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.OperatingCosts);
if (company == null)
return Json(new { success = false, message = "Company not found." });
if (company.OperatingCosts == null)
{
company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId.Value };
company.OperatingCosts.AiContextProfile = dto.AiContextProfile?.Trim();
await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts);
}
else
{
company.OperatingCosts.AiContextProfile = dto.AiContextProfile?.Trim();
company.OperatingCosts.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompanyOperatingCosts.UpdateAsync(company.OperatingCosts);
}
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} AI profile updated", companyId);
return Json(new { success = true, message = "AI profile saved successfully." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating AI profile");
return Json(new { success = false, message = "An error occurred while saving the AI profile." });
}
}
/// <summary>
/// Saves the Quoting Calibration / Shop Capability profile. Maps equipment fields onto
/// <see cref="CompanyOperatingCosts"/> and returns the freshly derived blast and coating
/// rates so the UI can update the "estimated rate" display without a page reload.
/// </summary>
// POST: CompanySettings/UpdateBlastProfile
[HttpPost]
public async Task<IActionResult> UpdateBlastProfile([FromBody] UpdateBlastProfileDto dto)
{
try
{
if (!ModelState.IsValid)
{
var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage);
return Json(new { success = false, message = string.Join(", ", errors) });
}
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "User does not have a company ID." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.OperatingCosts);
if (company == null)
return Json(new { success = false, message = "Company not found." });
if (company.OperatingCosts == null)
{
company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId.Value };
_mapper.Map(dto, company.OperatingCosts);
await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts);
}
else
{
_mapper.Map(dto, company.OperatingCosts);
company.OperatingCosts.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompanyOperatingCosts.UpdateAsync(company.OperatingCosts);
}
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} blast profile updated — tier={Tier}, CFM={Cfm}, nozzle=#{Nozzle}",
companyId, dto.ShopCapabilityTier, dto.CompressorCfm, dto.BlastNozzleSize);
var blastRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(company.OperatingCosts);
var coatingRate = ShopCapabilityCalculator.GetCoatingRateSqFtPerHour(company.OperatingCosts);
return Json(new
{
success = true,
message = "Quoting calibration saved successfully.",
derivedBlastRate = blastRate,
derivedCoatingRate = coatingRate
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating blast profile");
return Json(new { success = false, message = "An error occurred while saving the quoting calibration." });
}
}
#region Job Status Lookups
/// <summary>
/// Returns all job statuses for the current company ordered by <c>DisplayOrder</c>, with a live
/// job-count badge per status. The counts are fetched individually per status rather than via a
/// GROUP BY because the global multi-tenant query filter on <c>Jobs</c> is automatically applied,
/// guaranteeing the counts reflect only this tenant's data without extra filtering code.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetJobStatuses()
{
try
{
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses);
// Add job counts
foreach (var dto in dtos)
{
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobStatusId == dto.Id);
}
return Json(dtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading job statuses");
return Json(new { success = false, message = "Error loading job statuses." });
}
}
/// <summary>
/// Creates a new custom job status for the current company and immediately invalidates the
/// <see cref="ILookupCacheService"/> so subsequent status dropdowns reflect the addition without
/// a restart. StatusCode uniqueness is enforced at the application level (not just the DB) to
/// return a user-friendly message rather than a constraint violation exception.
/// System-defined statuses cannot be overridden by this endpoint — tenant customization is additive only.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateJobStatus([FromBody] CreateJobStatusLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Company not found" });
// Check if status code already exists for this company
var exists = await _unitOfWork.JobStatusLookups
.AnyAsync(s => s.StatusCode == dto.StatusCode);
if (exists)
return Json(new { success = false, message = "Status code already exists" });
var status = _mapper.Map<JobStatusLookup>(dto);
status.CompanyId = companyId.Value;
await _unitOfWork.JobStatusLookups.AddAsync(status);
await _unitOfWork.CompleteAsync();
_lookupCache.InvalidateCompanyCache(companyId.Value);
_logger.LogInformation("Job status {StatusCode} created for company {CompanyId}", dto.StatusCode, companyId);
return Json(new { success = true, message = "Status created successfully", id = status.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating job status");
return Json(new { success = false, message = "An error occurred while creating the status." });
}
}
/// <summary>
/// Updates label, color, and display properties of a custom job status. System-defined statuses
/// (those where <c>IsSystemDefined == true</c>) are blocked from modification because the core
/// job lifecycle logic (e.g. terminal-status detection for billing) depends on their fixed identity.
/// Cache is invalidated after save so live dropdowns pick up the rename immediately.
/// </summary>
[HttpPost]
public async Task<IActionResult> UpdateJobStatus([FromBody] UpdateJobStatusLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var status = await _unitOfWork.JobStatusLookups.GetByIdAsync(dto.Id);
if (status == null)
return Json(new { success = false, message = "Status not found" });
if (status.IsSystemDefined)
return Json(new { success = false, message = "System-defined statuses cannot be modified" });
_mapper.Map(dto, status);
await _unitOfWork.JobStatusLookups.UpdateAsync(status);
await _unitOfWork.CompleteAsync();
_lookupCache.InvalidateCompanyCache(status.CompanyId);
_logger.LogInformation("Job status {StatusId} updated", dto.Id);
return Json(new { success = true, message = "Status updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating job status");
return Json(new { success = false, message = "An error occurred while updating the status." });
}
}
/// <summary>
/// Soft-deletes a custom job status after verifying it is not currently assigned to any job.
/// A referential integrity check is performed before the soft-delete rather than relying on a DB
/// constraint because soft-deleted statuses remain in the table and a constraint would not fire.
/// System-defined statuses are always protected regardless of job-count.
/// </summary>
[HttpPost]
public async Task<IActionResult> DeleteJobStatus(int id)
{
try
{
var status = await _unitOfWork.JobStatusLookups.GetByIdAsync(id);
if (status == null)
return Json(new { success = false, message = "Status not found" });
if (status.IsSystemDefined)
return Json(new { success = false, message = "Cannot delete system-defined status" });
// Check if status is in use
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobStatusId == id);
if (inUse)
return Json(new { success = false, message = "Status is in use and cannot be deleted" });
await _unitOfWork.JobStatusLookups.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
_lookupCache.InvalidateCompanyCache(status.CompanyId);
_logger.LogInformation("Job status {StatusId} deleted", id);
return Json(new { success = true, message = "Status deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting job status");
return Json(new { success = false, message = "An error occurred while deleting the status." });
}
}
/// <summary>
/// Bulk-updates <c>DisplayOrder</c> for all job statuses according to the drag-sorted ID list sent
/// from the UI. Each status is updated individually (no bulk UPDATE) because the generic repository
/// does not expose a batch-update overload. Cache is invalidated after save so the new order
/// appears in kanban boards and dropdowns on the next request.
/// </summary>
[HttpPost]
public async Task<IActionResult> ReorderJobStatuses([FromBody] ReorderLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
var status = statuses.FirstOrDefault(s => s.Id == dto.OrderedIds[i]);
if (status != null)
{
status.DisplayOrder = i + 1;
await _unitOfWork.JobStatusLookups.UpdateAsync(status);
}
}
await _unitOfWork.CompleteAsync();
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value);
_logger.LogInformation("Job statuses reordered");
return Json(new { success = true, message = "Order updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reordering job statuses");
return Json(new { success = false, message = "An error occurred while reordering statuses." });
}
}
#endregion
#region Job Priority Lookups
/// <summary>
/// Returns all job priorities for the current company ordered by <c>DisplayOrder</c>, including
/// a live job-count per priority. Counts rely on the tenant-scoped global query filter so no
/// explicit CompanyId filter is needed. Used by the Settings UI to show which priorities are
/// actively in use before allowing rename or delete.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetJobPriorities()
{
try
{
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList();
var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities);
// Add job counts
foreach (var dto in dtos)
{
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobPriorityId == dto.Id);
}
return Json(dtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading job priorities");
return Json(new { success = false, message = "Error loading job priorities." });
}
}
/// <summary>
/// Creates a new custom job priority with a unique PriorityCode for the current company.
/// Priorities appear in the job create/edit dropdowns and drive color-coded badges across the UI.
/// Unlike job statuses, priorities do not require cache invalidation via <see cref="ILookupCacheService"/>
/// because the priority list is not currently cached — this should be added if performance requires it.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateJobPriority([FromBody] CreateJobPriorityLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Company not found" });
// Check if priority code already exists for this company
var exists = await _unitOfWork.JobPriorityLookups
.AnyAsync(p => p.PriorityCode == dto.PriorityCode);
if (exists)
return Json(new { success = false, message = "Priority code already exists" });
var priority = _mapper.Map<JobPriorityLookup>(dto);
priority.CompanyId = companyId.Value;
await _unitOfWork.JobPriorityLookups.AddAsync(priority);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Job priority {PriorityCode} created for company {CompanyId}", dto.PriorityCode, companyId);
return Json(new { success = true, message = "Priority created successfully", id = priority.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating job priority");
return Json(new { success = false, message = "An error occurred while creating the priority." });
}
}
/// <summary>
/// Updates the label, color, and icon of a custom job priority. System-defined priorities are
/// protected from modification to keep core features (e.g. Rush job surcharges, SLA alerting)
/// working against stable codes. AutoMapper applies only the mapped fields so internal flags
/// (<c>IsSystemDefined</c>, <c>PriorityCode</c>) remain unchanged.
/// </summary>
[HttpPost]
public async Task<IActionResult> UpdateJobPriority([FromBody] UpdateJobPriorityLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var priority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(dto.Id);
if (priority == null)
return Json(new { success = false, message = "Priority not found" });
if (priority.IsSystemDefined)
return Json(new { success = false, message = "System-defined priorities cannot be modified" });
_mapper.Map(dto, priority);
await _unitOfWork.JobPriorityLookups.UpdateAsync(priority);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Job priority {PriorityId} updated", dto.Id);
return Json(new { success = true, message = "Priority updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating job priority");
return Json(new { success = false, message = "An error occurred while updating the priority." });
}
}
/// <summary>
/// Soft-deletes a custom job priority after confirming it is not assigned to any existing job.
/// System-defined priorities cannot be deleted. Soft delete is used (rather than hard delete) so
/// historical job records that referenced the priority retain a coherent display value.
/// </summary>
[HttpPost]
public async Task<IActionResult> DeleteJobPriority(int id)
{
try
{
var priority = await _unitOfWork.JobPriorityLookups.GetByIdAsync(id);
if (priority == null)
return Json(new { success = false, message = "Priority not found" });
if (priority.IsSystemDefined)
return Json(new { success = false, message = "Cannot delete system-defined priority" });
// Check if priority is in use
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobPriorityId == id);
if (inUse)
return Json(new { success = false, message = "Priority is in use and cannot be deleted" });
await _unitOfWork.JobPriorityLookups.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Job priority {PriorityId} deleted", id);
return Json(new { success = true, message = "Priority deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting job priority");
return Json(new { success = false, message = "An error occurred while deleting the priority." });
}
}
/// <summary>
/// Bulk-updates <c>DisplayOrder</c> for job priorities to match the drag-sorted ID list from the UI.
/// The 1-based ordering (i+1) ensures DisplayOrder 0 is never stored, which could break ascending
/// sorts that treat 0 as "unset".
/// </summary>
[HttpPost]
public async Task<IActionResult> ReorderJobPriorities([FromBody] ReorderLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
var priority = priorities.FirstOrDefault(p => p.Id == dto.OrderedIds[i]);
if (priority != null)
{
priority.DisplayOrder = i + 1;
await _unitOfWork.JobPriorityLookups.UpdateAsync(priority);
}
}
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Job priorities reordered");
return Json(new { success = true, message = "Order updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reordering job priorities");
return Json(new { success = false, message = "An error occurred while reordering priorities." });
}
}
#endregion
#region Quote Status Lookups
/// <summary>
/// Returns all quote statuses for the current company ordered by <c>DisplayOrder</c>, including a
/// live count of quotes in each status. The counts inform the UI about which statuses are safe to
/// delete versus which need to be retired via rename or deactivation instead.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetQuoteStatuses()
{
try
{
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses);
// Add quote counts
foreach (var dto in dtos)
{
dto.QuoteCount = await _unitOfWork.Quotes.CountAsync(q => q.QuoteStatusId == dto.Id);
}
return Json(dtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading quote statuses");
return Json(new { success = false, message = "Error loading quote statuses." });
}
}
/// <summary>
/// Creates a new custom quote status for the company. Enforces two critical singleton business rules:
/// only one status may be flagged <c>IsApprovedStatus</c> (the flag used by the quote-to-job
/// conversion path) and only one may be flagged <c>IsConvertedStatus</c> (used to mark quotes that
/// generated a job). Allowing multiple "approved" statuses would break the conversion workflow by
/// making the trigger condition ambiguous.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateQuoteStatus([FromBody] CreateQuoteStatusLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Company not found" });
// Check if status code already exists for this company
var exists = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.StatusCode == dto.StatusCode);
if (exists)
return Json(new { success = false, message = "Status code already exists" });
// Validate business logic flags
if (dto.IsApprovedStatus)
{
var hasApproved = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.IsApprovedStatus);
if (hasApproved)
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
}
if (dto.IsConvertedStatus)
{
var hasConverted = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.IsConvertedStatus);
if (hasConverted)
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
}
var status = _mapper.Map<QuoteStatusLookup>(dto);
status.CompanyId = companyId.Value;
await _unitOfWork.QuoteStatusLookups.AddAsync(status);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Quote status {StatusCode} created for company {CompanyId}", dto.StatusCode, companyId);
return Json(new { success = true, message = "Status created successfully", id = status.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating quote status");
return Json(new { success = false, message = "An error occurred while creating the status." });
}
}
/// <summary>
/// Updates a custom quote status. Re-validates the <c>IsApprovedStatus</c> and <c>IsConvertedStatus</c>
/// singleton constraints on every update, but only fires the uniqueness check when the flag is being
/// newly set (changing from false → true). This avoids a false positive when saving the existing
/// approved status without modifying the flag.
/// System-defined statuses are read-only regardless of the DTO content.
/// </summary>
[HttpPost]
public async Task<IActionResult> UpdateQuoteStatus([FromBody] UpdateQuoteStatusLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var status = await _unitOfWork.QuoteStatusLookups.GetByIdAsync(dto.Id);
if (status == null)
return Json(new { success = false, message = "Status not found" });
if (status.IsSystemDefined)
return Json(new { success = false, message = "System-defined statuses cannot be modified" });
// Validate business logic flags
if (dto.IsApprovedStatus && !status.IsApprovedStatus)
{
var hasApproved = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.Id != dto.Id && s.IsApprovedStatus);
if (hasApproved)
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
}
if (dto.IsConvertedStatus && !status.IsConvertedStatus)
{
var hasConverted = await _unitOfWork.QuoteStatusLookups
.AnyAsync(s => s.Id != dto.Id && s.IsConvertedStatus);
if (hasConverted)
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
}
_mapper.Map(dto, status);
await _unitOfWork.QuoteStatusLookups.UpdateAsync(status);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Quote status {StatusId} updated", dto.Id);
return Json(new { success = true, message = "Status updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating quote status");
return Json(new { success = false, message = "An error occurred while updating the status." });
}
}
/// <summary>
/// Soft-deletes a custom quote status after confirming it is not referenced by any existing quote.
/// System-defined statuses are always protected. Soft delete preserves the status row for historical
/// quotes that already have this status assigned, avoiding orphaned foreign key references.
/// </summary>
[HttpPost]
public async Task<IActionResult> DeleteQuoteStatus(int id)
{
try
{
var status = await _unitOfWork.QuoteStatusLookups.GetByIdAsync(id);
if (status == null)
return Json(new { success = false, message = "Status not found" });
if (status.IsSystemDefined)
return Json(new { success = false, message = "Cannot delete system-defined status" });
// Check if status is in use
var inUse = await _unitOfWork.Quotes.AnyAsync(q => q.QuoteStatusId == id);
if (inUse)
return Json(new { success = false, message = "Status is in use and cannot be deleted" });
await _unitOfWork.QuoteStatusLookups.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Quote status {StatusId} deleted", id);
return Json(new { success = true, message = "Status deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting quote status");
return Json(new { success = false, message = "An error occurred while deleting the status." });
}
}
/// <summary>
/// Bulk-updates <c>DisplayOrder</c> for quote statuses to match the drag-sorted ID list from the UI.
/// Uses 1-based ordering to ensure DisplayOrder is never 0, consistent with
/// <see cref="ReorderJobStatuses"/>.
/// </summary>
[HttpPost]
public async Task<IActionResult> ReorderQuoteStatuses([FromBody] ReorderLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
var status = statuses.FirstOrDefault(s => s.Id == dto.OrderedIds[i]);
if (status != null)
{
status.DisplayOrder = i + 1;
await _unitOfWork.QuoteStatusLookups.UpdateAsync(status);
}
}
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Quote statuses reordered");
return Json(new { success = true, message = "Order updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reordering quote statuses");
return Json(new { success = false, message = "An error occurred while reordering statuses." });
}
}
#endregion
#region Prep Service Lookups
/// <summary>
/// Returns all prep services (sandblasting, media blast, chemical strip, etc.) for the current
/// company ordered by <c>DisplayOrder</c>. Prep services are selectable line items on quote and job
/// items that add cost to the pricing calculation; this list populates the wizard's Step 4 checkboxes.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetPrepServices()
{
try
{
var services = await _unitOfWork.PrepServices.GetAllAsync();
var sortedServices = services.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices);
return Json(dtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading prep services");
return Json(new { success = false, message = "Error loading prep services." });
}
}
/// <summary>
/// Creates a new prep service entry for the current company. Unlike job/quote status lookups,
/// prep services do not have system-defined entries or uniqueness constraints on a code field —
/// companies can add multiple services with similar names. The new service is immediately available
/// in the quote and job item wizards for this tenant.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreatePrepService([FromBody] CreatePrepServiceDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Company not found" });
var service = _mapper.Map<PrepService>(dto);
service.CompanyId = companyId.Value;
await _unitOfWork.PrepServices.AddAsync(service);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Prep service {ServiceName} created for company {CompanyId}", dto.ServiceName, companyId);
return Json(new { success = true, message = "Prep service created successfully", id = service.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating prep service");
return Json(new { success = false, message = "An error occurred while creating the prep service." });
}
}
/// <summary>
/// Updates the name, description, and pricing of an existing prep service. Unlike status lookups,
/// there are no system-defined prep services that must be protected — all are tenant-owned.
/// Changes take effect immediately for new quotes; existing quotes that already selected this service
/// retain their stored price snapshot and are unaffected.
/// </summary>
[HttpPost]
public async Task<IActionResult> UpdatePrepService([FromBody] UpdatePrepServiceDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var service = await _unitOfWork.PrepServices.GetByIdAsync(dto.Id);
if (service == null)
return Json(new { success = false, message = "Prep service not found" });
_mapper.Map(dto, service);
await _unitOfWork.PrepServices.UpdateAsync(service);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Prep service {ServiceId} updated", dto.Id);
return Json(new { success = true, message = "Prep service updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating prep service");
return Json(new { success = false, message = "An error occurred while updating the prep service." });
}
}
/// <summary>
/// Soft-deletes a prep service. No in-use check is performed here because prep service selections
/// are stored as a navigation collection on job/quote items, not a FK column — removing the lookup
/// row does not break existing records. Soft delete ensures the service name remains resolvable for
/// historical display in old quotes and jobs.
/// </summary>
[HttpPost]
public async Task<IActionResult> DeletePrepService(int id)
{
try
{
var service = await _unitOfWork.PrepServices.GetByIdAsync(id);
if (service == null)
return Json(new { success = false, message = "Prep service not found" });
await _unitOfWork.PrepServices.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Prep service {ServiceId} deleted", id);
return Json(new { success = true, message = "Prep service deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting prep service");
return Json(new { success = false, message = "An error occurred while deleting the prep service." });
}
}
/// <summary>
/// Bulk-updates <c>DisplayOrder</c> for prep services to match the drag-sorted ID list from the UI.
/// Display order controls the sequence in which prep service checkboxes appear in the item wizard.
/// </summary>
[HttpPost]
public async Task<IActionResult> ReorderPrepServices([FromBody] ReorderLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var services = await _unitOfWork.PrepServices.GetAllAsync();
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
var service = services.FirstOrDefault(s => s.Id == dto.OrderedIds[i]);
if (service != null)
{
service.DisplayOrder = i + 1;
await _unitOfWork.PrepServices.UpdateAsync(service);
}
}
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Prep services reordered");
return Json(new { success = true, message = "Order updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reordering prep services");
return Json(new { success = false, message = "An error occurred while reordering prep services." });
}
}
#endregion
#region Blast Setups
/// <summary>Returns all active blast setups for the current company with their derived rates.</summary>
[HttpGet]
public async Task<IActionResult> GetBlastSetups()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.CompanyId == companyId);
var result = setups.OrderBy(b => b.DisplayOrder).Select(b => new BlastSetupDto
{
Id = b.Id,
Name = b.Name,
SetupType = b.SetupType,
CompressorCfm = b.CompressorCfm,
BlastNozzleSize = b.BlastNozzleSize,
PrimarySubstrate = b.PrimarySubstrate,
BlastRateSqFtPerHourOverride = b.BlastRateSqFtPerHourOverride,
IsDefault = b.IsDefault,
IsActive = b.IsActive,
DisplayOrder = b.DisplayOrder,
DerivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b)
}).ToList();
return Json(new { success = true, setups = result });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading blast setups");
return Json(new { success = false, message = "Failed to load blast setups." });
}
}
/// <summary>Creates or updates a named blast setup.</summary>
[HttpPost]
public async Task<IActionResult> SaveBlastSetup([FromBody] SaveBlastSetupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data." });
var companyId = _tenantContext.GetCurrentCompanyId();
CompanyBlastSetup setup;
bool isNew;
if (dto.Id.HasValue && dto.Id.Value > 0)
{
setup = await _unitOfWork.BlastSetups.GetByIdAsync(dto.Id.Value);
if (setup == null || setup.CompanyId != (companyId ?? 0))
return Json(new { success = false, message = "Blast setup not found." });
isNew = false;
}
else
{
setup = new CompanyBlastSetup { CompanyId = companyId ?? 0, CreatedAt = DateTime.UtcNow };
isNew = true;
}
setup.Name = dto.Name;
setup.SetupType = dto.SetupType;
setup.CompressorCfm = dto.CompressorCfm;
setup.BlastNozzleSize = dto.BlastNozzleSize;
setup.PrimarySubstrate = dto.PrimarySubstrate;
setup.BlastRateSqFtPerHourOverride = dto.BlastRateSqFtPerHourOverride;
setup.IsActive = dto.IsActive;
setup.UpdatedAt = DateTime.UtcNow;
// Enforce single default — clear others when this one becomes default
if (dto.IsDefault)
{
var others = await _unitOfWork.BlastSetups.FindAsync(b => b.CompanyId == companyId && b.IsDefault);
foreach (var other in others.Where(o => o.Id != (dto.Id ?? 0)))
{
other.IsDefault = false;
await _unitOfWork.BlastSetups.UpdateAsync(other);
}
}
setup.IsDefault = dto.IsDefault;
if (isNew)
{
// Assign display order = max + 1
var existing = await _unitOfWork.BlastSetups.FindAsync(b => b.CompanyId == companyId);
setup.DisplayOrder = existing.Any() ? existing.Max(b => b.DisplayOrder) + 1 : 1;
await _unitOfWork.BlastSetups.AddAsync(setup);
}
else
{
await _unitOfWork.BlastSetups.UpdateAsync(setup);
}
await _unitOfWork.CompleteAsync();
var derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
_logger.LogInformation("{Action} blast setup {Id} '{Name}' for company {CompanyId}",
isNew ? "Created" : "Updated", setup.Id, setup.Name, companyId);
return Json(new { success = true, id = setup.Id, derivedRate, message = isNew ? "Blast setup added." : "Blast setup updated." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving blast setup");
return Json(new { success = false, message = "An error occurred while saving the blast setup." });
}
}
/// <summary>Soft-deletes a named blast setup.</summary>
[HttpPost]
public async Task<IActionResult> DeleteBlastSetup(int id)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
var setup = await _unitOfWork.BlastSetups.GetByIdAsync(id);
if (setup == null || setup.CompanyId != (companyId ?? 0))
return Json(new { success = false, message = "Blast setup not found." });
await _unitOfWork.BlastSetups.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Blast setup {Id} deleted", id);
return Json(new { success = true, message = "Blast setup deleted." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting blast setup");
return Json(new { success = false, message = "An error occurred while deleting the blast setup." });
}
}
#endregion
#region Appointment Type Lookups
/// <summary>
/// Returns all appointment type lookups for the current company ordered by <c>DisplayOrder</c>,
/// with a live appointment count per type. Appointment types classify customer drop-off, pick-up,
/// and estimate visits; the counts help admins see which types are actively in use.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetAppointmentTypes()
{
try
{
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
var sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList();
var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes);
// Add appointment counts
foreach (var dto in dtos)
{
dto.AppointmentCount = await _unitOfWork.Appointments.CountAsync(a => a.AppointmentTypeId == dto.Id);
}
return Json(dtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading appointment types");
return Json(new { success = false, message = "Error loading appointment types." });
}
}
/// <summary>
/// Creates a new appointment type for the current company with a unique TypeCode. TypeCode
/// uniqueness is enforced at application level to provide a friendly error message; the DB does
/// not have a unique constraint on this column because the global query filter makes the UNIQUE
/// INDEX per-company rather than global.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateAppointmentType([FromBody] CreateAppointmentTypeLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Company not found" });
// Check if type code already exists for this company
var exists = await _unitOfWork.AppointmentTypeLookups
.AnyAsync(t => t.TypeCode == dto.TypeCode);
if (exists)
return Json(new { success = false, message = "Type code already exists" });
var type = _mapper.Map<AppointmentTypeLookup>(dto);
type.CompanyId = companyId.Value;
await _unitOfWork.AppointmentTypeLookups.AddAsync(type);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Appointment type {TypeCode} created for company {CompanyId}", dto.TypeCode, companyId);
return Json(new { success = true, message = "Type created successfully", id = type.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating appointment type");
return Json(new { success = false, message = "An error occurred while creating the type." });
}
}
/// <summary>
/// Updates editable fields (color, display name, icon, description) of an appointment type.
/// Unlike job/quote statuses, system-defined appointment types CAN be renamed and recolored —
/// only TypeCode and IsSystemDefined are protected (ignored by AutoMapper in the reverse mapping).
/// This design allows businesses to brand standard types (e.g. rename "Drop Off" to "Check In")
/// without breaking the underlying workflow logic that uses TypeCode.
/// </summary>
[HttpPost]
public async Task<IActionResult> UpdateAppointmentType([FromBody] UpdateAppointmentTypeLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var type = await _unitOfWork.AppointmentTypeLookups.GetByIdAsync(dto.Id);
if (type == null)
return Json(new { success = false, message = "Type not found" });
// System-defined types can be edited (color, display name, icon, description)
// but TypeCode and IsSystemDefined are protected by AutoMapper (ignored in mapping)
_mapper.Map(dto, type);
await _unitOfWork.AppointmentTypeLookups.UpdateAsync(type);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Appointment type {TypeId} updated", dto.Id);
return Json(new { success = true, message = "Type updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating appointment type");
return Json(new { success = false, message = "An error occurred while updating the type." });
}
}
/// <summary>
/// Soft-deletes an appointment type after confirming it is not referenced by any existing appointment.
/// System-defined types cannot be deleted even when they have zero appointments, because platform
/// features (e.g. automated pick-up reminders) may reference them by TypeCode.
/// </summary>
[HttpPost]
public async Task<IActionResult> DeleteAppointmentType(int id)
{
try
{
var type = await _unitOfWork.AppointmentTypeLookups.GetByIdAsync(id);
if (type == null)
return Json(new { success = false, message = "Type not found" });
if (type.IsSystemDefined)
return Json(new { success = false, message = "Cannot delete system-defined type" });
// Check if type is in use
var inUse = await _unitOfWork.Appointments.AnyAsync(a => a.AppointmentTypeId == id);
if (inUse)
return Json(new { success = false, message = "Type is in use and cannot be deleted" });
await _unitOfWork.AppointmentTypeLookups.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Appointment type {TypeId} deleted", id);
return Json(new { success = true, message = "Type deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting appointment type");
return Json(new { success = false, message = "An error occurred while deleting the type." });
}
}
/// <summary>
/// Bulk-updates <c>DisplayOrder</c> for appointment types to match the drag-sorted ID list from the UI.
/// Display order controls the sequence in appointment scheduling dropdowns.
/// </summary>
[HttpPost]
public async Task<IActionResult> ReorderAppointmentTypes([FromBody] ReorderLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
var type = types.FirstOrDefault(t => t.Id == dto.OrderedIds[i]);
if (type != null)
{
type.DisplayOrder = i + 1;
await _unitOfWork.AppointmentTypeLookups.UpdateAsync(type);
}
}
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Appointment types reordered");
return Json(new { success = true, message = "Order updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reordering appointment types");
return Json(new { success = false, message = "An error occurred while reordering types." });
}
}
#endregion
#region Inventory Category Lookups
/// <summary>
/// Returns all inventory categories for the current company ordered by <c>DisplayOrder</c>,
/// with a live count of inventory items in each category. Categories classify items (powders,
/// primers, masking supplies, etc.) for filtering and reporting; the counts prevent deleting
/// categories that are still actively organizing stock.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetInventoryCategories()
{
try
{
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList();
var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories);
// Add item counts
foreach (var dto in dtos)
{
dto.ItemCount = await _unitOfWork.InventoryItems.CountAsync(i => i.InventoryCategoryId == dto.Id);
}
return Json(dtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading inventory categories");
return Json(new { success = false, message = "Error loading inventory categories." });
}
}
/// <summary>
/// Creates a new inventory category with a unique CategoryCode for the current company.
/// CategoryCode uniqueness prevents duplicate categories (e.g. two "POWDER" codes) that would
/// confuse inventory filtering. The check uses the repository's <c>AnyAsync</c> which applies
/// the global tenant filter, so only this company's codes are checked.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateInventoryCategory([FromBody] CreateInventoryCategoryLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "Company not found" });
// Check if category code already exists for this company
var exists = await _unitOfWork.InventoryCategoryLookups
.AnyAsync(c => c.CategoryCode == dto.CategoryCode);
if (exists)
return Json(new { success = false, message = "Category code already exists" });
var category = _mapper.Map<InventoryCategoryLookup>(dto);
category.CompanyId = companyId.Value;
await _unitOfWork.InventoryCategoryLookups.AddAsync(category);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Inventory category {CategoryCode} created for company {CompanyId}", dto.CategoryCode, companyId);
return Json(new { success = true, message = "Category created successfully", id = category.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating inventory category");
return Json(new { success = false, message = "An error occurred while creating the category." });
}
}
/// <summary>
/// Updates the display name, color, and icon of an inventory category. There are no system-defined
/// inventory categories, so all categories are fully editable without restriction.
/// </summary>
[HttpPost]
public async Task<IActionResult> UpdateInventoryCategory([FromBody] UpdateInventoryCategoryLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(dto.Id);
if (category == null)
return Json(new { success = false, message = "Category not found" });
_mapper.Map(dto, category);
await _unitOfWork.InventoryCategoryLookups.UpdateAsync(category);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Inventory category {CategoryId} updated", dto.Id);
return Json(new { success = true, message = "Category updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating inventory category");
return Json(new { success = false, message = "An error occurred while updating the category." });
}
}
/// <summary>
/// Soft-deletes an inventory category after confirming no inventory items are currently assigned to it.
/// Unlike status lookups there is no system-defined protection — all inventory categories are
/// tenant-owned and can be deleted when empty.
/// </summary>
[HttpPost]
public async Task<IActionResult> DeleteInventoryCategory(int id)
{
try
{
var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(id);
if (category == null)
return Json(new { success = false, message = "Category not found" });
// Check if category is in use
var inUse = await _unitOfWork.InventoryItems.AnyAsync(i => i.InventoryCategoryId == id);
if (inUse)
return Json(new { success = false, message = "Category is in use and cannot be deleted" });
await _unitOfWork.InventoryCategoryLookups.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Inventory category {CategoryId} deleted", id);
return Json(new { success = true, message = "Category deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting inventory category");
return Json(new { success = false, message = "An error occurred while deleting the category." });
}
}
/// <summary>
/// Bulk-updates <c>DisplayOrder</c> for inventory categories to match the drag-sorted ID list.
/// Display order controls the sequence in inventory filtering tabs and reporting groupings.
/// </summary>
[HttpPost]
public async Task<IActionResult> ReorderInventoryCategories([FromBody] ReorderLookupDto dto)
{
try
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
var category = categories.FirstOrDefault(c => c.Id == dto.OrderedIds[i]);
if (category != null)
{
category.DisplayOrder = i + 1;
await _unitOfWork.InventoryCategoryLookups.UpdateAsync(category);
}
}
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Inventory categories reordered");
return Json(new { success = true, message = "Order updated successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reordering inventory categories");
return Json(new { success = false, message = "An error occurred while reordering categories." });
}
}
#endregion
// =========================================================================
// OVEN COSTS
// =========================================================================
#region Oven Costs
/// <summary>
/// Returns all named ovens (OvenCost records) for the current company ordered by display order then
/// label. An explicit <c>CompanyId</c> filter is used here rather than relying solely on the global
/// query filter because <c>OvenCost</c> did not originally participate in the multi-tenancy filter
/// and the explicit check provides a defense-in-depth guarantee. Each oven exposes
/// <c>MaxLoadSqFt</c> and <c>DefaultCycleMinutes</c> used by the Oven Scheduler for capacity planning.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetOvenCosts()
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { success = false, message = "Company not found." });
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == companyId.Value))
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label);
var result = ovens.Select(o => new
{
id = o.Id,
label = o.Label,
costPerHour = o.CostPerHour,
isActive = o.IsActive,
displayOrder = o.DisplayOrder,
maxLoadSqFt = o.MaxLoadSqFt,
defaultCycleMinutes = o.DefaultCycleMinutes
});
return Json(new { success = true, data = result });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving oven costs");
return Json(new { success = false, message = "An error occurred while loading oven costs." });
}
}
/// <summary>
/// Creates a new named oven (<see cref="PowderCoating.Core.Entities.OvenCost"/>) for the current
/// company. The oven is immediately selectable in the Oven Scheduler for batch assignment. Fully
/// qualified DTO type is used to avoid an import ambiguity with other <c>CreateOvenCostDto</c>
/// classes that may exist in different namespaces.
/// <c>MaxLoadSqFt</c> and <c>DefaultCycleMinutes</c> are optional — the scheduler degrades
/// gracefully when they are null (no capacity enforcement).
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateOvenCost([FromBody] PowderCoating.Application.DTOs.Company.CreateOvenCostDto dto)
{
try
{
if (!ModelState.IsValid)
{
var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage);
return Json(new { success = false, message = string.Join(", ", errors) });
}
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { success = false, message = "Company not found." });
var oven = new PowderCoating.Core.Entities.OvenCost
{
Label = dto.Label.Trim(),
CostPerHour = dto.CostPerHour,
IsActive = dto.IsActive,
DisplayOrder = dto.DisplayOrder,
MaxLoadSqFt = dto.MaxLoadSqFt,
DefaultCycleMinutes = dto.DefaultCycleMinutes,
CompanyId = companyId.Value,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.OvenCosts.AddAsync(oven);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, message = "Oven added successfully.", id = oven.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating oven cost");
return Json(new { success = false, message = "An error occurred while adding the oven." });
}
}
/// <summary>
/// Updates an existing oven's label, hourly cost, capacity, and cycle time. An explicit
/// <c>CompanyId</c> ownership check (<c>oven.CompanyId != companyId.Value</c>) is performed in
/// addition to the global query filter to prevent tenant A from editing tenant B's oven if the
/// filter were ever bypassed (defense-in-depth). Deactivating an oven (<c>IsActive = false</c>)
/// hides it from the Oven Scheduler dropdown without deleting historical batch records.
/// </summary>
[HttpPost]
public async Task<IActionResult> UpdateOvenCost([FromBody] PowderCoating.Application.DTOs.Company.UpdateOvenCostDto dto)
{
try
{
if (!ModelState.IsValid)
{
var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage);
return Json(new { success = false, message = string.Join(", ", errors) });
}
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { success = false, message = "Company not found." });
var oven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.Id);
if (oven == null || oven.CompanyId != companyId.Value)
return Json(new { success = false, message = "Oven not found." });
oven.Label = dto.Label.Trim();
oven.CostPerHour = dto.CostPerHour;
oven.IsActive = dto.IsActive;
oven.DisplayOrder = dto.DisplayOrder;
oven.MaxLoadSqFt = dto.MaxLoadSqFt;
oven.DefaultCycleMinutes = dto.DefaultCycleMinutes;
oven.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.OvenCosts.UpdateAsync(oven);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, message = "Oven updated successfully." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating oven cost {OvenCostId}", dto.Id);
return Json(new { success = false, message = "An error occurred while updating the oven." });
}
}
/// <summary>
/// Soft-deletes a named oven after verifying it is not referenced by any existing quotes.
/// Quotes store <c>OvenCostId</c> as a FK for cost-snapshot purposes; if any quote references
/// the oven the user is directed to deactivate it instead to preserve those references.
/// The ownership check mirrors <see cref="UpdateOvenCost"/> for defense-in-depth.
/// </summary>
[HttpPost]
public async Task<IActionResult> DeleteOvenCost([FromBody] int id)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { success = false, message = "Company not found." });
var oven = await _unitOfWork.OvenCosts.GetByIdAsync(id);
if (oven == null || oven.CompanyId != companyId.Value)
return Json(new { success = false, message = "Oven not found." });
// Check if any quotes reference this oven
var usageCount = await _unitOfWork.Quotes.CountAsync(q => q.OvenCostId == id);
if (usageCount > 0)
return Json(new { success = false, message = $"Cannot delete: {usageCount} quote(s) reference this oven. Deactivate it instead." });
await _unitOfWork.OvenCosts.SoftDeleteAsync(oven);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, message = "Oven deleted successfully." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting oven cost {OvenCostId}", id);
return Json(new { success = false, message = "An error occurred while deleting the oven." });
}
}
#endregion
// =========================================================================
// NOTIFICATION TEMPLATES
// =========================================================================
#region Notification Templates
/// <summary>
/// Renders the standalone Notification Templates management page (separate from the main Settings
/// hub). Calls <see cref="EnsureNotificationTemplatesSeededAsync"/> so that any newly added
/// canonical notification types are automatically provisioned with default templates on first visit,
/// without requiring a migration or manual seeding step.
/// </summary>
// GET: CompanySettings/NotificationTemplates
[HttpGet]
public async Task<IActionResult> NotificationTemplates()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return RedirectToAction(nameof(Index));
// Load all existing templates for this company
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
// Auto-seed any missing canonical combinations
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
if (seeded > 0)
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
.Select(t => new NotificationTemplateDto
{
Id = t.Id,
NotificationType = t.NotificationType,
Channel = t.Channel,
DisplayName = t.DisplayName,
Subject = t.Subject,
Body = t.Body,
UpdatedAt = t.UpdatedAt
}).ToList();
return View(dtos);
}
/// <summary>
/// Returns a single notification template as a JSON object for the inline template editor modal.
/// Includes the applicable placeholder list (built by <see cref="GetApplicablePlaceholders"/>) so
/// the editor can display context-sensitive insertion buttons without a separate round-trip.
/// The <c>isEmail</c> flag drives whether the Subject field is shown in the editor UI.
/// </summary>
// GET: CompanySettings/GetTemplateJson/{id}
[HttpGet]
public async Task<IActionResult> GetTemplateJson(int id)
{
var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
if (template == null) return NotFound();
var placeholders = GetApplicablePlaceholders(template.NotificationType)
.Select(p => new { placeholder = p.Placeholder, description = p.Description })
.ToList();
return Json(new
{
id = template.Id,
displayName = template.DisplayName,
notificationType = template.NotificationType.ToString(),
isEmail = template.Channel == NotificationChannel.Email,
subject = template.Subject ?? string.Empty,
body = template.Body ?? string.Empty,
placeholders
});
}
/// <summary>
/// Persists inline edits to a notification template from the Settings hub modal editor.
/// Subject is only stored for Email channel templates — SMS templates do not have a subject line,
/// so it is explicitly nulled to avoid stale values leaking if a template is ever converted
/// between channel types. Returns a JSON envelope so the modal can show a success toast without reload.
/// </summary>
// POST: CompanySettings/SaveTemplateJson
[HttpPost]
public async Task<IActionResult> SaveTemplateJson([FromBody] SaveTemplateJsonRequest req)
{
var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(req.Id);
if (template == null) return Json(new { success = false, message = "Template not found." });
template.Subject = template.Channel == NotificationChannel.Email ? req.Subject : null;
template.Body = req.Body ?? string.Empty;
template.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.NotificationTemplates.UpdateAsync(template);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Notification template {TemplateId} updated via inline editor", template.Id);
return Json(new { success = true, message = $"Template \"{template.DisplayName}\" saved successfully." });
}
/// <summary>
/// Resets a notification template to its factory defaults from the inline Settings hub modal editor.
/// Default content is sourced from <c>SeedData.BuildDefaultNotificationTemplates</c> — the same
/// source used during initial company seeding — so defaults are always in sync with seed data changes.
/// Returns the new subject and body in the JSON response so the modal can refresh its editor
/// fields without a page reload.
/// </summary>
// POST: CompanySettings/ResetTemplateJson/{id}
[HttpPost]
public async Task<IActionResult> ResetTemplateJson(int id)
{
var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
if (template == null) return Json(new { success = false, message = "Template not found." });
var defaults = SeedData.BuildDefaultNotificationTemplates(template.CompanyId);
var defaultTemplate = defaults.FirstOrDefault(d =>
d.NotificationType == template.NotificationType && d.Channel == template.Channel);
if (defaultTemplate == null)
return Json(new { success = false, message = "No default found for this template." });
template.Subject = defaultTemplate.Subject;
template.Body = defaultTemplate.Body;
template.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.NotificationTemplates.UpdateAsync(template);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Notification template {TemplateId} reset to defaults via inline editor", template.Id);
return Json(new { success = true, message = $"Template \"{template.DisplayName}\" reset to defaults.", newSubject = template.Subject ?? string.Empty, newBody = template.Body });
}
/// <summary>
/// Renders the dedicated full-page template editor for a single notification template. Populates
/// <c>ViewBag.Placeholders</c> with the context-sensitive list from <see cref="GetApplicablePlaceholders"/>
/// so the Razor view can render insertion buttons without a separate AJAX call.
/// If the template is not found the user is redirected back to the Settings hub's notification tab
/// rather than shown a 404, to maintain the single-page settings UX.
/// </summary>
// GET: CompanySettings/EditTemplate/{id}
[HttpGet]
public async Task<IActionResult> EditTemplate(int id)
{
var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
if (template == null) return Redirect(Url.Action(nameof(Index))! + "#notification-templates");
var dto = new NotificationTemplateDto
{
Id = template.Id,
NotificationType = template.NotificationType,
Channel = template.Channel,
DisplayName = template.DisplayName,
Subject = template.Subject,
Body = template.Body,
UpdatedAt = template.UpdatedAt
};
ViewBag.Placeholders = GetApplicablePlaceholders(template.NotificationType);
return View(dto);
}
/// <summary>
/// Persists edits from the full-page template editor. On validation failure the view is re-rendered
/// with the user's in-progress content (not the stored version) so edits are not lost. Redirects
/// back to the Settings hub's notification tab on success using a fragment URL to restore the tab
/// state without JavaScript, using TempData for the success toast message.
/// </summary>
// POST: CompanySettings/EditTemplate/{id}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditTemplate(int id, UpdateNotificationTemplateDto dto)
{
var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
if (template == null) return Redirect(Url.Action(nameof(Index))! + "#notification-templates");
if (!ModelState.IsValid)
{
var viewDto = new NotificationTemplateDto
{
Id = template.Id,
NotificationType = template.NotificationType,
Channel = template.Channel,
DisplayName = template.DisplayName,
Subject = dto.Subject,
Body = dto.Body,
UpdatedAt = template.UpdatedAt
};
ViewBag.Placeholders = GetApplicablePlaceholders(template.NotificationType);
return View(viewDto);
}
template.Subject = template.Channel == NotificationChannel.Email ? dto.Subject : null;
template.Body = dto.Body ?? string.Empty;
template.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.NotificationTemplates.UpdateAsync(template);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Notification template {TemplateId} updated for company {CompanyId}",
id, template.CompanyId);
TempData["SuccessMessage"] = $"Template \"{template.DisplayName}\" saved successfully.";
return Redirect(Url.Action(nameof(Index))! + "#notification-templates");
}
/// <summary>
/// Resets a template to factory defaults from the full-page editor. Unlike <see cref="ResetTemplateJson"/>
/// (which is AJAX-based), this is a standard POST that redirects after completion so the full-page
/// editor's form state is cleanly replaced. If no default is found for the type+channel combination
/// (e.g. a future notification type without a seed entry) the redirect still occurs silently rather
/// than erroring, leaving the existing customized template intact.
/// </summary>
// POST: CompanySettings/ResetTemplate/{id}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetTemplate(int id)
{
var template = await _unitOfWork.NotificationTemplates.GetByIdAsync(id);
if (template == null) return Redirect(Url.Action(nameof(Index))! + "#notification-templates");
// Find the canonical default for this type+channel combination
var defaults = SeedData.BuildDefaultNotificationTemplates(template.CompanyId);
var defaultTemplate = defaults.FirstOrDefault(d =>
d.NotificationType == template.NotificationType && d.Channel == template.Channel);
if (defaultTemplate != null)
{
template.Subject = defaultTemplate.Subject;
template.Body = defaultTemplate.Body;
template.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.NotificationTemplates.UpdateAsync(template);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Notification template {TemplateId} reset to defaults", id);
TempData["SuccessMessage"] = $"Template \"{template.DisplayName}\" has been reset to defaults.";
}
return Redirect(Url.Action(nameof(Index))! + "#notification-templates");
}
/// <summary>
/// Ensures every canonical notification type+channel combination has a template row for the given
/// company. Called on every visit to the Settings Index and NotificationTemplates pages so new
/// notification types added to <c>SeedData.BuildDefaultNotificationTemplates</c> are automatically
/// provisioned without requiring a migration or a manual "Seed Data" action by the platform admin.
/// Returns the count of newly added templates so the caller can decide whether to reload from the DB.
/// </summary>
private async Task<int> EnsureNotificationTemplatesSeededAsync(
int companyId, List<NotificationTemplate> existing)
{
var allDefaults = SeedData.BuildDefaultNotificationTemplates(companyId);
var toAdd = allDefaults
.Where(d => !existing.Any(e =>
e.NotificationType == d.NotificationType && e.Channel == d.Channel))
.ToList();
foreach (var t in toAdd)
await _unitOfWork.NotificationTemplates.AddAsync(t);
if (toAdd.Count > 0)
await _unitOfWork.CompleteAsync();
return toAdd.Count;
}
/// <summary>
/// Returns the list of Handlebars-style placeholder tokens applicable to a given notification type.
/// The base set (<c>{{companyName}}</c>, <c>{{customerName}}</c>) is common to all types; additional
/// tokens (quote number, approval URL, job status, payment details, etc.) are added conditionally
/// based on the notification type so the editor only shows relevant tokens. Tokens are rendered in
/// email/SMS bodies by the notification delivery service via simple string replacement at send time.
/// Note: <c>{{quoteExpiry}}</c> and similar optional-phrase tokens expand to an empty string when
/// the underlying field is null — template authors should never wrap them in conditional logic.
/// </summary>
private static List<(string Placeholder, string Description)> GetApplicablePlaceholders(
NotificationType type)
{
var list = new List<(string, string)>
{
("{{companyName}}", "Your company name"),
("{{customerName}}", "The customer's or recipient's name")
};
// Quote types
if (type is NotificationType.QuoteSent or NotificationType.QuoteApproved
or NotificationType.QuoteDeclinedByCustomer)
list.Add(("{{quoteNumber}}", "Quote number (e.g. QT-2501-0001)"));
if (type == NotificationType.QuoteSent)
{
list.Add(("{{quoteTotal}}", "Quote total amount (formatted as currency)"));
list.Add(("{{quoteExpiry}}", "Expiry phrase, e.g. \" valid until January 1, 2026\" — blank if no expiry date is set"));
list.Add(("{{approvalUrl}}", "Unique URL for the customer to view and approve the quote online"));
}
if (type == NotificationType.QuoteDeclinedByCustomer)
{
list.Add(("{{response}}", "Customer's response — either \"APPROVED\" or \"DECLINED\""));
list.Add(("{{declineReasonSection}}", "HTML block containing the decline reason — blank when the customer approved"));
}
// Job types
if (type is NotificationType.JobStatusChanged or NotificationType.JobReadyForPickup
or NotificationType.JobCompleted)
list.Add(("{{jobNumber}}", "Job number (e.g. JOB-2501-0001)"));
if (type == NotificationType.JobStatusChanged)
{
list.Add(("{{jobStatus}}", "Current job status name"));
list.Add(("{{jobDueDate}}", "Due date phrase, e.g. \" Expected completion: January 1, 2026.\" — blank if not set"));
}
if (type == NotificationType.JobCompleted)
list.Add(("{{finalPrice}}", "Final job price (formatted as currency)"));
// Invoice types
if (type is NotificationType.InvoiceSent or NotificationType.PaymentReceived
or NotificationType.PaymentReminder)
list.Add(("{{invoiceNumber}}", "Invoice number (e.g. INV-2501-0001)"));
if (type == NotificationType.InvoiceSent)
{
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set"));
}
if (type == NotificationType.PaymentReceived)
{
list.Add(("{{paymentAmount}}", "Amount paid (formatted as currency)"));
list.Add(("{{paymentDate}}", "Date payment was recorded (e.g. January 1, 2026)"));
list.Add(("{{balanceDue}}", "Remaining balance phrase — blank when paid in full"));
}
if (type == NotificationType.PaymentReminder)
{
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
list.Add(("{{balanceDue}}", "Outstanding balance (formatted as currency)"));
list.Add(("{{dueDate}}", "Original due date (e.g. January 1, 2026)"));
list.Add(("{{daysOverdue}}", "Number of days the invoice is overdue"));
}
return list;
}
#endregion
// ── Role-Based Labor Rates ────────────────────────────────────────────────
/// <summary>
/// Returns the per-role hourly labor rates configured for the current company, keyed by
/// <see cref="PowderCoating.Core.Enums.ShopWorkerRole"/> integer value. An empty list is returned
/// (rather than a 404) when no rates have been configured yet, so the UI can render the rate grid
/// without special-casing an empty state. The global multi-tenant filter on
/// <c>ShopWorkerRoleCosts</c> ensures only this company's rates are returned.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetRoleCosts()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new List<object>());
var rates = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value);
var result = rates.Select(r => new { role = (int)r.Role, hourlyRate = r.HourlyRate }).ToList();
return Json(result);
}
/// <summary>
/// Upserts the per-role hourly labor rates for the current company. The operation handles three cases
/// per rate in a single pass: (1) rate cleared (≤ 0) — soft-delete the existing record; (2) rate set
/// but no existing record — insert new; (3) rate changed — update existing. This avoids full
/// table replace semantics that could cause audit log noise or trigger unintended EF change-tracking.
/// These rates are used by the pricing calculator when <c>UseRoleBasedLaborRates</c> is enabled in
/// <c>CompanyOperatingCosts</c>.
/// </summary>
[HttpPost]
public async Task<IActionResult> SaveRoleCosts([FromBody] List<SaveRoleCostDto> rates)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { success = false, message = "No company found." });
var existing = (await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value)).ToList();
foreach (var dto in rates)
{
var record = existing.FirstOrDefault(r => (int)r.Role == dto.Role);
if (dto.HourlyRate <= 0)
{
// Remove rate if cleared
if (record != null)
await _unitOfWork.ShopWorkerRoleCosts.SoftDeleteAsync(record.Id);
}
else if (record == null)
{
await _unitOfWork.ShopWorkerRoleCosts.AddAsync(new PowderCoating.Core.Entities.ShopWorkerRoleCost
{
CompanyId = companyId.Value,
Role = (PowderCoating.Core.Enums.ShopWorkerRole)dto.Role,
HourlyRate = dto.HourlyRate,
CreatedAt = DateTime.UtcNow
});
}
else
{
record.HourlyRate = dto.HourlyRate;
record.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ShopWorkerRoleCosts.UpdateAsync(record);
}
}
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving role costs");
return Json(new { success = false, message = "An error occurred saving role rates." });
}
}
// ─── Stripe Connect ───────────────────────────────────────────────────────
/// <summary>
/// Initiates the Stripe Connect OAuth flow by redirecting the browser to Stripe's authorization URL.
/// Guards against unconfigured environments by detecting the placeholder client ID string and showing
/// a friendly error rather than redirecting to a broken Stripe URL. The <c>state</c> parameter passed
/// to <see cref="IStripeConnectService.GetOAuthUrl"/> is the companyId (as a string) so the callback
/// can identify which tenant completed the flow without relying on session state, which is not
/// reliable across Stripe's redirect.
/// </summary>
[HttpGet]
public IActionResult ConnectStripe()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Forbid();
var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
if (string.IsNullOrWhiteSpace(connectClientId) ||
connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase))
{
TempData["Error"] = "Stripe Connect is not configured. A platform administrator must set Stripe:Connect:ConnectClientId in appsettings.json before companies can connect their Stripe accounts.";
return RedirectToAction("Index", "CompanySettings", new { tab = "online-payments" });
}
var redirectUri = Url.Action("StripeCallback", "CompanySettings", null, Request.Scheme)!;
var url = _stripeConnect.GetOAuthUrl(companyId.Value, redirectUri);
return Redirect(url);
}
/// <summary>
/// Handles the OAuth redirect callback from Stripe Connect. Stripe sends the authorization
/// <paramref name="code"/> and the <paramref name="state"/> value (company ID) we passed in
/// <see cref="ConnectStripe"/>. If the user declined on Stripe's side, the <paramref name="error"/>
/// parameter is non-null and the flow is aborted gracefully. On success the connected Stripe
/// account ID is stored on the company record by <c>HandleOAuthCallbackAsync</c>, enabling
/// payment link generation for that tenant. Always redirects to the online-payments tab regardless
/// of outcome so the user sees the updated connection status.
/// </summary>
[HttpGet]
public async Task<IActionResult> StripeCallback(string code, string state, string? error = null)
{
try
{
if (!string.IsNullOrEmpty(error))
{
TempData["Error"] = $"Stripe connection was declined: {error}";
return RedirectToAction("Index", "CompanySettings", new { tab = "online-payments" });
}
if (!int.TryParse(state, out var companyId))
{
TempData["Error"] = "Invalid callback state.";
return RedirectToAction("Index", "CompanySettings", new { tab = "online-payments" });
}
var (success, errorMsg) = await _stripeConnect.HandleOAuthCallbackAsync(code, companyId);
if (success)
TempData["Success"] = "Stripe account connected successfully! You can now send payment links to customers.";
else
TempData["Error"] = $"Could not connect Stripe: {errorMsg}";
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception in StripeCallback");
TempData["Error"] = "An unexpected error occurred while connecting your Stripe account. Please try again.";
}
return RedirectToAction("Index", "CompanySettings", new { tab = "online-payments" });
}
/// <summary>
/// Revokes the Stripe Connect OAuth link for the current company. After disconnection the tenant
/// can no longer send payment links via Stripe until they reconnect via <see cref="ConnectStripe"/>.
/// The underlying <c>DisconnectAsync</c> also deauthorizes the token on Stripe's side, not just
/// clearing the stored account ID — this complies with Stripe's platform requirements for handling
/// user-initiated disconnections.
/// </summary>
[HttpPost]
public async Task<IActionResult> DisconnectStripe()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Forbid();
var (success, errorMsg) = await _stripeConnect.DisconnectAsync(companyId.Value);
if (success)
return Json(new { success = true, message = "Stripe account disconnected." });
return Json(new { success = false, message = errorMsg ?? "Failed to disconnect." });
}
/// <summary>
/// Saves the online payment surcharge configuration for the current company. Enforces a hard cap
/// of 3% on percentage-based surcharges to comply with Visa/Mastercard card network rules, which
/// prohibit merchants from passing more than 3% of the transaction amount as a surcharge to
/// cardholders. The <c>SurchargeAcknowledged</c> flag is stored so the UI can track whether the
/// admin has read and accepted the surcharge disclosure notice.
/// </summary>
[HttpPost]
public async Task<IActionResult> SaveOnlinePaymentSettings([FromBody] SaveOnlinePaymentSettingsDto dto)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Forbid();
// Cap surcharge at 3% to comply with card network rules
if (dto.SurchargeType == OnlinePaymentSurchargeType.Percent && dto.SurchargeValue > 3m)
return Json(new { success = false, message = "Surcharge percentage cannot exceed 3% per card network rules." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null) return NotFound();
company.OnlinePaymentSurchargeType = dto.SurchargeType;
company.OnlinePaymentSurchargeValue = dto.SurchargeValue;
company.OnlineSurchargeAcknowledged = dto.SurchargeAcknowledged;
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, message = "Online payment settings saved." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving online payment settings");
return Json(new { success = false, message = "An error occurred saving settings." });
}
}
// ─── Account Deletion ────────────────────────────────────────────────────
/// <summary>
/// Shows the self-service account deletion confirmation page. Loads a summary of the company's
/// data counts so the admin can see exactly what will be affected before committing.
/// </summary>
[HttpGet]
public async Task<IActionResult> DeleteAccount()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return RedirectToAction(nameof(Index));
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null) return NotFound();
var userCount = await _userManager.Users.CountAsync(u => u.CompanyId == companyId.Value);
var jobCount = await _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value);
var invCount = await _context.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
ViewBag.CompanyName = company.CompanyName;
ViewBag.UserCount = userCount;
ViewBag.JobCount = jobCount;
ViewBag.QuoteCount = quoteCount;
ViewBag.CustomerCount = custCount;
ViewBag.InvoiceCount = invCount;
return View();
}
/// <summary>
/// Processes the self-service account deletion request. Requires the admin to type "DELETE" and
/// check the acknowledgement checkbox. On success: soft-deletes the company, deactivates every
/// user account, writes a full audit log entry, signs the requesting user out, then redirects to
/// the login page with a confirmation message. All data is retained in the database under soft-delete
/// so SuperAdmins can audit or recover records; no rows are physically removed here.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteAccount(string confirmationWord, bool acknowledged)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return RedirectToAction(nameof(Index));
// ── Validate inputs ──────────────────────────────────────────────────
if (!acknowledged)
{
TempData["Error"] = "You must check the acknowledgement checkbox to proceed.";
return RedirectToAction(nameof(DeleteAccount));
}
if (!string.Equals(confirmationWord?.Trim(), "DELETE", StringComparison.Ordinal))
{
TempData["Error"] = "Confirmation word did not match. Please type DELETE (all capitals) to confirm.";
return RedirectToAction(nameof(DeleteAccount));
}
var requestingUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var requestingUserName = User.Identity?.Name ?? "Unknown";
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.Users);
if (company == null) return NotFound();
var companyName = company.CompanyName;
// ── Gather counts for the audit snapshot ─────────────────────────
var userCount = company.Users.Count;
var jobCount = await _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value);
var invCount = await _context.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
// ── Soft-delete the company ───────────────────────────────────────
var now = DateTime.UtcNow;
company.IsDeleted = true;
company.IsActive = false;
company.UpdatedAt = now;
// ── Deactivate all user accounts ─────────────────────────────────
foreach (var user in company.Users)
{
user.IsActive = false;
user.UpdatedAt = now;
await _userManager.UpdateAsync(user);
}
await _unitOfWork.CompleteAsync();
// ── Write audit log ───────────────────────────────────────────────
_context.AuditLogs.Add(new AuditLog
{
UserId = requestingUserId,
UserName = requestingUserName,
CompanyId = companyId.Value,
CompanyName = companyName,
Action = "SelfServiceAccountDeletion",
EntityType = "Company",
EntityId = companyId.Value.ToString(),
EntityDescription = companyName,
NewValues = $"Company soft-deleted via self-service. " +
$"Users deactivated: {userCount}. " +
$"Jobs: {jobCount}, Quotes: {quoteCount}, " +
$"Customers: {custCount}, Invoices: {invCount}. " +
$"Requested by: {requestingUserName} ({requestingUserId}).",
Timestamp = now,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
await _context.SaveChangesAsync();
_logger.LogWarning(
"Self-service account deletion: company {CompanyName} (ID:{CompanyId}) deleted by {User}. " +
"Users:{UserCount} Jobs:{JobCount} Quotes:{QuoteCount} Customers:{CustCount} Invoices:{InvCount}",
companyName, companyId.Value, requestingUserName,
userCount, jobCount, quoteCount, custCount, invCount);
// ── Sign out and redirect ─────────────────────────────────────────
await _signInManager.SignOutAsync();
TempData["AccountDeleted"] = "true";
TempData["DeletedCompanyName"] = companyName;
return RedirectToPage("/Account/Login", new { area = "Identity" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during self-service account deletion for company {CompanyId}", companyId);
TempData["Error"] = "An error occurred while deleting your account. Please contact support.";
return RedirectToAction(nameof(DeleteAccount));
}
}
}
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
public record SaveRoleCostDto(int Role, decimal HourlyRate);
public record SaveOnlinePaymentSettingsDto(
OnlinePaymentSurchargeType SurchargeType,
decimal SurchargeValue,
bool SurchargeAcknowledged);