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

1132 lines
56 KiB
C#

using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Extensions;
using System.Security.Claims;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Controller for managing companies (SuperAdmin only)
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class CompaniesController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ISeedDataService _seedDataService;
private readonly ApplicationDbContext _context;
private readonly IInAppNotificationService _inApp;
private readonly ILogger<CompaniesController> _logger;
public CompaniesController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ISeedDataService seedDataService,
ApplicationDbContext context,
IInAppNotificationService inApp,
ILogger<CompaniesController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_seedDataService = seedDataService;
_context = context;
_inApp = inApp;
_logger = logger;
}
/// <summary>
/// Lists all non-deleted tenant companies with search, sort, and pagination. Bypasses the
/// global multi-tenancy query filter with <c>IgnoreQueryFilters()</c> because SuperAdmins
/// need a cross-company view. Job, quote, and customer counts are fetched in three separate
/// <c>GROUP BY</c> queries rather than loading related collections, avoiding N+1 behaviour
/// on large tenants. The current impersonation context is surfaced to the view so the UI
/// can highlight the company being impersonated.
/// </summary>
// GET: Companies
public async Task<IActionResult> Index(
string? searchTerm,
string sortColumn = "CompanyName",
string sortDirection = "asc",
int pageNumber = 1,
int pageSize = 25)
{
try
{
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
var query = _context.Companies
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var s = searchTerm.ToLower();
query = query.Where(c =>
c.CompanyName.ToLower().Contains(s) ||
(c.CompanyCode != null && c.CompanyCode.ToLower().Contains(s)) ||
(c.PrimaryContactEmail != null && c.PrimaryContactEmail.ToLower().Contains(s)) ||
(c.Phone != null && c.Phone.ToLower().Contains(s)));
}
query = (sortColumn, sortDirection == "asc") switch
{
("CompanyName", true) => query.OrderBy(c => c.CompanyName),
("CompanyName", false) => query.OrderByDescending(c => c.CompanyName),
("Plan", true) => query.OrderBy(c => c.SubscriptionPlan),
("Plan", false) => query.OrderByDescending(c => c.SubscriptionPlan),
("Status", true) => query.OrderBy(c => c.IsActive),
("Status", false) => query.OrderByDescending(c => c.IsActive),
("Created", true) => query.OrderBy(c => c.CreatedAt),
("Created", false) => query.OrderByDescending(c => c.CreatedAt),
_ => query.OrderBy(c => c.CompanyName)
};
var totalCount = await query.CountAsync();
var companies = await query
.Include(c => c.Users)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
// Populate job/quote/customer counts efficiently via group queries
if (companyDtos.Any())
{
var ids = companyDtos.Select(c => c.Id).ToList();
var jobCounts = await _context.Jobs.IgnoreQueryFilters()
.Where(j => ids.Contains(j.CompanyId) && !j.IsDeleted)
.GroupBy(j => j.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var quoteCounts = await _context.Quotes.IgnoreQueryFilters()
.Where(q => ids.Contains(q.CompanyId) && !q.IsDeleted)
.GroupBy(q => q.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var customerCounts = await _context.Customers.IgnoreQueryFilters()
.Where(c => ids.Contains(c.CompanyId) && !c.IsDeleted)
.GroupBy(c => c.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var wizardData = await _context.CompanyPreferences.IgnoreQueryFilters()
.Where(p => ids.Contains(p.CompanyId) && p.SetupWizardCompleted)
.Select(p => new
{
p.CompanyId,
p.SetupWizardCompletedAt,
p.SetupWizardCompletedByName
})
.ToDictionaryAsync(x => x.CompanyId);
foreach (var dto in companyDtos)
{
dto.JobCount = jobCounts.GetValueOrDefault(dto.Id, 0);
dto.QuoteCount = quoteCounts.GetValueOrDefault(dto.Id, 0);
dto.CustomerCount = customerCounts.GetValueOrDefault(dto.Id, 0);
if (wizardData.TryGetValue(dto.Id, out var w))
{
dto.WizardCompleted = true;
dto.WizardCompletedAt = w.SetupWizardCompletedAt;
dto.WizardCompletedByName = w.SetupWizardCompletedByName;
}
}
}
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
ViewBag.SearchTerm = searchTerm;
ViewBag.SortColumn = sortColumn;
ViewBag.SortDirection = sortDirection;
ViewBag.TotalCount = totalCount;
ViewBag.PageNumber = pageNumber;
ViewBag.PageSize = pageSize;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
return View(companyDtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving companies list");
TempData["Error"] = "An error occurred while loading companies.";
return View(new List<CompanyListDto>());
}
}
/// <summary>
/// Begins a SuperAdmin impersonation session for the specified company. Stores the target
/// company ID and name in the server-side session so that <see cref="ITenantContext"/> resolves
/// to that company for the duration of the session. The action is intentionally logged at
/// Warning level because impersonation is a privileged escalation that should appear in
/// audit trails without requiring a full audit-log query.
/// </summary>
// POST: Companies/StartImpersonating
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartImpersonating(int companyId)
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null || company.IsDeleted)
return NotFound();
HttpContext.Session.SetInt32("ImpersonatingCompanyId", companyId);
HttpContext.Session.SetString("ImpersonatingCompanyName", company.CompanyName);
_logger.LogWarning("SuperAdmin {User} started impersonating company {Id} ({Name})",
User.Identity?.Name, companyId, company.CompanyName);
TempData["SuccessMessage"] = $"Now impersonating {company.CompanyName}. All data is now scoped to this company.";
return RedirectToAction("Index", "Dashboard");
}
/// <summary>
/// Ends the current impersonation session by removing the company ID and name keys from the
/// server-side session, restoring the SuperAdmin to the full platform view. The company name
/// is captured before removal so it can appear in the success message even after the session
/// key is gone.
/// </summary>
// POST: Companies/StopImpersonating
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult StopImpersonating()
{
var name = HttpContext.Session.GetString("ImpersonatingCompanyName") ?? "company";
HttpContext.Session.Remove("ImpersonatingCompanyId");
HttpContext.Session.Remove("ImpersonatingCompanyName");
_logger.LogInformation("SuperAdmin {User} stopped impersonating {Name}",
User.Identity?.Name, name);
TempData["SuccessMessage"] = "Impersonation ended. You are back to full platform view.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Shows the full details page for a single tenant company, including its user list,
/// customers, and jobs. Uses <c>ignoreQueryFilters: true</c> so deleted companies can
/// still be inspected by a SuperAdmin without needing to manually bypass the soft-delete
/// filter.
/// </summary>
// GET: Companies/Details/5
public async Task<IActionResult> Details(int id)
{
try
{
var company = await _unitOfWork.Companies
.GetByIdAsync(id, ignoreQueryFilters: true,
c => c.Users,
c => c.Customers,
c => c.Jobs);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
var companyDto = _mapper.Map<CompanyDto>(company);
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
return View(companyDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving company details for ID {CompanyId}", id);
TempData["Error"] = "An error occurred while loading company details.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Renders the new-company form pre-filled with today's subscription start date and
/// <c>IsActive = true</c>. Available subscription plans are loaded from
/// <c>SubscriptionPlanConfig</c> so the dropdown stays in sync with whatever plans are
/// currently active in the database.
/// </summary>
// GET: Companies/Create
public async Task<IActionResult> Create()
{
var model = new CreateCompanyDto
{
SubscriptionStartDate = DateTime.UtcNow,
IsActive = true
};
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
return View(model);
}
/// <summary>
/// Creates a new tenant company together with its first CompanyAdmin user account. The company
/// record is saved first so its generated ID can be assigned back to <c>Company.CompanyId</c>
/// (a self-referencing FK used internally). Company-specific lookup tables (job statuses,
/// priorities, etc.) are seeded immediately via <see cref="ISeedDataService.SeedCompanyLookupsAsync"/>
/// so the new tenant can start using the app without a separate seeding step. If user creation
/// fails after the company is saved, the company record is kept and a warning is shown — a
/// full rollback is not attempted to avoid leaving orphaned ASP.NET Identity records.
/// </summary>
// POST: Companies/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateCompanyDto model)
{
if (!ModelState.IsValid)
{
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
return View(model);
}
try
{
// Check if company code is unique
if (!string.IsNullOrWhiteSpace(model.CompanyCode))
{
var existingCompany = await _unitOfWork.Companies
.FindAsync(c => c.CompanyCode == model.CompanyCode, ignoreQueryFilters: true);
if (existingCompany.Any())
{
ModelState.AddModelError("CompanyCode", "A company with this code already exists.");
return View(model);
}
}
// Create company
var company = _mapper.Map<Company>(model);
company.CompanyId = 0; // Will be set after creation
await _unitOfWork.Companies.AddAsync(company);
await _unitOfWork.CompleteAsync();
// Update self-reference
company.CompanyId = company.Id;
await _unitOfWork.CompleteAsync();
// Seed lookup tables for the new company
_logger.LogInformation("Seeding lookup tables for new company {CompanyName}", company.CompanyName);
var seedResult = await _seedDataService.SeedCompanyLookupsAsync(company.Id);
if (!seedResult.Success)
{
_logger.LogWarning("Failed to seed lookup tables for company {CompanyName}: {Message}",
company.CompanyName, seedResult.Message);
}
else
{
_logger.LogInformation("Successfully seeded {Count} lookup items for company {CompanyName}",
seedResult.ItemsSeeded, company.CompanyName);
}
// Create admin user for the company
var adminUser = new ApplicationUser
{
UserName = model.AdminEmail,
Email = model.AdminEmail,
EmailConfirmed = true,
FirstName = model.AdminFirstName,
LastName = model.AdminLastName,
CompanyId = company.Id,
CompanyRole = AppConstants.CompanyRoles.CompanyAdmin,
IsActive = true,
HireDate = DateTime.UtcNow,
Department = "Management",
Position = "Company Administrator",
};
adminUser.GrantAllPermissions();
var result = await _userManager.CreateAsync(adminUser, model.AdminPassword);
if (result.Succeeded)
{
await _userManager.AddToRoleAsync(adminUser, AppConstants.Roles.Administrator);
_logger.LogInformation("Company {CompanyName} created successfully by {User}",
company.CompanyName, User.Identity?.Name);
_ = _inApp.CreateForSuperAdminsAsync(
"Company Created",
$"{company.CompanyName} was created manually by {User.Identity?.Name}.",
"NewCompany",
$"/Companies/Details/{company.Id}");
TempData["Success"] = $"Company '{company.CompanyName}' and admin user created successfully.";
return RedirectToAction(nameof(Details), new { id = company.Id });
}
else
{
// If user creation failed, we should consider rolling back company creation
// For now, log the error and inform the user
_logger.LogError("Failed to create admin user for company {CompanyName}: {Errors}",
company.CompanyName, string.Join(", ", result.Errors.Select(e => e.Description)));
TempData["Warning"] = $"Company created but admin user creation failed: {string.Join(", ", result.Errors.Select(e => e.Description))}";
return RedirectToAction(nameof(Details), new { id = company.Id });
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating company");
ModelState.AddModelError("", "An error occurred while creating the company.");
return View(model);
}
}
/// <summary>
/// Loads the company edit form. Uses <c>ignoreQueryFilters: true</c> so SuperAdmins can
/// edit a company that has been soft-deleted (e.g., to reactivate it).
/// </summary>
// GET: Companies/Edit/5
public async Task<IActionResult> Edit(int id)
{
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
var model = _mapper.Map<UpdateCompanyDto>(company);
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
return View(model);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading company for edit: {CompanyId}", id);
TempData["Error"] = "An error occurred while loading the company.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Saves changes to an existing company. Validates that any changed company code remains
/// unique across all tenants (excluding the record being edited). AutoMapper merges only
/// the DTO fields onto the tracked entity so fields not present in the form (e.g. internal
/// flags) are not accidentally overwritten.
/// </summary>
// POST: Companies/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateCompanyDto model)
{
if (id != model.Id)
{
return NotFound();
}
if (!ModelState.IsValid)
{
return View(model);
}
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
// Check if company code is unique (if changed)
if (!string.IsNullOrWhiteSpace(model.CompanyCode) && model.CompanyCode != company.CompanyCode)
{
var existingCompany = await _unitOfWork.Companies
.FindAsync(c => c.CompanyCode == model.CompanyCode && c.Id != id, ignoreQueryFilters: true);
if (existingCompany.Any())
{
ModelState.AddModelError("CompanyCode", "A company with this code already exists.");
return View(model);
}
}
// Update company properties
_mapper.Map(model, company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyName} updated successfully by {User}",
company.CompanyName, User.Identity?.Name);
TempData["Success"] = "Company updated successfully.";
return RedirectToAction(nameof(Details), new { id = company.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating company {CompanyId}", id);
ModelState.AddModelError("", "An error occurred while updating the company.");
return View(model);
}
}
/// <summary>
/// Flips the <c>IsActive</c> flag on a company. Deactivating a company prevents its users
/// from logging in (the app checks <c>IsActive</c> during sign-in) but keeps all data intact
/// so the company can be reactivated later without data loss. For a more permanent action
/// see <see cref="SoftDelete"/>.
/// </summary>
// POST: Companies/ToggleActive/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleActive(int id)
{
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
company.IsActive = !company.IsActive;
await _unitOfWork.CompleteAsync();
var status = company.IsActive ? "activated" : "deactivated";
_logger.LogInformation("Company {CompanyName} {Status} by {User}",
company.CompanyName, status, User.Identity?.Name);
TempData["Success"] = $"Company {status} successfully.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error toggling company active status for {CompanyId}", id);
TempData["Error"] = "An error occurred while updating the company status.";
}
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Soft-deletes a company by setting <c>IsDeleted = true</c> and <c>IsActive = false</c>,
/// and deactivates all of its users so they can no longer log in. All business data is
/// preserved for potential recovery. An audit log entry is written to <c>AuditLog</c>
/// recording who performed the action and how many users were affected. This is less
/// destructive than <see cref="HardDelete"/> and should be the default deactivation path.
/// </summary>
// POST: Companies/SoftDelete/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SoftDelete(int id)
{
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(id,
ignoreQueryFilters: true,
c => c.Users);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
var adminUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var adminName = User.Identity?.Name ?? "Unknown";
var companyName = company.CompanyName;
var userCount = company.Users.Count;
// Soft-delete the company and deactivate all users
company.IsDeleted = true;
company.IsActive = false;
company.UpdatedAt = DateTime.UtcNow;
foreach (var user in company.Users)
{
user.IsActive = false;
await _userManager.UpdateAsync(user);
}
await _unitOfWork.CompleteAsync();
// Write audit log
_context.AuditLogs.Add(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
CompanyId = id,
CompanyName = companyName,
Action = "SoftDelete",
EntityType = "Company",
EntityId = id.ToString(),
EntityDescription = companyName,
NewValues = $"IsDeleted=true, IsActive=false. {userCount} user(s) deactivated.",
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
await _context.SaveChangesAsync();
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) soft-deleted by {User}",
companyName, id, adminName);
TempData["Success"] = $"Company '{companyName}' deactivated. {userCount} user(s) disabled.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error soft-deleting company {CompanyId}", id);
TempData["Error"] = "An error occurred while deactivating the company.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Permanently deletes a company and every piece of data associated with it across all
/// tables. Requires the caller to type "DELETE" as a confirmation string to prevent
/// accidental invocation. Deletion is ordered leaf-to-root across six tiers to respect
/// foreign key constraints without disabling constraint checks. ASP.NET Identity users are
/// deleted via <c>UserManager.DeleteAsync</c> so the Identity framework cascades to its
/// own AspNetUser* tables correctly. An audit log entry is written before the method returns
/// so there is a permanent record even though the company record itself is gone.
/// This is irreversible — prefer <see cref="SoftDelete"/> for normal deactivations.
/// </summary>
// POST: Companies/HardDelete/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> HardDelete(int id, string confirmation)
{
if (confirmation != "DELETE")
{
TempData["Error"] = "Hard delete requires typing DELETE to confirm.";
return RedirectToAction(nameof(Index));
}
var company = await _context.Companies.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == id);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
var adminUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var adminName = User.Identity?.Name ?? "Unknown";
var companyName = company.CompanyName;
try
{
// ── Tier 1: Leaf children (must go before their parents) ─────────────
// JobItem children
await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// QuoteItem children
await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// AnnouncementDismissals (no CompanyId — delete by user or company-targeted announcement)
var userIds = await _userManager.Users.IgnoreQueryFilters()
.Where(u => u.CompanyId == id).Select(u => u.Id).ToListAsync();
var announcementIds = await _context.Announcements
.Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync();
await _context.AnnouncementDismissals.IgnoreQueryFilters()
.Where(x => userIds.Contains(x.UserId) || announcementIds.Contains(x.AnnouncementId))
.ExecuteDeleteAsync();
// ── Tier 2: Mid-level children ────────────────────────────────────────
await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// ── Tier 3: Top-level company entities ───────────────────────────────
// Order matters: child-side of FK must be deleted before parent-side.
// Invoices/Appointments → Customers; Bills/Expenses → Vendors
await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// Announcements are platform-wide; only delete company-targeted ones (TargetCompanyId)
await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync();
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// ── Tier 4: Company configs and lookup tables ─────────────────────────
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPriorityLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.AppointmentStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.AppointmentTypeLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PricingTiers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CompanyOperatingCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CompanyPreferences.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// ── Tier 5: Users (via Identity to cascade AspNetUser* tables) ────────
var users = await _userManager.Users.IgnoreQueryFilters()
.Where(u => u.CompanyId == id).ToListAsync();
var userCount = users.Count;
foreach (var user in users)
await _userManager.DeleteAsync(user);
// ── Tier 6: Company record ────────────────────────────────────────────
await _context.Companies.IgnoreQueryFilters().Where(c => c.Id == id).ExecuteDeleteAsync();
// Write audit log (use platform default company context — no companyId since it's gone)
_context.AuditLogs.Add(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
CompanyId = null,
CompanyName = companyName,
Action = "HardDelete",
EntityType = "Company",
EntityId = id.ToString(),
EntityDescription = companyName,
OldValues = $"Company '{companyName}' (ID:{id}) permanently deleted. {userCount} user(s) removed.",
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
await _context.SaveChangesAsync();
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) HARD DELETED by {User}. {UserCount} users removed.",
companyName, id, adminName, userCount);
TempData["Success"] = $"Company '{companyName}' and all associated data permanently deleted ({userCount} user(s) removed).";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error hard-deleting company {CompanyId}", id);
TempData["Error"] = $"An error occurred during deletion: {ex.Message}. Some data may have been partially removed.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Permanently deletes all business data for a company (jobs, quotes, customers, invoices,
/// inventory, etc.) while preserving the company record, its users, operating costs,
/// preferences, and lookup tables. Useful for resetting a demo or trial tenant back to a
/// clean state. Requires the caller to type "DELETE" as confirmation. Deletion is ordered
/// across three tiers (grandchildren → children → top-level entities) to avoid FK violations.
/// The QuickBooks migration wizard progress is also cleared. An audit log entry is written
/// to record the action. This operation is irreversible.
/// </summary>
// POST: Companies/ResetData/5
// Permanently hard-deletes all business data for a company while keeping the company record,
// its users, operating costs, preferences, and lookup tables intact.
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetData(int id, string confirmation)
{
if (confirmation != "DELETE")
{
TempData["Error"] = "Data reset requires typing DELETE to confirm.";
return RedirectToAction(nameof(Details), new { id });
}
var company = await _context.Companies.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == id);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
var adminUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var adminName = User.Identity?.Name ?? "Unknown";
var companyName = company.CompanyName;
try
{
// ── Tier 0: Grandchildren ─────────────────────────────────────────────
await _context.JobTemplateItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobTemplateItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.GiftCertificateRedemptions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CreditMemoApplications .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.OvenBatchItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// AnnouncementDismissals for company-targeted announcements
var announcementIds = await _context.Announcements
.Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync();
if (announcementIds.Any())
await _context.AnnouncementDismissals.IgnoreQueryFilters()
.Where(x => announcementIds.Contains(x.AnnouncementId))
.ExecuteDeleteAsync();
// ── Tier 1: Children ──────────────────────────────────────────────────
await _context.JobTemplateItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobStatusHistory .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobChangeHistories .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobDailyPriorities .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobTimeEntries .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.ReworkRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuotePrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuotePhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CustomerNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.MaintenanceRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.BillLineItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.BillPayments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Payments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Deposits .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InvoiceItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PurchaseOrderItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.AiItemPredictions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PowderUsageLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.ShopWorkerRoleCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.OvenBatches .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Refunds .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CreditMemos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.GiftCertificates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// ── Tier 2: Top-level business entities ──────────────────────────────
// Order matters: child-side of FK must be deleted before parent-side.
// Invoices/Appointments → Customers; Bills/PurchaseOrders/Expenses → Vendors
await _context.Invoices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Appointments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Jobs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobTemplates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Quotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Customers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Bills .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PurchaseOrders .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Expenses .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Vendors .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CatalogItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Equipment .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.OvenCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Accounts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.NotificationLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.ShopWorkers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// Company-targeted announcements only (platform-wide announcements are left alone)
await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync();
// Reset QB migration wizard progress
var prefs = await _context.CompanyPreferences.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.CompanyId == id);
if (prefs?.QbMigrationStateJson != null)
{
prefs.QbMigrationStateJson = null;
prefs.UpdatedAt = DateTime.UtcNow;
}
// Audit log
_context.AuditLogs.Add(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
CompanyId = id,
CompanyName = companyName,
Action = "ResetData",
EntityType = "Company",
EntityId = id.ToString(),
EntityDescription = companyName,
OldValues = $"All business data for company '{companyName}' (ID:{id}) permanently deleted by {adminName}. Company record, users, and settings preserved.",
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
await _context.SaveChangesAsync();
_logger.LogWarning(
"Company {CompanyName} (ID:{CompanyId}) ALL BUSINESS DATA RESET by {User}.",
companyName, id, adminName);
TempData["SuccessPermanent"] = $"All business data for '{companyName}' has been permanently deleted. The company record, users, and configuration have been preserved.";
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error resetting data for company {CompanyId}", id);
TempData["ErrorPermanent"] = $"An error occurred during data reset: {ex.Message}. Some data may have been partially removed.";
return RedirectToAction(nameof(Details), new { id });
}
}
/// <summary>
/// Renders the form for adding an additional CompanyAdmin user to an existing company.
/// Used when a company needs more than one admin or when the original admin's account must
/// be replaced (e.g., employee departure). This is a SuperAdmin action; company admins
/// create regular users via <c>CompanyUsersController</c>.
/// </summary>
// GET: Companies/CreateCompanyAdmin/5
public async Task<IActionResult> CreateCompanyAdmin(int id)
{
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
var model = new CreateCompanyAdminDto
{
CompanyId = company.Id,
CompanyName = company.CompanyName
};
return View(model);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading create company admin page for company {CompanyId}", id);
TempData["Error"] = "An error occurred while loading the page.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Creates a new CompanyAdmin Identity user for the specified company. Grants all
/// per-company permissions by default (same set as the initial admin created in
/// <see cref="Create(CreateCompanyDto)"/>) and assigns the ASP.NET Identity
/// <c>Administrator</c> role. Email uniqueness is checked before creation; if the email
/// already exists the form is returned with a model error rather than throwing an exception.
/// </summary>
// POST: Companies/CreateCompanyAdmin
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreateCompanyAdmin(CreateCompanyAdminDto model)
{
if (!ModelState.IsValid)
{
var company = await _unitOfWork.Companies.GetByIdAsync(model.CompanyId, ignoreQueryFilters: true);
if (company != null)
{
model.CompanyName = company.CompanyName;
}
return View(model);
}
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(model.CompanyId, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
// Check if user already exists
var existingUser = await _userManager.FindByEmailAsync(model.Email);
if (existingUser != null)
{
ModelState.AddModelError("Email", "A user with this email already exists.");
model.CompanyName = company.CompanyName;
return View(model);
}
// Create admin user for the company
var adminUser = new ApplicationUser
{
UserName = model.Email,
Email = model.Email,
EmailConfirmed = true,
FirstName = model.FirstName,
LastName = model.LastName,
CompanyId = company.Id,
CompanyRole = AppConstants.CompanyRoles.CompanyAdmin,
IsActive = true,
HireDate = DateTime.UtcNow,
Department = model.Department ?? "Management",
Position = model.Position ?? "Company Administrator",
PhoneNumber = model.Phone,
};
adminUser.GrantAllPermissions();
var result = await _userManager.CreateAsync(adminUser, model.Password);
if (result.Succeeded)
{
await _userManager.AddToRoleAsync(adminUser, AppConstants.Roles.Administrator);
_logger.LogInformation("Company admin {Email} created for company {CompanyName} by SuperAdmin {User}",
adminUser.Email, company.CompanyName, User.Identity?.Name);
TempData["Success"] = $"Company admin user '{adminUser.FullName}' created successfully for {company.CompanyName}.";
return RedirectToAction(nameof(Details), new { id = company.Id });
}
else
{
foreach (var error in result.Errors)
{
ModelState.AddModelError("", error.Description);
}
model.CompanyName = company.CompanyName;
return View(model);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating company admin for company {CompanyId}", model.CompanyId);
ModelState.AddModelError("", "An error occurred while creating the admin user.");
return View(model);
}
}
// ─── User Login History ──────────────────────────────────────────────────
/// <summary>
/// Returns profile info and the last 50 login-related audit entries for a single user,
/// used by the user-details offcanvas on the Company Details page. Verifies that the
/// requested user actually belongs to the specified company so that a SuperAdmin cannot
/// use this endpoint to pull audit data for users in other companies by guessing IDs.
/// </summary>
[HttpGet]
public async Task<IActionResult> UserLoginHistory(int companyId, string userId)
{
try
{
var user = await _userManager.Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Id == userId && u.CompanyId == companyId);
if (user == null)
return NotFound(new { error = "User not found." });
// Use the viewed company's timezone so timestamps match the tenant's local time
var tz = await _context.Companies
.Where(c => c.Id == companyId)
.Select(c => c.TimeZone)
.FirstOrDefaultAsync();
var logs = new List<dynamic>();
try
{
var rawLogs = await _context.AuditLogs
.AsNoTracking()
.Where(l => l.UserId == userId && l.EntityType == "ApplicationUser")
.OrderByDescending(l => l.Timestamp)
.Take(50)
.Select(l => new { l.Action, l.IpAddress, l.Timestamp, l.NewValues })
.ToListAsync();
logs = rawLogs.Select(l => (dynamic)new
{
action = l.Action,
ipAddress = l.IpAddress ?? "—",
timestamp = l.Timestamp.Tz(tz).ToString("MMM d, yyyy h:mm tt"),
note = TryParseNote(l.NewValues)
}).ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not load audit log history for user {UserId}", userId);
// Login history is non-critical — return user data with empty history
}
return Json(new
{
user = new
{
id = user.Id,
fullName = user.FullName,
email = user.Email,
companyRole = user.CompanyRole,
department = user.Department,
position = user.Position,
phone = user.PhoneNumber,
isActive = user.IsActive,
emailConfirmed = user.EmailConfirmed,
hireDate = user.HireDate != default ? user.HireDate.ToString("MMM d, yyyy") : null,
lastLoginDate = user.LastLoginDate.HasValue
? user.LastLoginDate.Value.Tz(tz).ToString("MMM d, yyyy h:mm tt")
: null,
createdAt = user.CreatedAt.Tz(tz).ToString("MMM d, yyyy")
},
loginHistory = logs
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading user login history for user {UserId} in company {CompanyId}", userId, companyId);
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>
/// Safely extracts the "note" string from the JSON stored in <c>AuditLog.NewValues</c>
/// (e.g., <c>{"note":"Company 'Acme' is deactivated"}</c>). Returns null if the field is
/// absent or the JSON cannot be parsed, so the UI can omit the column cleanly.
/// </summary>
private static string? TryParseNote(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
return doc.RootElement.TryGetProperty("note", out var prop) ? prop.GetString() : null;
}
catch { return null; }
}
}