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.Core.Interfaces.Services; 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 _logger; private readonly ICompanyLogoService _logoService; private readonly ILookupCacheService _lookupCache; private readonly IStripeConnectService _stripeConnect; private readonly IConfiguration _configuration; private readonly IAuditLogService _auditLog; private readonly UserManager _userManager; private readonly SignInManager _signInManager; private readonly IAzureBlobStorageService _blobStorage; private readonly ICustomFormulaAiService _formulaAiService; private readonly IFormulaLibraryService _formulaLibraryService; public CompanySettingsController( IUnitOfWork unitOfWork, IMapper mapper, ITenantContext tenantContext, ILogger logger, ICompanyLogoService logoService, ILookupCacheService lookupCache, IStripeConnectService stripeConnect, IConfiguration configuration, IAuditLogService auditLog, UserManager userManager, SignInManager signInManager, IAzureBlobStorageService blobStorage, ICustomFormulaAiService formulaAiService, IFormulaLibraryService formulaLibraryService) { _unitOfWork = unitOfWork; _mapper = mapper; _tenantContext = tenantContext; _logger = logger; _logoService = logoService; _lookupCache = lookupCache; _stripeConnect = stripeConnect; _configuration = configuration; _auditLog = auditLog; _userManager = userManager; _signInManager = signInManager; _blobStorage = blobStorage; _formulaAiService = formulaAiService; _formulaLibraryService = formulaLibraryService; } /// /// 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. /// // 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 }); } /// /// Renders the Company Settings hub page with all configuration tabs pre-loaded. /// Resolves the tenant from — 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 /// so the database never starts empty. /// The AllowOnlinePayments flag is read from SubscriptionPlanConfig (not hardcoded) /// so plan changes take effect without a deployment. /// // GET: CompanySettings public async Task 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(company); // Populate plan-gated feature flags var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan); dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false; dto.AllowSms = planConfig?.AllowSms ?? false; ViewBag.AllowCustomFormulas = AllowCustomFormulas(); dto.SmsEnabled = company.SmsEnabled; dto.SmsDisabledByAdmin = company.SmsDisabledByAdmin; dto.SmsTermsVersion = AppConstants.SmsTermsVersion; dto.HasCurrentSmsAgreement = await _unitOfWork.CompanySmsAgreements .AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion); // Timeclock settings dto.TimeclockEnabled = company.TimeclockEnabled; dto.TimeclockAllowMultiplePunchesPerDay = company.TimeclockAllowMultiplePunchesPerDay; dto.TimeclockAutoClockOutHours = company.TimeclockAutoClockOutHours; var kioskDevices = await _unitOfWork.TimeclockKioskDevices.FindAsync(d => d.CompanyId == companyId.Value); ViewBag.TimeclockKioskDevices = kioskDevices.OrderBy(d => d.ActivatedAt).ToList(); // 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.FindAsync(t => t.CompanyId == companyId.Value); var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList()); if (seeded > 0) existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value); 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(); ViewBag.BookLockedThrough = company.BookLockedThrough.HasValue ? (DateTime?)company.BookLockedThrough.Value.ToLocalTime() : null; 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"); } } /// /// 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. /// // POST: CompanySettings/UpdateCompanyInfo [HttpPost] public async Task 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." }); } } /// /// Locks the books through the given date, preventing new or edited accounting entries /// (JEs, bills, expenses) from being dated on or before this date. Null clears the lock. /// [HttpPost] [ValidateAntiForgeryToken] public async Task SetPeriodLock(DateTime? lockThrough) { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return BadRequest(); var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value); if (company == null) return NotFound(); company.BookLockedThrough = lockThrough.HasValue ? DateTime.SpecifyKind(lockThrough.Value.Date, DateTimeKind.Utc) : null; company.UpdatedAt = DateTime.UtcNow; await _unitOfWork.Companies.UpdateAsync(company); await _unitOfWork.CompleteAsync(); TempData["Success"] = lockThrough.HasValue ? $"Books locked through {lockThrough.Value:MMMM d, yyyy}." : "Period lock cleared — all periods are now open."; return RedirectToAction(nameof(Index), null, "company-info"); } /// /// Serves the current company's logo as a binary file response. Logos are stored on the filesystem /// via (primary) or as raw bytes in Company.LogoData /// (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). /// // GET: CompanySettings/Logo [HttpGet] [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByHeader = "Accept")] public async Task 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(); } /// /// Accepts a multipart logo upload, saves it to the filesystem via , /// and updates Company.LogoFilePath. Any previously stored filesystem file is deleted first /// to prevent orphaned files accumulating over repeated uploads. The legacy DB columns /// (LogoData and LogoContentType) 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. /// // POST: CompanySettings/UploadLogo [HttpPost] [ValidateAntiForgeryToken] public async Task 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." }); } } /// /// 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. /// // POST: CompanySettings/DeleteLogo [HttpPost] public async Task 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." }); } } /// /// Generic helper that persists any CompanyPreferences-mapped DTO via upsert. Because all /// preferences live on a single child entity () 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 (, /// , , etc.) delegate here so the /// upsert logic is maintained in one place. /// private async Task UpdatePreferences(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." }); } } /// /// Saves application-level defaults (currency, date format, default payment terms, quote validity days) /// to . Delegates to . /// // POST: CompanySettings/UpdateAppDefaults [HttpPost] public Task UpdateAppDefaults([FromBody] UpdateAppDefaultsDto dto) => UpdatePreferences(dto, "Application defaults saved successfully."); /// /// Saves job and workflow defaults (default turnaround days, auto-status transitions, shop access code /// requirements) to . Delegates to . /// // POST: CompanySettings/UpdateJobDefaults [HttpPost] public Task UpdateJobDefaults([FromBody] UpdateJobDefaultsDto dto) => UpdatePreferences(dto, "Job/workflow defaults saved successfully."); /// /// Saves notification channel preferences (email enabled, SMS enabled, in-app enabled) to /// . Delegates to . /// // POST: CompanySettings/UpdateNotifications [HttpPost] public Task UpdateNotifications([FromBody] UpdateNotificationsDto dto) => UpdatePreferences(dto, "Notification settings saved successfully."); /// /// Saves data-retention policy preferences (how long to keep soft-deleted records, completed jobs, etc.) /// to . Delegates to . /// // POST: CompanySettings/UpdateDataRetention [HttpPost] public Task UpdateDataRetention([FromBody] UpdateDataRetentionDto dto) => UpdatePreferences(dto, "Data retention settings saved successfully."); /// /// Saves Quote PDF layout preferences (footer text, T&C blurb, show/hide columns, signature line) /// to . Delegates to . /// // POST: CompanySettings/UpdateQuoteTemplate [HttpPost] public Task UpdateQuoteTemplate([FromBody] UpdateQuoteTemplateDto dto) => UpdatePreferences(dto, "Quote PDF settings saved successfully."); /// /// Saves Invoice PDF layout preferences (payment instructions, bank details, footer text) /// to . Delegates to . /// // POST: CompanySettings/UpdateInvoiceTemplate [HttpPost] public Task UpdateInvoiceTemplate([FromBody] UpdateInvoiceTemplateDto dto) => UpdatePreferences(dto, "Invoice PDF settings saved successfully."); // POST: CompanySettings/UpdateWorkOrderTemplate [HttpPost] public Task UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) => UpdatePreferences(dto, "Work order settings saved successfully."); /// /// Saves kiosk intake output preference ("Quote" or "Job") to . /// Delegates to . /// // POST: CompanySettings/UpdateKioskSettings [HttpPost] public Task UpdateKioskSettings([FromBody] UpdateKioskSettingsDto dto) => UpdatePreferences(dto, "Kiosk settings saved successfully."); /// /// 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 . These values feed the IPricingCalculationService /// for every quote and job costing calculation, so changes here take effect on all new quotes /// immediately. Uses an upsert pattern identical to . /// // POST: CompanySettings/UpdateOperatingCosts [HttpPost] public async Task 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." }); } } /// /// Saves the free-text AI context profile to CompanyOperatingCosts.AiContextProfile. /// This text is injected into the system prompt by AiQuoteService.BuildSystemPrompt(context) /// 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. /// // POST: CompanySettings/UpdateAiProfile [HttpPost] public async Task 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." }); } } /// /// Toggles the company-level SMS opt-in flag. When enabling and no current-version agreement /// exists, the request must include AgreedToTerms=true and a matching TermsVersion — the /// acceptance is then recorded as a audit row. /// Disabling never requires agreement. /// // POST: CompanySettings/UpdateSmsPreferences [HttpPost] public async Task UpdateSmsPreferences([FromBody] UpdateSmsPreferencesDto 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); if (company == null) return Json(new { success = false, message = "Company not found." }); if (dto.SmsEnabled) { var hasAgreement = await _unitOfWork.CompanySmsAgreements .AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion); if (!hasAgreement) { // Require explicit acceptance of the current terms version if (!dto.AgreedToTerms || dto.TermsVersion != AppConstants.SmsTermsVersion) return Json(new { success = false, requiresAgreement = true, message = "You must accept the SMS terms of service to enable SMS notifications." }); var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? string.Empty; var userName = User.Identity?.Name ?? string.Empty; var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); var ua = Request.Headers.UserAgent.ToString(); var agreement = new CompanySmsAgreement { CompanyId = companyId.Value, AgreedByUserId = userId, AgreedByUserName = userName, AgreedAt = DateTime.UtcNow, IpAddress = ip, UserAgent = ua, TermsVersion = AppConstants.SmsTermsVersion, CreatedAt = DateTime.UtcNow }; await _unitOfWork.CompanySmsAgreements.AddAsync(agreement); _logger.LogInformation("Company {CompanyId} accepted SMS terms v{Version} by user {UserId} from {Ip}", companyId, AppConstants.SmsTermsVersion, userId, ip); } } company.SmsEnabled = dto.SmsEnabled; await _unitOfWork.Companies.UpdateAsync(company); await _unitOfWork.CompleteAsync(); _logger.LogInformation("Company {CompanyId} SMS opt-in set to {SmsEnabled}", companyId, dto.SmsEnabled); return Json(new { success = true, message = dto.SmsEnabled ? "SMS notifications enabled." : "SMS notifications disabled." }); } catch (Exception ex) { _logger.LogError(ex, "Error updating SMS preferences"); return Json(new { success = false, message = "An error occurred while saving SMS preferences." }); } } // POST: CompanySettings/UpdateTimeclockSettings /// /// Saves company-level timeclock settings (enabled toggle, multiple-punches-per-day, auto clock-out hours). /// [HttpPost] public async Task UpdateTimeclockSettings([FromBody] UpdateTimeclockSettingsDto 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); if (company == null) return Json(new { success = false, message = "Company not found." }); company.TimeclockEnabled = dto.TimeclockEnabled; company.TimeclockAllowMultiplePunchesPerDay = dto.TimeclockAllowMultiplePunchesPerDay; company.TimeclockAutoClockOutHours = dto.TimeclockAutoClockOutHours; await _unitOfWork.Companies.UpdateAsync(company); await _unitOfWork.CompleteAsync(); _logger.LogInformation("Company {CompanyId} timeclock settings updated", companyId); return Json(new { success = true, message = "Timeclock settings saved." }); } catch (Exception ex) { _logger.LogError(ex, "Error updating timeclock settings"); return Json(new { success = false, message = "An error occurred while saving timeclock settings." }); } } /// /// Builds a suggested AI profile draft from existing company configuration — company name/location, /// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and /// operating cost rates. Returns a pre-filled paragraph the user can review and edit before saving. /// // GET: CompanySettings/GenerateAiProfileDraft [HttpGet] public async Task GenerateAiProfileDraft() { try { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return Json(new { success = false, message = "No company found." }); var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false, c => c.OperatingCosts); if (company == null) return Json(new { success = false, message = "Company not found." }); var costs = company.OperatingCosts; var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive && o.CompanyId == companyId.Value)).OrderBy(o => o.DisplayOrder).ToList(); var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating && c.CompanyId == companyId.Value)).ToList(); var sb = new System.Text.StringBuilder(); // Opening line var location = new[] { company.City, company.State }.Where(s => !string.IsNullOrWhiteSpace(s)); var locationStr = string.Join(", ", location); sb.Append(company.CompanyName); if (!string.IsNullOrWhiteSpace(locationStr)) sb.Append($" is a powder coating shop based in {locationStr}."); else sb.Append(" is a powder coating shop."); sb.AppendLine(); sb.AppendLine(); // Shop size if (costs != null) { var tierLabel = costs.ShopCapabilityTier switch { ShopCapabilityTier.Garage => "garage/hobbyist", ShopCapabilityTier.Small => "small", ShopCapabilityTier.Medium => "medium-sized", ShopCapabilityTier.Large => "high-volume", _ => "small" }; sb.AppendLine($"We are a {tierLabel} operation."); } // Ovens if (ovens.Any()) { sb.AppendLine(); sb.AppendLine("Our curing ovens:"); foreach (var oven in ovens) { var parts = new List(); if (oven.MaxLoadSqFt.HasValue && oven.MaxLoadSqFt > 0) parts.Add($"{oven.MaxLoadSqFt:0} sq ft capacity"); if (oven.DefaultCycleMinutes.HasValue && oven.DefaultCycleMinutes > 0) parts.Add($"{oven.DefaultCycleMinutes} min cure cycle"); var detail = parts.Any() ? $" ({string.Join(", ", parts)})" : ""; sb.AppendLine($"• {oven.Label}{detail}"); } } // Equipment capabilities inferred from rates if (costs != null) { var capabilities = new List(); if (costs.SandblasterCostPerHour > 0) capabilities.Add("sandblasting / media blasting"); if (costs.CoatingBoothCostPerHour > 0) capabilities.Add("powder coating booth"); if (capabilities.Any()) { sb.AppendLine(); sb.AppendLine($"We have in-house {string.Join(" and ", capabilities)} capability."); } } // Powder/coating categories if (coatingCategories.Any()) { sb.AppendLine(); var catNames = coatingCategories.Select(c => c.DisplayName).ToList(); sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}."); } // Rates hint if (costs != null && costs.StandardLaborRate > 0) { sb.AppendLine(); sb.AppendLine($"Our standard labor rate is ${costs.StandardLaborRate:0.00}/hr. " + $"We target approximately {costs.GeneralMarkupPercentage:0}% markup on all jobs."); } sb.AppendLine(); sb.AppendLine("(Edit this profile to add detail about the types of parts you typically coat, " + "any brands of powder you prefer, your cure temperature, or anything else that " + "helps the AI understand your shop better.)"); return Json(new { success = true, draft = sb.ToString().Trim() }); } catch (Exception ex) { _logger.LogError(ex, "Error generating AI profile draft"); return Json(new { success = false, message = "An error occurred while generating the draft." }); } } /// /// Saves the Quoting Calibration / Shop Capability profile. Maps equipment fields onto /// and returns the freshly derived blast and coating /// rates so the UI can update the "estimated rate" display without a page reload. /// // POST: CompanySettings/UpdateBlastProfile [HttpPost] public async Task 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 /// /// Returns all job statuses for the current company ordered by DisplayOrder, 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 Jobs is automatically applied, /// guaranteeing the counts reflect only this tenant's data without extra filtering code. /// [HttpGet] public async Task GetJobStatuses() { try { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId); var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList(); var dtos = _mapper.Map>(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." }); } } /// /// Creates a new custom job status for the current company and immediately invalidates the /// 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. /// [HttpPost] public async Task 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(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." }); } } /// /// Updates label, color, and display properties of a custom job status. System-defined statuses /// (those where IsSystemDefined == true) 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. /// [HttpPost] public async Task 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." }); } } /// /// 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. /// [HttpPost] public async Task 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." }); } } /// /// Bulk-updates DisplayOrder 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. /// [HttpPost] public async Task ReorderJobStatuses([FromBody] ReorderLookupDto dto) { try { if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data" }); var companyId = _tenantContext.GetCurrentCompanyId(); var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == (companyId ?? 0)); 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(); 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 /// /// Returns all job priorities for the current company ordered by DisplayOrder, 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. /// [HttpGet] public async Task GetJobPriorities() { try { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId); var sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList(); var dtos = _mapper.Map>(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." }); } } /// /// 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 /// because the priority list is not currently cached — this should be added if performance requires it. /// [HttpPost] public async Task 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(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." }); } } /// /// 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 /// (IsSystemDefined, PriorityCode) remain unchanged. /// [HttpPost] public async Task 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." }); } } /// /// 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. /// [HttpPost] public async Task 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." }); } } /// /// Bulk-updates DisplayOrder 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". /// [HttpPost] public async Task ReorderJobPriorities([FromBody] ReorderLookupDto dto) { try { if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data" }); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId); 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 /// /// Returns all quote statuses for the current company ordered by DisplayOrder, 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. /// [HttpGet] public async Task GetQuoteStatuses() { try { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId); var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList(); var dtos = _mapper.Map>(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." }); } } /// /// Creates a new custom quote status for the company. Enforces two critical singleton business rules: /// only one status may be flagged IsApprovedStatus (the flag used by the quote-to-job /// conversion path) and only one may be flagged IsConvertedStatus (used to mark quotes that /// generated a job). Allowing multiple "approved" statuses would break the conversion workflow by /// making the trigger condition ambiguous. /// [HttpPost] public async Task 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(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." }); } } /// /// Updates a custom quote status. Re-validates the IsApprovedStatus and IsConvertedStatus /// 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. /// [HttpPost] public async Task 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." }); } } /// /// 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. /// [HttpPost] public async Task 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." }); } } /// /// Bulk-updates DisplayOrder 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 /// . /// [HttpPost] public async Task ReorderQuoteStatuses([FromBody] ReorderLookupDto dto) { try { if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data" }); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId); 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 /// /// Returns all prep services (sandblasting, media blast, chemical strip, etc.) for the current /// company ordered by DisplayOrder. 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. /// [HttpGet] public async Task GetPrepServices() { try { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId); var sortedServices = services.OrderBy(s => s.DisplayOrder).ToList(); var dtos = _mapper.Map>(sortedServices); return Json(dtos); } catch (Exception ex) { _logger.LogError(ex, "Error loading prep services"); return Json(new { success = false, message = "Error loading prep services." }); } } /// /// 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. /// [HttpPost] public async Task 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(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." }); } } /// /// 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. /// [HttpPost] public async Task 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." }); } } /// /// 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. /// [HttpPost] public async Task 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." }); } } /// /// Bulk-updates DisplayOrder 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. /// [HttpPost] public async Task ReorderPrepServices([FromBody] ReorderLookupDto dto) { try { if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data" }); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId); 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 /// Returns all active blast setups for the current company with their derived rates. [HttpGet] public async Task 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." }); } } /// Creates or updates a named blast setup. [HttpPost] public async Task 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." }); } } /// Soft-deletes a named blast setup. [HttpPost] public async Task 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 /// /// Returns all appointment type lookups for the current company ordered by DisplayOrder, /// 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. /// [HttpGet] public async Task GetAppointmentTypes() { try { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId); var sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList(); var dtos = _mapper.Map>(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." }); } } /// /// 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. /// [HttpPost] public async Task 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(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." }); } } /// /// 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. /// [HttpPost] public async Task 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." }); } } /// /// 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. /// [HttpPost] public async Task 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." }); } } /// /// Bulk-updates DisplayOrder for appointment types to match the drag-sorted ID list from the UI. /// Display order controls the sequence in appointment scheduling dropdowns. /// [HttpPost] public async Task ReorderAppointmentTypes([FromBody] ReorderLookupDto dto) { try { if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data" }); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId); 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 /// /// Returns all inventory categories for the current company ordered by DisplayOrder, /// 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. /// [HttpGet] public async Task GetInventoryCategories() { try { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId); var sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList(); var dtos = _mapper.Map>(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." }); } } /// /// 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 AnyAsync which applies /// the global tenant filter, so only this company's codes are checked. /// [HttpPost] public async Task 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(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." }); } } /// /// 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. /// [HttpPost] public async Task 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." }); } } /// /// 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. /// [HttpPost] public async Task 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." }); } } /// /// Bulk-updates DisplayOrder for inventory categories to match the drag-sorted ID list. /// Display order controls the sequence in inventory filtering tabs and reporting groupings. /// [HttpPost] public async Task ReorderInventoryCategories([FromBody] ReorderLookupDto dto) { try { if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data" }); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId); 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 /// /// Returns all named ovens (OvenCost records) for the current company ordered by display order then /// label. An explicit CompanyId filter is used here rather than relying solely on the global /// query filter because OvenCost did not originally participate in the multi-tenancy filter /// and the explicit check provides a defense-in-depth guarantee. Each oven exposes /// MaxLoadSqFt and DefaultCycleMinutes used by the Oven Scheduler for capacity planning. /// [HttpGet] public async Task 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." }); } } /// /// Creates a new named oven () 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 CreateOvenCostDto /// classes that may exist in different namespaces. /// MaxLoadSqFt and DefaultCycleMinutes are optional — the scheduler degrades /// gracefully when they are null (no capacity enforcement). /// [HttpPost] public async Task 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." }); } } /// /// Updates an existing oven's label, hourly cost, capacity, and cycle time. An explicit /// CompanyId ownership check (oven.CompanyId != companyId.Value) 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 (IsActive = false) /// hides it from the Oven Scheduler dropdown without deleting historical batch records. /// [HttpPost] public async Task 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." }); } } /// /// Soft-deletes a named oven after verifying it is not referenced by any existing quotes. /// Quotes store OvenCostId 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 for defense-in-depth. /// [HttpPost] public async Task 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 /// /// Renders the standalone Notification Templates management page (separate from the main Settings /// hub). Calls 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. /// // GET: CompanySettings/NotificationTemplates [HttpGet] public async Task NotificationTemplates() { var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId == null) return RedirectToAction(nameof(Index)); // Load all existing templates for this company var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value); // Auto-seed any missing canonical combinations var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList()); if (seeded > 0) existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value); 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); } /// /// Returns a single notification template as a JSON object for the inline template editor modal. /// Includes the applicable placeholder list (built by ) so /// the editor can display context-sensitive insertion buttons without a separate round-trip. /// The isEmail flag drives whether the Subject field is shown in the editor UI. /// // GET: CompanySettings/GetTemplateJson/{id} [HttpGet] public async Task 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 }); } /// /// 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. /// // POST: CompanySettings/SaveTemplateJson [HttpPost] public async Task 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." }); } /// /// Resets a notification template to its factory defaults from the inline Settings hub modal editor. /// Default content is sourced from SeedData.BuildDefaultNotificationTemplates — 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. /// // POST: CompanySettings/ResetTemplateJson/{id} [HttpPost] public async Task 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 }); } /// /// Renders the dedicated full-page template editor for a single notification template. Populates /// ViewBag.Placeholders with the context-sensitive list from /// 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. /// // GET: CompanySettings/EditTemplate/{id} [HttpGet] public async Task 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); } /// /// 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. /// // POST: CompanySettings/EditTemplate/{id} [HttpPost] [ValidateAntiForgeryToken] public async Task 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"); } /// /// Resets a template to factory defaults from the full-page editor. Unlike /// (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. /// // POST: CompanySettings/ResetTemplate/{id} [HttpPost] [ValidateAntiForgeryToken] public async Task 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"); } /// /// 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 SeedData.BuildDefaultNotificationTemplates are automatically /// provisioned without requiring a migration or a manual "Seed Data" action by the platform admin. /// Also removes stale rows (no longer in the canonical list) that have never been customised /// (UpdatedAt == null), so retired notification types disappear from the UI automatically. /// Returns the count of changes so the caller can decide whether to reload from the DB. /// private async Task EnsureNotificationTemplatesSeededAsync( int companyId, List existing) { var allDefaults = SeedData.BuildDefaultNotificationTemplates(companyId); var toAdd = allDefaults .Where(d => !existing.Any(e => e.NotificationType == d.NotificationType && e.Channel == d.Channel)) .ToList(); // Remove rows that are no longer canonical and have never been customised. var toRemove = existing .Where(e => !allDefaults.Any(d => d.NotificationType == e.NotificationType && d.Channel == e.Channel) && e.UpdatedAt == null) .ToList(); foreach (var t in toAdd) await _unitOfWork.NotificationTemplates.AddAsync(t); foreach (var t in toRemove) await _unitOfWork.NotificationTemplates.DeleteAsync(t); if (toAdd.Count > 0 || toRemove.Count > 0) await _unitOfWork.CompleteAsync(); return toAdd.Count + toRemove.Count; } /// /// Returns the list of Handlebars-style placeholder tokens applicable to a given notification type. /// The base set ({{companyName}}, {{customerName}}) 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: {{quoteExpiry}} 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. /// 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.QuoteApprovedByCustomer 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 is NotificationType.QuoteApprovedByCustomer or NotificationType.QuoteDeclinedByCustomer) { list.Add(("{{response}}", "Customer's response — either \"APPROVED\" or \"DECLINED\"")); } if (type == NotificationType.QuoteDeclinedByCustomer) { 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")); list.Add(("{{viewUrl}}", "Permanent link for the customer to view the invoice online (used in SMS)")); } 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 ──────────────────────────────────────────────── // ─── Stripe Connect ─────────────────────────────────────────────────────── /// /// 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 state parameter passed /// to 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. /// [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); } /// /// Handles the OAuth redirect callback from Stripe Connect. Stripe sends the authorization /// and the value (company ID) we passed in /// . If the user declined on Stripe's side, the /// parameter is non-null and the flow is aborted gracefully. On success the connected Stripe /// account ID is stored on the company record by HandleOAuthCallbackAsync, 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. /// [HttpGet] public async Task 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" }); } /// /// 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 . /// The underlying DisconnectAsync 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. /// [HttpPost] public async Task 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." }); } /// /// 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 SurchargeAcknowledged flag is stored so the UI can track whether the /// admin has read and accepted the surcharge disclosure notice. /// [HttpPost] public async Task 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 ──────────────────────────────────────────────────── /// /// 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. /// [HttpGet] public async Task 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 _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value); var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value); var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value); var invCount = await _unitOfWork.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(); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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 _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value); var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value); var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value); var invCount = await _unitOfWork.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 ─────────────────────────────────────────────── await _auditLog.LogAsync(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() }); _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)); } } // ─── Custom Formula Item Templates ────────────────────────────────────────── private bool AllowCustomFormulas() => HttpContext.Items["AllowCustomFormulas"] as bool? ?? false; /// Returns all active + inactive formula templates for the current company. [HttpGet] public async Task GetCustomItemTemplate(int id) { if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." }); var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id); if (entity == null || entity.CompanyId != companyId) return Json(new { success = false, message = "Template not found." }); var dto = _mapper.Map(entity); return Json(new { success = true, template = dto }); } /// Returns all active + inactive formula templates for the current company. [HttpGet] public async Task GetCustomItemTemplates() { if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." }); var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var templates = await _unitOfWork.CustomItemTemplates.FindAsync( t => t.CompanyId == companyId); var dtos = _mapper.Map>(templates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)); return Json(new { success = true, templates = dtos }); } /// Downloads all formula templates as a portable JSON backup file. [HttpGet] public async Task ExportCustomItemTemplates() { if (!AllowCustomFormulas()) return Forbid(); var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var templates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId); // Parse FieldsJson into a real JsonElement so it is embedded as a proper JSON array // in the export file rather than as an escaped string. This makes the file human-readable // and avoids round-trip corruption when files are manually edited. static System.Text.Json.JsonElement ParseFields(string? raw) { try { return System.Text.Json.JsonDocument.Parse(raw ?? "[]").RootElement.Clone(); } catch { return System.Text.Json.JsonDocument.Parse("[]").RootElement.Clone(); } } var export = new { exportedAt = DateTime.UtcNow, version = 1, templates = templates .OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name) .Select(t => new { t.Name, t.Description, t.OutputMode, Fields = ParseFields(t.FieldsJson), t.Formula, t.DefaultRate, t.RateLabel, t.Notes, t.DisplayOrder, t.IsActive }) }; var json = System.Text.Json.JsonSerializer.Serialize(export, new System.Text.Json.JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); var filename = $"formula-templates-{DateTime.UtcNow:yyyyMMdd}.json"; return File(System.Text.Encoding.UTF8.GetBytes(json), "application/json", filename); } /// /// Imports formula templates from a JSON backup file produced by ExportCustomItemTemplates. /// Templates whose name already exists in the company are skipped; all others are created. /// [HttpPost] [ValidateAntiForgeryToken] public async Task ImportCustomItemTemplates(IFormFile file) { if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." }); if (file == null || file.Length == 0) return Json(new { success = false, message = "No file selected." }); if (!file.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) return Json(new { success = false, message = "File must be a .json export file." }); if (file.Length > 512 * 1024) return Json(new { success = false, message = "File is too large (max 512 KB)." }); string json; using (var reader = new System.IO.StreamReader(file.OpenReadStream())) json = await reader.ReadToEndAsync(); System.Text.Json.JsonElement root; try { root = System.Text.Json.JsonDocument.Parse(json).RootElement; } catch { return Json(new { success = false, message = "Could not parse file — make sure it is a valid formula export." }); } if (!root.TryGetProperty("templates", out var templatesEl) || templatesEl.ValueKind != System.Text.Json.JsonValueKind.Array) return Json(new { success = false, message = "Invalid export format: missing \"templates\" array." }); var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var existing = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId); // Track names already in DB + names imported within this same file to prevent intra-file duplicates var usedNames = existing.Select(t => t.Name.ToLowerInvariant()).ToHashSet(); int imported = 0, skipped = 0; var skippedNames = new List(); var errors = new List(); foreach (var item in templatesEl.EnumerateArray()) { try { var name = item.TryGetProperty("name", out var nEl) ? nEl.GetString() ?? "" : ""; if (string.IsNullOrWhiteSpace(name)) { errors.Add("Skipped one template with no name."); continue; } if (usedNames.Contains(name.ToLowerInvariant())) { skipped++; skippedNames.Add(name); continue; } var dto = new CreateCustomItemTemplateDto { Name = name, Description = item.TryGetProperty("description", out var d) ? d.GetString() : null, OutputMode = item.TryGetProperty("outputMode", out var om) ? om.GetString() ?? "FixedRate" : "FixedRate", // "fields" is a real JSON array in the export; GetRawText() reconstructs the string FieldsJson = item.TryGetProperty("fields", out var fj) ? fj.GetRawText() : "[]", Formula = item.TryGetProperty("formula", out var f) ? f.GetString() ?? "" : "", DefaultRate = item.TryGetProperty("defaultRate", out var dr) && dr.ValueKind == System.Text.Json.JsonValueKind.Number ? dr.GetDecimal() : null, RateLabel = item.TryGetProperty("rateLabel", out var rl) ? rl.GetString() : null, Notes = item.TryGetProperty("notes", out var n) ? n.GetString() : null, DisplayOrder = item.TryGetProperty("displayOrder", out var dord) && dord.ValueKind == System.Text.Json.JsonValueKind.Number ? dord.GetInt32() : 0, IsActive = true, }; var fieldError = ValidateTemplateFields(dto.FieldsJson); if (fieldError != null) { errors.Add($"\"{name}\": {fieldError}"); continue; } var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula); if (formulaError != null) { errors.Add($"\"{name}\": formula error — {formulaError}"); continue; } dto.Formula = normalizedFormula; var entity = _mapper.Map(dto); entity.CompanyId = companyId; entity.CreatedAt = DateTime.UtcNow; await _unitOfWork.CustomItemTemplates.AddAsync(entity); usedNames.Add(name.ToLowerInvariant()); imported++; } catch (Exception ex) { errors.Add($"Unexpected error on one template: {ex.Message}"); } } if (imported > 0) await _unitOfWork.CompleteAsync(); return Json(new { success = true, imported, skipped, skippedNames, errors }); } /// Creates a new formula template for the current company. [HttpPost] public async Task CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto) { if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." }); if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data." }); var fieldError = ValidateTemplateFields(dto.FieldsJson); if (fieldError != null) return Json(new { success = false, message = fieldError }); var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula); if (formulaError != null) return Json(new { success = false, message = $"Formula error: {formulaError}" }); dto.Formula = normalizedFormula; var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var entity = _mapper.Map(dto); entity.CompanyId = companyId; entity.CreatedAt = DateTime.UtcNow; await _unitOfWork.CustomItemTemplates.AddAsync(entity); await _unitOfWork.CompleteAsync(); return Json(new { success = true, id = entity.Id }); } /// Updates an existing formula template owned by the current company. [HttpPost] public async Task UpdateCustomItemTemplate([FromBody] UpdateCustomItemTemplateDto dto) { if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." }); if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data." }); var fieldError = ValidateTemplateFields(dto.FieldsJson); if (fieldError != null) return Json(new { success = false, message = fieldError }); var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula); if (formulaError != null) return Json(new { success = false, message = $"Formula error: {formulaError}" }); dto.Formula = normalizedFormula; var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id); if (entity == null || entity.CompanyId != companyId) return Json(new { success = false, message = "Template not found." }); _mapper.Map(dto, entity); entity.UpdatedAt = DateTime.UtcNow; // If this was imported from the library, mark it as modified so the share button appears if (entity.SourceFormulaLibraryItemId.HasValue) entity.IsModifiedFromSource = true; await _unitOfWork.CompleteAsync(); return Json(new { success = true }); } /// Soft-deletes a formula template owned by the current company. [HttpPost] public async Task DeleteCustomItemTemplate(int id) { if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." }); var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id); if (entity == null || entity.CompanyId != companyId) return Json(new { success = false, message = "Template not found." }); await _unitOfWork.CustomItemTemplates.SoftDeleteAsync(id); await _unitOfWork.CompleteAsync(); return Json(new { success = true }); } // ── Community Library: share / unshare / status ─────────────────────── /// /// Returns the community library status for a given template: whether it is published, /// eligible to share, and where it was originally imported from if applicable. /// [HttpGet] public async Task FormulaLibraryStatus(int templateId) { if (!AllowCustomFormulas()) return Json(new { canShare = false }); var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var status = await _formulaLibraryService.GetTemplateLibraryStatusAsync(templateId, companyId); return Json(status); } /// /// Publishes a company template to the community library (or re-publishes after unshare). /// Only templates that are original creations or modified imports may be shared. /// [HttpPost] public async Task ShareFormula([FromBody] PowderCoating.Application.DTOs.Company.ShareFormulaRequest request) { if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." }); var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? ""; try { var libraryItemId = await _formulaLibraryService.ShareAsync(companyId, userId, request); return Json(new { success = true, libraryItemId }); } catch (InvalidOperationException ex) { return Json(new { success = false, message = ex.Message }); } } /// Removes a template from the community library. Existing company imports are unaffected. [HttpPost] public async Task UnshareFormula(int libraryItemId) { var companyId = _tenantContext.GetCurrentCompanyId()!.Value; await _formulaLibraryService.UnshareAsync(libraryItemId, companyId); return Json(new { success = true }); } /// /// Uploads a diagram image for a template to blob storage container /// formulatemplate-diagrams/{companyId}/{templateId}/diagram.{ext}. /// Returns the blob path for storage on the entity. /// [HttpPost] public async Task UploadTemplateDiagram(int templateId, IFormFile diagramFile) { if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." }); var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId); if (entity == null || entity.CompanyId != companyId) return Json(new { success = false, message = "Template not found." }); var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" }; if (!allowedTypes.Contains(diagramFile.ContentType.ToLowerInvariant())) return Json(new { success = false, message = "Only JPEG, PNG, GIF, or WebP images are allowed." }); if (diagramFile.Length > 10 * 1024 * 1024) return Json(new { success = false, message = "Image must be under 10 MB." }); var ext = Path.GetExtension(diagramFile.FileName).ToLowerInvariant().TrimStart('.'); var blobPath = $"{companyId}/{templateId}/diagram.{ext}"; using var stream = diagramFile.OpenReadStream(); var (ok, err) = await _blobStorage.UploadAsync("formulatemplate-diagrams", blobPath, stream, diagramFile.ContentType); if (!ok) return Json(new { success = false, message = err }); entity.DiagramImagePath = blobPath; entity.UpdatedAt = DateTime.UtcNow; await _unitOfWork.CompleteAsync(); return Json(new { success = true, diagramImagePath = blobPath }); } /// /// Serves a template diagram image from blob storage. The path is tenant-scoped /// so cross-company access is prevented by checking CompanyId on the template. /// [HttpGet] public async Task TemplateDiagram(int templateId) { var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId); if (entity == null || entity.CompanyId != companyId || string.IsNullOrEmpty(entity.DiagramImagePath)) return NotFound(); var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", entity.DiagramImagePath); if (!ok || bytes == null || bytes.Length == 0) return NotFound(); return File(bytes, contentType ?? "image/jpeg"); } /// /// Evaluates a NCalc formula with the supplied variable values, automatically injecting /// three read-only shop-rate variables sourced from the company's operating costs: /// standard_labor_rate, additional_coat_labor_pct, and markup_pct. /// User-supplied variables take precedence so the test panel can override them. /// [HttpPost] public async Task EvaluateFormula([FromBody] EvaluateFormulaRequest req) { if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." }); // Inject shop-rate system variables; user-supplied values win if the same key appears in both. var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId != null) { var costs = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => c.CompanyId == companyId.Value); if (costs != null) req = InjectShopRateVariables(req, costs); } var result = _formulaAiService.EvaluateFormula(req); return Json(result); } /// /// Merges standard_labor_rate, additional_coat_labor_pct, and markup_pct /// from into the request's variable map without overwriting any key /// the caller already set (so the test panel can still override these values explicitly). /// private static EvaluateFormulaRequest InjectShopRateVariables( EvaluateFormulaRequest req, CompanyOperatingCosts costs) { var vars = System.Text.Json.JsonSerializer .Deserialize>(req.VariablesJson ?? "{}") ?? new(); void Inject(string key, decimal value) { if (!vars.ContainsKey(key)) vars[key] = System.Text.Json.JsonDocument.Parse(value.ToString("G")).RootElement.Clone(); } Inject("standard_labor_rate", costs.StandardLaborRate); Inject("additional_coat_labor_pct", costs.AdditionalCoatLaborPercent); Inject("markup_pct", costs.GeneralMarkupPercentage); return new EvaluateFormulaRequest { Formula = req.Formula, VariablesJson = System.Text.Json.JsonSerializer.Serialize(vars) }; } /// /// Calls Claude to generate a formula template from a natural-language description /// and an optional diagram image uploaded in the same multipart form. /// [HttpPost] public async Task GenerateFormulaFromAi([FromForm] string description, IFormFile? diagramImage) { if (!AllowCustomFormulas()) return Json(new { success = false, error = "Custom Formulas are not available on your current plan." }); if (string.IsNullOrWhiteSpace(description)) return Json(new { success = false, error = "Description is required." }); byte[]? imageBytes = null; string? imageContentType = null; if (diagramImage is { Length: > 0 }) { using var ms = new MemoryStream(); await diagramImage.CopyToAsync(ms); imageBytes = ms.ToArray(); imageContentType = diagramImage.ContentType; } var result = await _formulaAiService.GenerateFormulaAsync( new GenerateFormulaFromAiRequest { Description = description }, imageBytes, imageContentType); return Json(result); } /// /// Validates field variable names in a fieldsJson array against NCalc identifier rules: /// must start with a letter, contain only letters/digits/underscores, and not use the /// reserved name "rate" (which is auto-populated from the template's Default Rate). /// Returns an error message string on failure, or null if all names are valid. /// private static string? ValidateTemplateFields(string? fieldsJson) { if (string.IsNullOrWhiteSpace(fieldsJson)) return null; List? fields; try { fields = System.Text.Json.JsonSerializer.Deserialize>(fieldsJson); } catch { return "Invalid fields JSON."; } if (fields == null) return null; var nameRegex = new System.Text.RegularExpressions.Regex(@"^[a-zA-Z][a-zA-Z0-9_]*$"); var seen = new HashSet(StringComparer.Ordinal); foreach (var field in fields) { var name = field.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : ""; if (string.IsNullOrEmpty(name)) return "All fields must have a variable name."; if (name == "rate") return $"\"rate\" is a reserved variable name — it is pre-populated from the template's Default Rate."; if (!nameRegex.IsMatch(name)) return $"Invalid field name \"{name}\": must start with a letter and contain only letters, digits, or underscores."; if (!seen.Add(name)) return $"Duplicate field name \"{name}\"."; } return null; } } public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body); public record SaveOnlinePaymentSettingsDto( OnlinePaymentSurchargeType SurchargeType, decimal SurchargeValue, bool SurchargeAcknowledged);